Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 858c7bbc39 | |||
| 149123ef90 | |||
| 6bc49d1c52 | |||
| 52ffe49019 | |||
| 73fa292528 | |||
| f2c0a4581c | |||
| 367c4d8404 | |||
| 587c88980f | |||
| fcdfdb4588 | |||
| 6bbaa8d105 | |||
| eccc084441 | |||
| da6b8aba64 | |||
| 290097b4e6 | |||
| 45894cce34 | |||
| 7195906da0 | |||
| dcb466f53b | |||
| 14089766ea | |||
| 6ecabe4a04 | |||
| b205220bde | |||
| 3d5a802c6e | |||
| b5d1272f85 | |||
| e152fb8171 | |||
| d7cec1fa0e | |||
| 67c2af958a | |||
| 015e295370 | |||
| c9952bfd1d | |||
| f9aaf3712e | |||
| d2bbfe3b40 | |||
| f3fafa8ea0 | |||
| 625c58398c | |||
| a306d24f51 | |||
| 59d3e97ef0 | |||
| 0c0d0caae6 | |||
| 2c9f12623e | |||
| a55649c5f2 | |||
| 0d7c588536 | |||
| b6debcbb59 | |||
| 5847bc5795 | |||
| e662415137 | |||
| 580b087e8a | |||
| ac3c405cb2 | |||
| a594affdfa | |||
| 61e78ea672 | |||
| 6501eac38a | |||
| 7f9206ae77 | |||
| 6229f8e886 | |||
| 8ac090aff3 | |||
| 696f9d361d | |||
| 31840da9e7 | |||
| 96ec2c7d8d | |||
| 9029375247 | |||
| 95d0a1622f | |||
| 646d615e76 | |||
| 51409099fc | |||
| 22cd20e639 | |||
| e7cc1c8ca5 | |||
| 0ccb15a929 | |||
| a94ddcfbb2 | |||
| d3a9af140c | |||
| 0dc3ba0da4 | |||
| 1a94465dba | |||
| 7e256025ea | |||
| e843de42da | |||
| 4d1b2231e3 | |||
| 71f47f9037 | |||
| 79d41b6a73 | |||
| 6b7236ba97 | |||
| 40a95b5353 | |||
| 7329e00125 | |||
| 94be60ba4e | |||
| a8bc039c02 | |||
| 653deb7995 | |||
| 61f65107f2 | |||
| 664f165234 | |||
| f07e44016a | |||
| cc3ec8f0c2 | |||
| c9db75c945 | |||
| bb8665212a |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
# .npmrc is allowed as it contains the registry template
|
||||
dist
|
||||
build
|
||||
out
|
||||
coverage
|
||||
.vercel
|
||||
.turbo
|
||||
*.log
|
||||
.DS_Store
|
||||
32
.env.example
Normal file
32
.env.example
Normal file
@@ -0,0 +1,32 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.7.0
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=mintel
|
||||
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||
|
||||
# Host Config (Local)
|
||||
TRAEFIK_HOST=sample-website.localhost
|
||||
DIRECTUS_HOST=cms.sample-website.localhost
|
||||
|
||||
# Next.js
|
||||
NEXT_PUBLIC_BASE_URL=http://sample-website.localhost
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=http://localhost:8055
|
||||
DIRECTUS_KEY=sample-key-123
|
||||
DIRECTUS_SECRET=sample-secret-123
|
||||
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
|
||||
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
|
||||
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
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Code Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-and-build:
|
||||
runs-on: docker
|
||||
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
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
138
.gitea/workflows/pipeline.yml
Normal file
138
.gitea/workflows/pipeline.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
name: Monorepo Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qa:
|
||||
name: 🧪 Quality Assurance
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Install dependencies
|
||||
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
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
release:
|
||||
name: 🚀 Release
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
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: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🏷️ Release Packages (Tag-Driven)
|
||||
run: |
|
||||
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
||||
pnpm release:tag
|
||||
|
||||
build-images:
|
||||
name: 🐳 Build ${{ matrix.name }}
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: nextjs
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
name: Build-Base
|
||||
- image: runtime
|
||||
file: packages/infra/docker/Dockerfile.runtime
|
||||
name: Production Runtime
|
||||
- image: gatekeeper
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
name: Gatekeeper (Product)
|
||||
- image: directus
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
name: Directus (Base)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Release Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: docker
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
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
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Release Packages
|
||||
run: |
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
echo "🏷️ Tag detected, performing sync release..."
|
||||
pnpm sync-versions
|
||||
pnpm release:tag
|
||||
else
|
||||
echo "🚀 Push detected, looking for changesets..."
|
||||
pnpm release
|
||||
fi
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
|
||||
16
.husky/pre-push
Executable file
16
.husky/pre-push
Executable file
@@ -0,0 +1,16 @@
|
||||
|
||||
# Check if we are pushing a tag
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
if [[ "$remote_ref" == refs/tags/v* ]]; then
|
||||
TAG=${remote_ref#refs/tags/}
|
||||
echo "🏷️ Tag detected: $TAG, syncing versions..."
|
||||
pnpm sync-versions "$TAG"
|
||||
|
||||
# Stage the changed files
|
||||
git add package.json packages/*/package.json apps/*/package.json .env .env.example
|
||||
|
||||
echo "⚠️ package.json and .env files updated to match tag $TAG."
|
||||
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
|
||||
fi
|
||||
done
|
||||
3
.npmrc
3
.npmrc
@@ -2,3 +2,6 @@
|
||||
registry=https://npm.infra.mintel.me/
|
||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
|
||||
public-hoist-pattern[]=*
|
||||
shamefully-hoist=true
|
||||
|
||||
34
apps/sample-website/.gitignore
vendored
Normal file
34
apps/sample-website/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnpm-debug.log*
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# directus
|
||||
/directus/uploads
|
||||
/directus/extensions
|
||||
/.env
|
||||
38
apps/sample-website/Dockerfile
Normal file
38
apps/sample-website/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Start from the pre-built Nextjs Base image
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build-time environment variables for Next.js
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
|
||||
# Build the specific application
|
||||
RUN pnpm --filter sample-website build
|
||||
|
||||
# Production runner image
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir -p apps/sample-website/.next && chown nextjs:nodejs apps/sample-website/.next
|
||||
|
||||
# Copy standalone output and static files from the monorepo path
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sample-website/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/sample-website/.next/static ./apps/sample-website/.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
# server.js in monorepo standalone is created for each app
|
||||
CMD ["node", "apps/sample-website/server.js"]
|
||||
0
apps/sample-website/directus/extensions/.gitkeep
Normal file
0
apps/sample-website/directus/extensions/.gitkeep
Normal file
@@ -1,3 +0,0 @@
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default nextConfig;
|
||||
5
apps/sample-website/messages/en.json
Normal file
5
apps/sample-website/messages/en.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Index": {
|
||||
"title": "Welcome"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,36 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "0.1.1",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "mintel dev",
|
||||
"dev:local": "mintel dev --local",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"cms:pull:prod": "mintel directus sync pull production",
|
||||
"pagespeed:test": "mintel pagespeed"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.1.6",
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@sentry/nextjs": "10.38.0",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@mintel/next-utils": "workspace:*"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/next-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/next-config": "workspace:*"
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/sample-website/sentry.client.config.ts
Normal file
9
apps/sample-website/sentry.client.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
|
||||
initSentry({
|
||||
// Use a placeholder DSN on the client if you want to bypass ad-blockers via tunnel
|
||||
// Or just use the real DSN if you don't care about ad-blockers for errors.
|
||||
// The Mintel standard is to use the relay.
|
||||
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder for relay
|
||||
tunnel: "/errors/api/relay",
|
||||
});
|
||||
8
apps/sample-website/sentry.edge.config.ts
Normal file
8
apps/sample-website/sentry.edge.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
const env = validateMintelEnv();
|
||||
|
||||
initSentry({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
8
apps/sample-website/sentry.server.config.ts
Normal file
8
apps/sample-website/sentry.server.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
const env = validateMintelEnv();
|
||||
|
||||
initSentry({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSentryRelayHandler } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
export const POST = createSentryRelayHandler({
|
||||
dsn: validateMintelEnv().SENTRY_DSN,
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import "./globals.css";
|
||||
import {
|
||||
AnalyticsContextProvider,
|
||||
AnalyticsAutoTracker,
|
||||
} from "@mintel/next-observability/client";
|
||||
import { getAnalyticsConfig } from "@/lib/observability";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sample Website",
|
||||
@@ -11,9 +17,18 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const analyticsConfig = getAnalyticsConfig();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<AnalyticsContextProvider config={analyticsConfig}>
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsAutoTracker />
|
||||
</Suspense>
|
||||
{children}
|
||||
</AnalyticsContextProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createUmamiProxyHandler } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
export const POST = createUmamiProxyHandler({
|
||||
websiteId: validateMintelEnv().UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: validateMintelEnv().UMAMI_API_ENDPOINT,
|
||||
});
|
||||
7
apps/sample-website/src/i18n/request.ts
Normal file
7
apps/sample-website/src/i18n/request.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMintelI18nRequestConfig } from "@mintel/next-utils";
|
||||
|
||||
export default createMintelI18nRequestConfig(
|
||||
["en"],
|
||||
"en",
|
||||
(locale) => import(`../../messages/${locale}.json`),
|
||||
);
|
||||
13
apps/sample-website/src/instrumentation.ts
Normal file
13
apps/sample-website/src/instrumentation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("../sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("../sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
54
apps/sample-website/src/lib/observability.ts
Normal file
54
apps/sample-website/src/lib/observability.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
UmamiAnalyticsService,
|
||||
GotifyNotificationService,
|
||||
NoopNotificationService,
|
||||
} from "@mintel/observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
let analyticsService: any = null;
|
||||
let notificationService: any = null;
|
||||
|
||||
export function getAnalyticsConfig() {
|
||||
const isClient = typeof window !== "undefined";
|
||||
|
||||
if (isClient) {
|
||||
return {
|
||||
enabled: true,
|
||||
apiEndpoint: "/stats",
|
||||
};
|
||||
}
|
||||
|
||||
const env = validateMintelEnv();
|
||||
return {
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAnalyticsService() {
|
||||
if (analyticsService) return analyticsService;
|
||||
|
||||
const config = getAnalyticsConfig();
|
||||
analyticsService = new UmamiAnalyticsService(config);
|
||||
|
||||
return analyticsService;
|
||||
}
|
||||
|
||||
export function getNotificationService() {
|
||||
if (notificationService) return notificationService;
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
const env = validateMintelEnv();
|
||||
notificationService = new GotifyNotificationService({
|
||||
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
|
||||
url: env.GOTIFY_URL || "",
|
||||
token: env.GOTIFY_TOKEN || "",
|
||||
});
|
||||
} else {
|
||||
// Notifications are typically server-side only to protect tokens
|
||||
notificationService = new NoopNotificationService();
|
||||
}
|
||||
|
||||
return notificationService;
|
||||
}
|
||||
0
directus/schema/.gitkeep
Normal file
0
directus/schema/.gitkeep
Normal file
19
directus/schema/snapshot.yaml
Normal file
19
directus/schema/snapshot.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
version: 1
|
||||
directus: 11.15.1
|
||||
vendor: postgres
|
||||
collections: []
|
||||
fields: []
|
||||
systemFields:
|
||||
- collection: directus_activity
|
||||
field: timestamp
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: activity
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: parent
|
||||
schema:
|
||||
is_indexed: true
|
||||
relations: []
|
||||
0
directus/uploads/.gitkeep
Normal file
0
directus/uploads/.gitkeep
Normal file
73
docker-compose.yml
Normal file
73
docker-compose.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ./apps/sample-website
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
||||
NEXT_PUBLIC_TARGET: ${TARGET:-development}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
environment:
|
||||
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
|
||||
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
KEY: ${DIRECTUS_KEY:-mintel-key}
|
||||
SECRET: ${DIRECTUS_SECRET:-mintel-secret}
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
||||
DB_CLIENT: 'pg'
|
||||
DB_HOST: 'directus-db'
|
||||
DB_PORT: '5432'
|
||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
|
||||
WEBSOCKETS_ENABLED: 'true'
|
||||
PUBLIC_URL: ${DIRECTUS_URL:-http://localhost:8055}
|
||||
ports:
|
||||
- "8055:8055"
|
||||
volumes:
|
||||
- ./directus/uploads:/directus/uploads
|
||||
- ./directus/extensions:/directus/extensions
|
||||
- ./directus/schema:/directus/schema
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
||||
|
||||
directus-db:
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
environment:
|
||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
|
||||
volumes:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
@@ -1,3 +1,26 @@
|
||||
import baseConfig from "@mintel/eslint-config";
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default nextConfig;
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"packages/cms-infra/extensions/**",
|
||||
"packages/customer-manager/index.js",
|
||||
"**/*.db",
|
||||
"**/build/**",
|
||||
"**/data/**",
|
||||
"**/reference/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
],
|
||||
},
|
||||
...baseConfig,
|
||||
...nextConfig.map((config) => ({
|
||||
...config,
|
||||
files: [
|
||||
"apps/sample-website/**/*.{ts,tsx}",
|
||||
"packages/gatekeeper/**/*.{ts,tsx}",
|
||||
"../klz-2026/**/*.{ts,tsx}",
|
||||
],
|
||||
})),
|
||||
];
|
||||
|
||||
26
package.json
26
package.json
@@ -5,11 +5,17 @@
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm -r dev",
|
||||
"lint": "pnpm -r lint",
|
||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||
"test": "pnpm -r test",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"sync-versions": "tsx scripts/sync-versions.ts",
|
||||
"sync-versions": "tsx scripts/sync-versions.ts --",
|
||||
"cms:push:infra": "./scripts/sync-directus.sh push infra",
|
||||
"cms:pull:infra": "./scripts/sync-directus.sh pull infra",
|
||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
||||
"dev:infra": "docker-compose up -d directus directus-db",
|
||||
"release": "pnpm build && changeset publish",
|
||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||
"prepare": "husky"
|
||||
@@ -22,11 +28,12 @@
|
||||
"@mintel/husky-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-next": "^0.0.0",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"happy-dom": "^20.4.0",
|
||||
@@ -38,5 +45,18 @@
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
},
|
||||
"version": "1.7.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"next": "16.1.6",
|
||||
"@sentry/nextjs": "10.38.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,30 @@ The Mintel CLI is the primary automation tool for managing the monorepo and ensu
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
The CLI is intended to be used within the monorepo:
|
||||
### Using npx (Recommended)
|
||||
|
||||
Run the CLI without installing it globally. This always uses the latest version from the registry:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
npx @mintel/cli init apps/my-new-website.com
|
||||
```
|
||||
|
||||
### Global Installation
|
||||
|
||||
Install the CLI globally from the Mintel registry:
|
||||
|
||||
```bash
|
||||
npm install -g @mintel/cli
|
||||
```
|
||||
|
||||
### Development (Local Link)
|
||||
|
||||
If you are contributing to the CLI, you can link it locally:
|
||||
|
||||
```bash
|
||||
cd packages/cli
|
||||
pnpm build
|
||||
npm link
|
||||
```
|
||||
|
||||
## 🛠 Commands
|
||||
@@ -17,10 +37,11 @@ pnpm install
|
||||
Scaffolds a new, production-ready client website in the specified path.
|
||||
|
||||
```bash
|
||||
pnpm --filter @mintel/cli start init apps/my-new-website.com
|
||||
mintel init apps/my-new-website.com
|
||||
```
|
||||
|
||||
#### What it does:
|
||||
|
||||
1. **Project Structure**: Creates a modern Next.js directory layout.
|
||||
2. **Shared Configs**: Generates `package.json`, `tsconfig.json`, and `eslint.config.mjs` that extend the `@mintel` shared packages.
|
||||
3. **Localization**: Sets up a localized routing structure (`src/app/[locale]`) with `next-intl` pre-configured.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -10,7 +10,7 @@
|
||||
"mintel": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --target es2020",
|
||||
"build": "tsup",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsup src/index.ts --format esm --watch --target es2020",
|
||||
"test": "vitest run"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
@@ -10,7 +11,186 @@ const program = new Command();
|
||||
program
|
||||
.name("mintel")
|
||||
.description("CLI for Mintel monorepo management")
|
||||
.version("1.0.0");
|
||||
.version("1.0.1");
|
||||
|
||||
program
|
||||
.command("dev")
|
||||
.description("Start the development environment (Docker stack)")
|
||||
.option("-l, --local", "Run Next.js locally instead of in Docker")
|
||||
.action(async (options) => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("🚀 Starting Development Environment..."));
|
||||
|
||||
if (options.local) {
|
||||
console.log(chalk.cyan("Running Next.js locally..."));
|
||||
execSync("next dev", { stdio: "inherit" });
|
||||
} else {
|
||||
try {
|
||||
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||
// Ensure network exists
|
||||
} catch (_e) {
|
||||
// Network already exists or docker is not running
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(`
|
||||
📱 App: http://localhost:3000
|
||||
🗄️ CMS: http://localhost:8055/admin
|
||||
🚦 Traefik: http://localhost:8080
|
||||
`),
|
||||
);
|
||||
execSync(
|
||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
const directus = program
|
||||
.command("directus")
|
||||
.description("Directus management commands");
|
||||
|
||||
directus
|
||||
.command("bootstrap")
|
||||
.description("Setup Directus branding and settings")
|
||||
.action(async () => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("🎨 Bootstrapping Directus..."));
|
||||
execSync("npx tsx --env-file=.env scripts/setup-directus.ts", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
|
||||
directus
|
||||
.command("bootstrap-feedback")
|
||||
.description("Setup Directus collections and flows for Feedback")
|
||||
.action(async () => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
|
||||
// Use the logic from setup-feedback-hardened.ts
|
||||
const bootstrapScript = `
|
||||
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
|
||||
|
||||
async function setup() {
|
||||
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
|
||||
const email = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = createDirectus(url).with(authentication('json')).with(rest());
|
||||
|
||||
try {
|
||||
console.log('🔑 Authenticating...');
|
||||
await client.login(email, password);
|
||||
|
||||
const roles = await client.request(readRoles());
|
||||
const adminRole = roles.find(r => r.name === 'Administrator');
|
||||
const policies = await client.request(readPolicies());
|
||||
const adminPolicy = policies.find(p => p.name === 'Administrator');
|
||||
|
||||
console.log('🏗️ Creating Collection "visual_feedback"...');
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback',
|
||||
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
|
||||
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'url', type: 'string' },
|
||||
{ field: 'selector', type: 'string' },
|
||||
{ field: 'x', type: 'float' },
|
||||
{ field: 'y', type: 'float' },
|
||||
{ field: 'type', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'user_identity', type: 'string' },
|
||||
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (_e) { console.log(' (Collection might already exist)'); }
|
||||
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback_comments',
|
||||
meta: { icon: 'comment' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
|
||||
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
if (adminPolicy) {
|
||||
console.log('🔐 Granting ALL permissions to Administrator Policy...');
|
||||
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
|
||||
for (const action of ['create', 'read', 'update', 'delete']) {
|
||||
try {
|
||||
await client.request(createPermission({
|
||||
collection: coll,
|
||||
action,
|
||||
fields: ['*'],
|
||||
policy: adminPolicy.id
|
||||
} as any));
|
||||
} catch (_e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📊 Creating Dashboard...');
|
||||
try {
|
||||
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
|
||||
await client.request(createPanel({
|
||||
dashboard: dash.id,
|
||||
name: 'Total Feedbacks',
|
||||
type: 'metric',
|
||||
width: 12, height: 6, position_x: 1, position_y: 1,
|
||||
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
|
||||
} catch (e) { console.error('❌ FAILURE:', e); }
|
||||
}
|
||||
setup();
|
||||
`;
|
||||
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
|
||||
await fs.writeFile(tempFile, bootstrapScript);
|
||||
try {
|
||||
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
|
||||
} finally {
|
||||
await fs.remove(tempFile);
|
||||
}
|
||||
});
|
||||
|
||||
directus
|
||||
.command("sync <action> <env>")
|
||||
.description("Sync Directus data (push/pull) for a specific environment")
|
||||
.action(async (action, env) => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(
|
||||
chalk.blue(`📥 Executing Directus sync: ${action} -> ${env}...`),
|
||||
);
|
||||
execSync(`./scripts/sync-directus.sh ${action} ${env}`, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command("pagespeed")
|
||||
.description("Run PageSpeed (Lighthouse) tests")
|
||||
.action(async () => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("⚡ Running PageSpeed tests..."));
|
||||
execSync("npx tsx ./scripts/pagespeed-sitemap.ts", { stdio: "inherit" });
|
||||
});
|
||||
|
||||
program
|
||||
.command("init <path>")
|
||||
@@ -18,7 +198,7 @@ program
|
||||
.action(async (projectPath) => {
|
||||
const fullPath = path.isAbsolute(projectPath)
|
||||
? projectPath
|
||||
: path.resolve(process.cwd(), "../../", projectPath);
|
||||
: path.resolve(process.cwd(), projectPath);
|
||||
const projectName = path.basename(fullPath);
|
||||
|
||||
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
||||
@@ -34,16 +214,28 @@ program
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
dev: "next dev",
|
||||
dev: "mintel dev",
|
||||
"dev:local": "mintel dev --local",
|
||||
build: "next build",
|
||||
start: "next start",
|
||||
lint: "next lint",
|
||||
typecheck: "tsc --noEmit",
|
||||
test: "vitest run --passWithNoTests",
|
||||
"directus:bootstrap": "mintel directus bootstrap",
|
||||
"directus:push:testing": "mintel directus sync push testing",
|
||||
"directus:pull:testing": "mintel directus sync pull testing",
|
||||
"directus:push:staging": "mintel directus sync push staging",
|
||||
"directus:pull:staging": "mintel directus sync pull staging",
|
||||
"directus:push:prod": "mintel directus sync push production",
|
||||
"directus:pull:prod": "mintel directus sync pull production",
|
||||
"pagespeed:test": "mintel pagespeed",
|
||||
},
|
||||
dependencies: {
|
||||
next: "15.1.6",
|
||||
next: "16.1.6",
|
||||
react: "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@directus/sdk": "^21.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
@@ -182,11 +374,15 @@ export default createMintelI18nRequestConfig(
|
||||
// Create instrumentation.ts
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "src/instrumentation.ts"),
|
||||
`import * as Sentry from '@sentry/nextjs';
|
||||
`import { Sentry } from '@mintel/next-observability';
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
// Server-side initialization
|
||||
await import('./sentry.server.config');
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
await import('./sentry.edge.config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,34 +434,56 @@ export default function Home() {
|
||||
// Copy infra templates
|
||||
const infraPath = path.resolve(__dirname, "../../infra");
|
||||
if (await fs.pathExists(infraPath)) {
|
||||
await fs.copy(
|
||||
path.join(infraPath, "docker/Dockerfile.nextjs"),
|
||||
path.join(fullPath, "Dockerfile"),
|
||||
// Setup Dockerfile from template
|
||||
const templatePath = path.join(
|
||||
infraPath,
|
||||
"docker/Dockerfile.app-template",
|
||||
);
|
||||
await fs.copy(
|
||||
path.join(infraPath, "docker/docker-compose.template.yml"),
|
||||
path.join(fullPath, "docker-compose.yml"),
|
||||
if (await fs.pathExists(templatePath)) {
|
||||
let dockerfile = await fs.readFile(templatePath, "utf8");
|
||||
dockerfile = dockerfile.replace(/\$\{APP_NAME:-app\}/g, projectName);
|
||||
await fs.writeFile(path.join(fullPath, "Dockerfile"), dockerfile);
|
||||
}
|
||||
|
||||
// Setup docker-compose from template
|
||||
const composeTemplatePath = path.join(
|
||||
infraPath,
|
||||
"docker/docker-compose.template.yml",
|
||||
);
|
||||
if (await fs.pathExists(composeTemplatePath)) {
|
||||
let compose = await fs.readFile(composeTemplatePath, "utf8");
|
||||
compose = compose.replace(/\$\{APP_NAME:-app\}/g, projectName);
|
||||
compose = compose.replace(/\$\{PROJECT_NAME:-app\}/g, projectName);
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "docker-compose.yml"),
|
||||
compose,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
|
||||
await fs.copy(
|
||||
path.join(infraPath, "gitea/deploy-action.yml"),
|
||||
path.join(fullPath, ".gitea/workflows/deploy.yml"),
|
||||
const deployActionPath = path.join(
|
||||
infraPath,
|
||||
"gitea/deploy-action.yml",
|
||||
);
|
||||
if (await fs.pathExists(deployActionPath)) {
|
||||
await fs.copy(
|
||||
deployActionPath,
|
||||
path.join(fullPath, ".gitea/workflows/deploy.yml"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Directus structure
|
||||
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
|
||||
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "directus/uploads/.gitkeep"),
|
||||
"",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "directus/extensions/.gitkeep"),
|
||||
"",
|
||||
);
|
||||
// Create Directus structure
|
||||
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
|
||||
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
|
||||
await fs.writeFile(path.join(fullPath, "directus/uploads/.gitkeep"), "");
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "directus/extensions/.gitkeep"),
|
||||
"",
|
||||
);
|
||||
|
||||
// Create .env.example
|
||||
const envExample = `# Project
|
||||
// Create .env.example
|
||||
const envExample = `# Project
|
||||
PROJECT_NAME=${projectName}
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
@@ -294,17 +512,20 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||
SENTRY_DSN=
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
`;
|
||||
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
|
||||
const templatePath = path.join(infraPath, "templates/website");
|
||||
if (await fs.pathExists(templatePath)) {
|
||||
console.log(chalk.blue("Applying premium templates..."));
|
||||
await fs.copy(templatePath, fullPath, { overwrite: true });
|
||||
}
|
||||
# Notifications (Gotify)
|
||||
GOTIFY_URL=
|
||||
GOTIFY_TOKEN=
|
||||
`;
|
||||
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||
|
||||
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
|
||||
const templatePath = path.join(infraPath, "templates/website");
|
||||
if (await fs.pathExists(templatePath)) {
|
||||
console.log(chalk.blue("Applying premium templates..."));
|
||||
await fs.copy(templatePath, fullPath, { overwrite: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
||||
11
packages/cli/tsup.config.ts
Normal file
11
packages/cli/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
target: 'es2020',
|
||||
clean: true,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
0
packages/cms-infra/database/RELOAD_TEST
Normal file
0
packages/cms-infra/database/RELOAD_TEST
Normal file
BIN
packages/cms-infra/database/data.db
Normal file
BIN
packages/cms-infra/database/data.db
Normal file
Binary file not shown.
39
packages/cms-infra/docker-compose.yml
Normal file
39
packages/cms-infra/docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
infra-cms:
|
||||
image: directus/directus:11
|
||||
ports:
|
||||
- "8059:8055"
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
environment:
|
||||
KEY: "infra-cms-key"
|
||||
SECRET: "infra-cms-secret"
|
||||
ADMIN_EMAIL: "marc@mintel.me"
|
||||
ADMIN_PASSWORD: "Tim300493."
|
||||
DB_CLIENT: "sqlite3"
|
||||
DB_FILENAME: "/directus/database/data.db"
|
||||
WEBSOCKETS_ENABLED: "true"
|
||||
EMAIL_TRANSPORT: "smtp"
|
||||
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
|
||||
EMAIL_SMTP_PORT: "587"
|
||||
EMAIL_SMTP_USER: "postmaster@mg.mintel.me"
|
||||
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
||||
EMAIL_SMTP_SECURE: "false"
|
||||
EMAIL_FROM: "postmaster@mg.mintel.me"
|
||||
volumes:
|
||||
- ./database:/directus/database
|
||||
- ./uploads:/directus/uploads
|
||||
- ./schema:/directus/schema
|
||||
- ./extensions:/directus/extensions
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)"
|
||||
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: mintel-infra-cms-internal
|
||||
infra:
|
||||
external: true
|
||||
851
packages/cms-infra/extensions/customer-manager/index.js
Normal file
851
packages/cms-infra/extensions/customer-manager/index.js
Normal file
@@ -0,0 +1,851 @@
|
||||
import { useApi as e, defineModule as a } from "@directus/extensions-sdk";
|
||||
import {
|
||||
defineComponent as t,
|
||||
ref as l,
|
||||
onMounted as n,
|
||||
resolveComponent as i,
|
||||
resolveDirective as s,
|
||||
openBlock as d,
|
||||
createBlock as r,
|
||||
withCtx as u,
|
||||
createVNode as o,
|
||||
createElementBlock as m,
|
||||
Fragment as c,
|
||||
renderList as v,
|
||||
createTextVNode as p,
|
||||
toDisplayString as f,
|
||||
createCommentVNode as g,
|
||||
createElementVNode as y,
|
||||
withDirectives as b,
|
||||
nextTick as _,
|
||||
} from "vue";
|
||||
const h = { class: "content-wrapper" },
|
||||
x = { key: 0, class: "empty-state" },
|
||||
w = { class: "header" },
|
||||
k = { class: "header-left" },
|
||||
V = { class: "title" },
|
||||
C = { class: "subtitle" },
|
||||
M = { class: "header-right" },
|
||||
F = { class: "user-cell" },
|
||||
N = { class: "user-name" },
|
||||
z = { key: 0, class: "status-date" },
|
||||
E = { key: 0, class: "drawer-content" },
|
||||
U = { class: "form-section" },
|
||||
S = { class: "field" },
|
||||
A = { class: "drawer-actions" },
|
||||
T = { key: 0, class: "drawer-content" },
|
||||
Z = { class: "form-section" },
|
||||
j = { class: "field" },
|
||||
$ = { class: "field" },
|
||||
D = { class: "field" },
|
||||
O = { key: 1, class: "field" },
|
||||
W = { class: "drawer-actions" };
|
||||
var q = t({
|
||||
__name: "module",
|
||||
setup(a) {
|
||||
const t = e(),
|
||||
q = l([]),
|
||||
B = l(null),
|
||||
K = l([]),
|
||||
L = l(!1),
|
||||
P = l(!1),
|
||||
G = l(null),
|
||||
I = l(null),
|
||||
H = l(!1),
|
||||
J = l(!1),
|
||||
Q = l({ id: "", name: "" }),
|
||||
R = l(!1),
|
||||
X = l(!1),
|
||||
Y = l({
|
||||
id: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
temporary_password: "",
|
||||
}),
|
||||
ee = [
|
||||
{ text: "Name", value: "name", sortable: !0 },
|
||||
{ text: "E-Mail", value: "email", sortable: !0 },
|
||||
{ text: "Zuletzt eingeladen", value: "last_invited", sortable: !0 },
|
||||
];
|
||||
async function ae() {
|
||||
const e = await t.get("/items/companies", {
|
||||
params: { fields: ["id", "name"], sort: "name" },
|
||||
});
|
||||
q.value = e.data.data;
|
||||
}
|
||||
async function te(e) {
|
||||
((B.value = e), (L.value = !0));
|
||||
try {
|
||||
const a = await t.get("/items/client_users", {
|
||||
params: {
|
||||
filter: { company: { _eq: e.id } },
|
||||
fields: ["*"],
|
||||
sort: "first_name",
|
||||
},
|
||||
});
|
||||
K.value = a.data.data;
|
||||
} finally {
|
||||
L.value = !1;
|
||||
}
|
||||
}
|
||||
function le() {
|
||||
((J.value = !1), (Q.value = { id: "", name: "" }), (H.value = !0));
|
||||
}
|
||||
async function ne() {
|
||||
B.value &&
|
||||
((Q.value = { id: B.value.id, name: B.value.name }),
|
||||
(J.value = !0),
|
||||
await _(),
|
||||
(H.value = !0));
|
||||
}
|
||||
async function ie() {
|
||||
var e;
|
||||
if (Q.value.name) {
|
||||
P.value = !0;
|
||||
try {
|
||||
(J.value
|
||||
? (await t.patch(`/items/companies/${Q.value.id}`, {
|
||||
name: Q.value.name,
|
||||
}),
|
||||
(I.value = { type: "success", message: "Firma aktualisiert!" }))
|
||||
: (await t.post("/items/companies", { name: Q.value.name }),
|
||||
(I.value = { type: "success", message: "Firma angelegt!" })),
|
||||
(H.value = !1),
|
||||
await ae(),
|
||||
(null == (e = B.value) ? void 0 : e.id) === Q.value.id &&
|
||||
(B.value.name = Q.value.name));
|
||||
} catch (e) {
|
||||
I.value = { type: "danger", message: e.message };
|
||||
} finally {
|
||||
P.value = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function se() {
|
||||
((X.value = !1),
|
||||
(Y.value = {
|
||||
id: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
temporary_password: "",
|
||||
}),
|
||||
(R.value = !0));
|
||||
}
|
||||
async function de() {
|
||||
if (Y.value.email && B.value) {
|
||||
P.value = !0;
|
||||
try {
|
||||
(X.value
|
||||
? (await t.patch(`/items/client_users/${Y.value.id}`, {
|
||||
first_name: Y.value.first_name,
|
||||
last_name: Y.value.last_name,
|
||||
email: Y.value.email,
|
||||
}),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: "Mitarbeiter aktualisiert!",
|
||||
}))
|
||||
: (await t.post("/items/client_users", {
|
||||
first_name: Y.value.first_name,
|
||||
last_name: Y.value.last_name,
|
||||
email: Y.value.email,
|
||||
company: B.value.id,
|
||||
}),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: "Mitarbeiter angelegt!",
|
||||
})),
|
||||
(R.value = !1),
|
||||
await te(B.value));
|
||||
} catch (e) {
|
||||
I.value = { type: "danger", message: e.message };
|
||||
} finally {
|
||||
P.value = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function re(e) {
|
||||
const a = (null == e ? void 0 : e.item) || e;
|
||||
a &&
|
||||
a.id &&
|
||||
(async function (e) {
|
||||
((Y.value = {
|
||||
id: e.id || "",
|
||||
first_name: e.first_name || "",
|
||||
last_name: e.last_name || "",
|
||||
email: e.email || "",
|
||||
temporary_password: e.temporary_password || "",
|
||||
}),
|
||||
(X.value = !0),
|
||||
await _(),
|
||||
(R.value = !0));
|
||||
})(a);
|
||||
}
|
||||
return (
|
||||
n(() => {
|
||||
ae();
|
||||
}),
|
||||
(e, a) => {
|
||||
const l = i("v-icon"),
|
||||
n = i("v-list-item-icon"),
|
||||
_ = i("v-text-overflow"),
|
||||
ae = i("v-list-item-content"),
|
||||
ue = i("v-list-item"),
|
||||
oe = i("v-divider"),
|
||||
me = i("v-list"),
|
||||
ce = i("v-notice"),
|
||||
ve = i("v-button"),
|
||||
pe = i("v-info"),
|
||||
fe = i("v-avatar"),
|
||||
ge = i("v-chip"),
|
||||
ye = i("v-table"),
|
||||
be = i("v-input"),
|
||||
_e = i("v-drawer"),
|
||||
he = i("private-view"),
|
||||
xe = s("tooltip");
|
||||
return (
|
||||
d(),
|
||||
r(
|
||||
he,
|
||||
{ title: "Customer Manager" },
|
||||
{
|
||||
navigation: u(() => [
|
||||
o(
|
||||
me,
|
||||
{ nav: "" },
|
||||
{
|
||||
default: u(() => [
|
||||
o(
|
||||
ue,
|
||||
{ onClick: le, clickable: "" },
|
||||
{
|
||||
default: u(() => [
|
||||
o(n, null, {
|
||||
default: u(() => [
|
||||
o(l, {
|
||||
name: "add",
|
||||
color: "var(--theme--primary)",
|
||||
}),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
o(ae, null, {
|
||||
default: u(() => [
|
||||
o(_, { text: "Neue Firma anlegen" }),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
o(oe),
|
||||
(d(!0),
|
||||
m(
|
||||
c,
|
||||
null,
|
||||
v(q.value, (e) => {
|
||||
var a;
|
||||
return (
|
||||
d(),
|
||||
r(
|
||||
ue,
|
||||
{
|
||||
key: e.id,
|
||||
active:
|
||||
(null == (a = B.value) ? void 0 : a.id) ===
|
||||
e.id,
|
||||
class: "company-item",
|
||||
clickable: "",
|
||||
onClick: (a) => te(e),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(n, null, {
|
||||
default: u(() => [
|
||||
o(l, { name: "business" }),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
o(
|
||||
ae,
|
||||
null,
|
||||
{
|
||||
default: u(() => [
|
||||
o(_, { text: e.name }, null, 8, [
|
||||
"text",
|
||||
]),
|
||||
]),
|
||||
_: 2,
|
||||
},
|
||||
1024,
|
||||
),
|
||||
]),
|
||||
_: 2,
|
||||
},
|
||||
1032,
|
||||
["active", "onClick"],
|
||||
)
|
||||
);
|
||||
}),
|
||||
128,
|
||||
)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
"title-outer:after": u(() => [
|
||||
I.value
|
||||
? (d(),
|
||||
r(
|
||||
ce,
|
||||
{
|
||||
key: 0,
|
||||
type: I.value.type,
|
||||
onClose: a[0] || (a[0] = (e) => (I.value = null)),
|
||||
dismissible: "",
|
||||
},
|
||||
{ default: u(() => [p(f(I.value.message), 1)]), _: 1 },
|
||||
8,
|
||||
["type"],
|
||||
))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
default: u(() => [
|
||||
y("div", h, [
|
||||
B.value
|
||||
? (d(),
|
||||
m(
|
||||
c,
|
||||
{ key: 1 },
|
||||
[
|
||||
y("header", w, [
|
||||
y("div", k, [
|
||||
y("h1", V, f(B.value.name), 1),
|
||||
y(
|
||||
"p",
|
||||
C,
|
||||
f(K.value.length) + " Kunden-Mitarbeiter",
|
||||
1,
|
||||
),
|
||||
]),
|
||||
y("div", M, [
|
||||
b(
|
||||
(d(),
|
||||
r(
|
||||
ve,
|
||||
{
|
||||
secondary: "",
|
||||
rounded: "",
|
||||
icon: "",
|
||||
onClick: ne,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(l, { name: "edit" }),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)),
|
||||
[
|
||||
[
|
||||
xe,
|
||||
"Firma bearbeiten",
|
||||
void 0,
|
||||
{ bottom: !0 },
|
||||
],
|
||||
],
|
||||
),
|
||||
o(
|
||||
ve,
|
||||
{ primary: "", onClick: se },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[14] ||
|
||||
(a[14] = [
|
||||
p(" Mitarbeiter hinzufügen ", -1),
|
||||
])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
]),
|
||||
o(
|
||||
ye,
|
||||
{
|
||||
headers: ee,
|
||||
items: K.value,
|
||||
loading: L.value,
|
||||
class: "clickable-table",
|
||||
"fixed-header": "",
|
||||
"onClick:row": re,
|
||||
},
|
||||
{
|
||||
"item.name": u(({ item: e }) => [
|
||||
y("div", F, [
|
||||
o(
|
||||
fe,
|
||||
{ name: e.first_name, "x-small": "" },
|
||||
null,
|
||||
8,
|
||||
["name"],
|
||||
),
|
||||
y(
|
||||
"span",
|
||||
N,
|
||||
f(e.first_name) + " " + f(e.last_name),
|
||||
1,
|
||||
),
|
||||
]),
|
||||
]),
|
||||
"item.last_invited": u(({ item: e }) => {
|
||||
return [
|
||||
e.last_invited
|
||||
? (d(),
|
||||
m(
|
||||
"span",
|
||||
z,
|
||||
f(
|
||||
((t = e.last_invited),
|
||||
new Date(t).toLocaleString(
|
||||
"de-DE",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)),
|
||||
),
|
||||
1,
|
||||
))
|
||||
: (d(),
|
||||
r(
|
||||
ge,
|
||||
{ key: 1, "x-small": "" },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[15] ||
|
||||
(a[15] = [p("Noch nie", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)),
|
||||
];
|
||||
var t;
|
||||
}),
|
||||
_: 2,
|
||||
},
|
||||
1032,
|
||||
["items", "loading"],
|
||||
),
|
||||
],
|
||||
64,
|
||||
))
|
||||
: (d(),
|
||||
m("div", x, [
|
||||
o(
|
||||
pe,
|
||||
{
|
||||
title: "Firmen auswählen",
|
||||
icon: "business",
|
||||
center: "",
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
a[12] ||
|
||||
(a[12] = p(
|
||||
" Wähle eine Firma in der Navigation aus oder ",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
ve,
|
||||
{ "x-small": "", onClick: le },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[11] ||
|
||||
(a[11] = [
|
||||
p("erstelle eine neue Firma", -1),
|
||||
])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
a[13] || (a[13] = p(". ", -1)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
])),
|
||||
]),
|
||||
o(
|
||||
_e,
|
||||
{
|
||||
modelValue: H.value,
|
||||
"onUpdate:modelValue":
|
||||
a[2] || (a[2] = (e) => (H.value = e)),
|
||||
title: J.value
|
||||
? "Firma bearbeiten"
|
||||
: "Neue Firma anlegen",
|
||||
icon: "business",
|
||||
onCancel: a[3] || (a[3] = (e) => (H.value = !1)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
H.value
|
||||
? (d(),
|
||||
m("div", E, [
|
||||
y("div", U, [
|
||||
y("div", S, [
|
||||
a[16] ||
|
||||
(a[16] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Firmenname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Q.value.name,
|
||||
"onUpdate:modelValue":
|
||||
a[1] ||
|
||||
(a[1] = (e) => (Q.value.name = e)),
|
||||
placeholder: "z.B. KLZ Cables",
|
||||
autofocus: "",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
]),
|
||||
y("div", A, [
|
||||
o(
|
||||
ve,
|
||||
{
|
||||
primary: "",
|
||||
block: "",
|
||||
loading: P.value,
|
||||
onClick: ie,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[17] ||
|
||||
(a[17] = [p("Speichern", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
),
|
||||
]),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["modelValue", "title"],
|
||||
),
|
||||
o(
|
||||
_e,
|
||||
{
|
||||
modelValue: R.value,
|
||||
"onUpdate:modelValue":
|
||||
a[9] || (a[9] = (e) => (R.value = e)),
|
||||
title: X.value
|
||||
? "Mitarbeiter bearbeiten"
|
||||
: "Neuen Mitarbeiter anlegen",
|
||||
icon: "person",
|
||||
onCancel: a[10] || (a[10] = (e) => (R.value = !1)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
R.value
|
||||
? (d(),
|
||||
m("div", T, [
|
||||
y("div", Z, [
|
||||
y("div", j, [
|
||||
a[18] ||
|
||||
(a[18] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Vorname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.first_name,
|
||||
"onUpdate:modelValue":
|
||||
a[4] ||
|
||||
(a[4] = (e) =>
|
||||
(Y.value.first_name = e)),
|
||||
placeholder: "Vorname",
|
||||
autofocus: "",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
y("div", $, [
|
||||
a[19] ||
|
||||
(a[19] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Nachname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.last_name,
|
||||
"onUpdate:modelValue":
|
||||
a[5] ||
|
||||
(a[5] = (e) => (Y.value.last_name = e)),
|
||||
placeholder: "Nachname",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
y("div", D, [
|
||||
a[20] ||
|
||||
(a[20] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"E-Mail",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.email,
|
||||
"onUpdate:modelValue":
|
||||
a[6] ||
|
||||
(a[6] = (e) => (Y.value.email = e)),
|
||||
placeholder: "E-Mail Adresse",
|
||||
type: "email",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
X.value
|
||||
? (d(), r(oe, { key: 0 }))
|
||||
: g("v-if", !0),
|
||||
X.value
|
||||
? (d(),
|
||||
m("div", O, [
|
||||
a[21] ||
|
||||
(a[21] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Temporäres Passwort",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue:
|
||||
Y.value.temporary_password,
|
||||
"onUpdate:modelValue":
|
||||
a[7] ||
|
||||
(a[7] = (e) =>
|
||||
(Y.value.temporary_password = e)),
|
||||
readonly: "",
|
||||
class: "password-input",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
a[22] ||
|
||||
(a[22] = y(
|
||||
"p",
|
||||
{ class: "field-note" },
|
||||
"Wird beim Senden der Zugangsdaten automatisch generiert.",
|
||||
-1,
|
||||
)),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
y("div", W, [
|
||||
o(
|
||||
ve,
|
||||
{
|
||||
primary: "",
|
||||
block: "",
|
||||
loading: P.value,
|
||||
onClick: de,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[23] ||
|
||||
(a[23] = [p("Daten speichern", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
),
|
||||
X.value
|
||||
? (d(),
|
||||
m(
|
||||
c,
|
||||
{ key: 0 },
|
||||
[
|
||||
o(oe),
|
||||
b(
|
||||
(d(),
|
||||
r(
|
||||
ve,
|
||||
{
|
||||
secondary: "",
|
||||
block: "",
|
||||
loading: G.value === Y.value.id,
|
||||
onClick:
|
||||
a[8] ||
|
||||
(a[8] = (e) =>
|
||||
(async function (e) {
|
||||
G.value = e.id;
|
||||
try {
|
||||
if (
|
||||
(await t.post(
|
||||
"/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501",
|
||||
[e.id],
|
||||
),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: `Zugangsdaten für ${e.first_name} versendet. 📧`,
|
||||
}),
|
||||
await te(B.value),
|
||||
R.value &&
|
||||
Y.value.id === e.id)
|
||||
) {
|
||||
const a = K.value.find(
|
||||
(a) => a.id === e.id,
|
||||
);
|
||||
a &&
|
||||
(Y.value.temporary_password =
|
||||
a.temporary_password);
|
||||
}
|
||||
} catch (e) {
|
||||
I.value = {
|
||||
type: "danger",
|
||||
message: `Fehler: ${e.message}`,
|
||||
};
|
||||
} finally {
|
||||
G.value = null;
|
||||
}
|
||||
})(Y.value)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(l, {
|
||||
name: "send",
|
||||
left: "",
|
||||
}),
|
||||
a[24] ||
|
||||
(a[24] = p(
|
||||
" Zugangsdaten senden ",
|
||||
-1,
|
||||
)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
)),
|
||||
[
|
||||
[
|
||||
xe,
|
||||
"Generiert PW, speichert es und sendet E-Mail",
|
||||
void 0,
|
||||
{ bottom: !0 },
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
64,
|
||||
))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["modelValue", "title"],
|
||||
),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
}),
|
||||
B = [],
|
||||
K = [];
|
||||
!(function (e, a) {
|
||||
if (e && "undefined" != typeof document) {
|
||||
var t,
|
||||
l = !0 === a.prepend ? "prepend" : "append",
|
||||
n = !0 === a.singleTag,
|
||||
i =
|
||||
"string" == typeof a.container
|
||||
? document.querySelector(a.container)
|
||||
: document.getElementsByTagName("head")[0];
|
||||
if (n) {
|
||||
var s = B.indexOf(i);
|
||||
(-1 === s && ((s = B.push(i) - 1), (K[s] = {})),
|
||||
(t = K[s] && K[s][l] ? K[s][l] : (K[s][l] = d())));
|
||||
} else t = d();
|
||||
(65279 === e.charCodeAt(0) && (e = e.substring(1)),
|
||||
t.styleSheet
|
||||
? (t.styleSheet.cssText += e)
|
||||
: t.appendChild(document.createTextNode(e)));
|
||||
}
|
||||
function d() {
|
||||
var e = document.createElement("style");
|
||||
if ((e.setAttribute("type", "text/css"), a.attributes))
|
||||
for (var t = Object.keys(a.attributes), n = 0; n < t.length; n++)
|
||||
e.setAttribute(t[n], a.attributes[t[n]]);
|
||||
var s = "prepend" === l ? "afterbegin" : "beforeend";
|
||||
return (i.insertAdjacentElement(s, e), e);
|
||||
}
|
||||
})(
|
||||
"\n.content-wrapper[data-v-3fd11e72] { padding: 32px; height: 100%; display: flex; flex-direction: column;\n}\n.company-item[data-v-3fd11e72] { cursor: pointer;\n}\n.header[data-v-3fd11e72] { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end;\n}\n.header-right[data-v-3fd11e72] { display: flex; gap: 12px;\n}\n.title[data-v-3fd11e72] { font-size: 24px; font-weight: 800; margin-bottom: 4px;\n}\n.subtitle[data-v-3fd11e72] { color: var(--theme--foreground-subdued); font-size: 14px;\n}\n.empty-state[data-v-3fd11e72] { height: 100%; display: flex; align-items: center; justify-content: center;\n}\n.user-cell[data-v-3fd11e72] { display: flex; align-items: center; gap: 12px;\n}\n.user-name[data-v-3fd11e72] { font-weight: 600;\n}\n.status-date[data-v-3fd11e72] { font-size: 12px; color: var(--theme--foreground-subdued);\n}\n.drawer-content[data-v-3fd11e72] { padding: 24px; display: flex; flex-direction: column; gap: 32px;\n}\n.form-section[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 20px;\n}\n.field[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 8px;\n}\n.label[data-v-3fd11e72] { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px;\n}\n.field-note[data-v-3fd11e72] { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px;\n}\n.drawer-actions[data-v-3fd11e72] { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;\n}\n.password-input[data-v-3fd11e72] textarea {\n\tfont-family: var(--family-monospace);\n\tfont-weight: 800;\n\tcolor: var(--theme--primary) !important;\n\tbackground: var(--theme--background-subdued) !important;\n}\n.clickable-table[data-v-3fd11e72] tbody tr { cursor: pointer; transition: background-color 0.2s ease;\n}\n.clickable-table[data-v-3fd11e72] tbody tr:hover { background-color: var(--theme--background-subdued) !important;\n}\n[data-v-3fd11e72] .v-list-item { cursor: pointer !important;\n}\n",
|
||||
{},
|
||||
);
|
||||
var L = a({
|
||||
id: "customer-manager",
|
||||
name: "Customer Manager",
|
||||
icon: "supervisor_account",
|
||||
routes: [
|
||||
{
|
||||
path: "",
|
||||
component: ((e, a) => {
|
||||
const t = e.__vccOpts || e;
|
||||
for (const [e, l] of a) t[e] = l;
|
||||
return t;
|
||||
})(q, [
|
||||
["__scopeId", "data-v-3fd11e72"],
|
||||
["__file", "module.vue"],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
export { L as default };
|
||||
29
packages/cms-infra/extensions/customer-manager/package.json
Normal file
29
packages/cms-infra/extensions/customer-manager/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||
"icon": "supervisor_account",
|
||||
"version": "1.0.0",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Customer Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||
"icon": "view_kanban",
|
||||
"version": "1.0.0",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"index.js"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Feedback Commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
11
packages/cms-infra/package.json
Normal file
11
packages/cms-infra/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@mintel/cms-infra",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"up": "docker compose up -d",
|
||||
"down": "docker compose down",
|
||||
"logs": "docker compose logs -f"
|
||||
}
|
||||
}
|
||||
1221
packages/cms-infra/schema/snapshot.yaml
Normal file
1221
packages/cms-infra/schema/snapshot.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/cms-infra/uploads/directus-health-file
Normal file
1
packages/cms-infra/uploads/directus-health-file
Normal file
@@ -0,0 +1 @@
|
||||
xmKX5
|
||||
851
packages/customer-manager/index.js
Normal file
851
packages/customer-manager/index.js
Normal file
@@ -0,0 +1,851 @@
|
||||
import { useApi as e, defineModule as a } from "@directus/extensions-sdk";
|
||||
import {
|
||||
defineComponent as t,
|
||||
ref as l,
|
||||
onMounted as n,
|
||||
resolveComponent as i,
|
||||
resolveDirective as s,
|
||||
openBlock as d,
|
||||
createBlock as r,
|
||||
withCtx as u,
|
||||
createVNode as o,
|
||||
createElementBlock as m,
|
||||
Fragment as c,
|
||||
renderList as v,
|
||||
createTextVNode as p,
|
||||
toDisplayString as f,
|
||||
createCommentVNode as g,
|
||||
createElementVNode as y,
|
||||
withDirectives as b,
|
||||
nextTick as _,
|
||||
} from "vue";
|
||||
const h = { class: "content-wrapper" },
|
||||
x = { key: 0, class: "empty-state" },
|
||||
w = { class: "header" },
|
||||
k = { class: "header-left" },
|
||||
V = { class: "title" },
|
||||
C = { class: "subtitle" },
|
||||
M = { class: "header-right" },
|
||||
F = { class: "user-cell" },
|
||||
N = { class: "user-name" },
|
||||
z = { key: 0, class: "status-date" },
|
||||
E = { key: 0, class: "drawer-content" },
|
||||
U = { class: "form-section" },
|
||||
S = { class: "field" },
|
||||
A = { class: "drawer-actions" },
|
||||
T = { key: 0, class: "drawer-content" },
|
||||
Z = { class: "form-section" },
|
||||
j = { class: "field" },
|
||||
$ = { class: "field" },
|
||||
D = { class: "field" },
|
||||
O = { key: 1, class: "field" },
|
||||
W = { class: "drawer-actions" };
|
||||
var q = t({
|
||||
__name: "module",
|
||||
setup(a) {
|
||||
const t = e(),
|
||||
q = l([]),
|
||||
B = l(null),
|
||||
K = l([]),
|
||||
L = l(!1),
|
||||
P = l(!1),
|
||||
G = l(null),
|
||||
I = l(null),
|
||||
H = l(!1),
|
||||
J = l(!1),
|
||||
Q = l({ id: "", name: "" }),
|
||||
R = l(!1),
|
||||
X = l(!1),
|
||||
Y = l({
|
||||
id: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
temporary_password: "",
|
||||
}),
|
||||
ee = [
|
||||
{ text: "Name", value: "name", sortable: !0 },
|
||||
{ text: "E-Mail", value: "email", sortable: !0 },
|
||||
{ text: "Zuletzt eingeladen", value: "last_invited", sortable: !0 },
|
||||
];
|
||||
async function ae() {
|
||||
const e = await t.get("/items/companies", {
|
||||
params: { fields: ["id", "name"], sort: "name" },
|
||||
});
|
||||
q.value = e.data.data;
|
||||
}
|
||||
async function te(e) {
|
||||
((B.value = e), (L.value = !0));
|
||||
try {
|
||||
const a = await t.get("/items/client_users", {
|
||||
params: {
|
||||
filter: { company: { _eq: e.id } },
|
||||
fields: ["*"],
|
||||
sort: "first_name",
|
||||
},
|
||||
});
|
||||
K.value = a.data.data;
|
||||
} finally {
|
||||
L.value = !1;
|
||||
}
|
||||
}
|
||||
function le() {
|
||||
((J.value = !1), (Q.value = { id: "", name: "" }), (H.value = !0));
|
||||
}
|
||||
async function ne() {
|
||||
B.value &&
|
||||
((Q.value = { id: B.value.id, name: B.value.name }),
|
||||
(J.value = !0),
|
||||
await _(),
|
||||
(H.value = !0));
|
||||
}
|
||||
async function ie() {
|
||||
var e;
|
||||
if (Q.value.name) {
|
||||
P.value = !0;
|
||||
try {
|
||||
(J.value
|
||||
? (await t.patch(`/items/companies/${Q.value.id}`, {
|
||||
name: Q.value.name,
|
||||
}),
|
||||
(I.value = { type: "success", message: "Firma aktualisiert!" }))
|
||||
: (await t.post("/items/companies", { name: Q.value.name }),
|
||||
(I.value = { type: "success", message: "Firma angelegt!" })),
|
||||
(H.value = !1),
|
||||
await ae(),
|
||||
(null == (e = B.value) ? void 0 : e.id) === Q.value.id &&
|
||||
(B.value.name = Q.value.name));
|
||||
} catch (e) {
|
||||
I.value = { type: "danger", message: e.message };
|
||||
} finally {
|
||||
P.value = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function se() {
|
||||
((X.value = !1),
|
||||
(Y.value = {
|
||||
id: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
temporary_password: "",
|
||||
}),
|
||||
(R.value = !0));
|
||||
}
|
||||
async function de() {
|
||||
if (Y.value.email && B.value) {
|
||||
P.value = !0;
|
||||
try {
|
||||
(X.value
|
||||
? (await t.patch(`/items/client_users/${Y.value.id}`, {
|
||||
first_name: Y.value.first_name,
|
||||
last_name: Y.value.last_name,
|
||||
email: Y.value.email,
|
||||
}),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: "Mitarbeiter aktualisiert!",
|
||||
}))
|
||||
: (await t.post("/items/client_users", {
|
||||
first_name: Y.value.first_name,
|
||||
last_name: Y.value.last_name,
|
||||
email: Y.value.email,
|
||||
company: B.value.id,
|
||||
}),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: "Mitarbeiter angelegt!",
|
||||
})),
|
||||
(R.value = !1),
|
||||
await te(B.value));
|
||||
} catch (e) {
|
||||
I.value = { type: "danger", message: e.message };
|
||||
} finally {
|
||||
P.value = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function re(e) {
|
||||
const a = (null == e ? void 0 : e.item) || e;
|
||||
a &&
|
||||
a.id &&
|
||||
(async function (e) {
|
||||
((Y.value = {
|
||||
id: e.id || "",
|
||||
first_name: e.first_name || "",
|
||||
last_name: e.last_name || "",
|
||||
email: e.email || "",
|
||||
temporary_password: e.temporary_password || "",
|
||||
}),
|
||||
(X.value = !0),
|
||||
await _(),
|
||||
(R.value = !0));
|
||||
})(a);
|
||||
}
|
||||
return (
|
||||
n(() => {
|
||||
ae();
|
||||
}),
|
||||
(e, a) => {
|
||||
const l = i("v-icon"),
|
||||
n = i("v-list-item-icon"),
|
||||
_ = i("v-text-overflow"),
|
||||
ae = i("v-list-item-content"),
|
||||
ue = i("v-list-item"),
|
||||
oe = i("v-divider"),
|
||||
me = i("v-list"),
|
||||
ce = i("v-notice"),
|
||||
ve = i("v-button"),
|
||||
pe = i("v-info"),
|
||||
fe = i("v-avatar"),
|
||||
ge = i("v-chip"),
|
||||
ye = i("v-table"),
|
||||
be = i("v-input"),
|
||||
_e = i("v-drawer"),
|
||||
he = i("private-view"),
|
||||
xe = s("tooltip");
|
||||
return (
|
||||
d(),
|
||||
r(
|
||||
he,
|
||||
{ title: "Customer Manager" },
|
||||
{
|
||||
navigation: u(() => [
|
||||
o(
|
||||
me,
|
||||
{ nav: "" },
|
||||
{
|
||||
default: u(() => [
|
||||
o(
|
||||
ue,
|
||||
{ onClick: le, clickable: "" },
|
||||
{
|
||||
default: u(() => [
|
||||
o(n, null, {
|
||||
default: u(() => [
|
||||
o(l, {
|
||||
name: "add",
|
||||
color: "var(--theme--primary)",
|
||||
}),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
o(ae, null, {
|
||||
default: u(() => [
|
||||
o(_, { text: "Neue Firma anlegen" }),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
o(oe),
|
||||
(d(!0),
|
||||
m(
|
||||
c,
|
||||
null,
|
||||
v(q.value, (e) => {
|
||||
var a;
|
||||
return (
|
||||
d(),
|
||||
r(
|
||||
ue,
|
||||
{
|
||||
key: e.id,
|
||||
active:
|
||||
(null == (a = B.value) ? void 0 : a.id) ===
|
||||
e.id,
|
||||
class: "company-item",
|
||||
clickable: "",
|
||||
onClick: (a) => te(e),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(n, null, {
|
||||
default: u(() => [
|
||||
o(l, { name: "business" }),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
o(
|
||||
ae,
|
||||
null,
|
||||
{
|
||||
default: u(() => [
|
||||
o(_, { text: e.name }, null, 8, [
|
||||
"text",
|
||||
]),
|
||||
]),
|
||||
_: 2,
|
||||
},
|
||||
1024,
|
||||
),
|
||||
]),
|
||||
_: 2,
|
||||
},
|
||||
1032,
|
||||
["active", "onClick"],
|
||||
)
|
||||
);
|
||||
}),
|
||||
128,
|
||||
)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
"title-outer:after": u(() => [
|
||||
I.value
|
||||
? (d(),
|
||||
r(
|
||||
ce,
|
||||
{
|
||||
key: 0,
|
||||
type: I.value.type,
|
||||
onClose: a[0] || (a[0] = (e) => (I.value = null)),
|
||||
dismissible: "",
|
||||
},
|
||||
{ default: u(() => [p(f(I.value.message), 1)]), _: 1 },
|
||||
8,
|
||||
["type"],
|
||||
))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
default: u(() => [
|
||||
y("div", h, [
|
||||
B.value
|
||||
? (d(),
|
||||
m(
|
||||
c,
|
||||
{ key: 1 },
|
||||
[
|
||||
y("header", w, [
|
||||
y("div", k, [
|
||||
y("h1", V, f(B.value.name), 1),
|
||||
y(
|
||||
"p",
|
||||
C,
|
||||
f(K.value.length) + " Kunden-Mitarbeiter",
|
||||
1,
|
||||
),
|
||||
]),
|
||||
y("div", M, [
|
||||
b(
|
||||
(d(),
|
||||
r(
|
||||
ve,
|
||||
{
|
||||
secondary: "",
|
||||
rounded: "",
|
||||
icon: "",
|
||||
onClick: ne,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(l, { name: "edit" }),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)),
|
||||
[
|
||||
[
|
||||
xe,
|
||||
"Firma bearbeiten",
|
||||
void 0,
|
||||
{ bottom: !0 },
|
||||
],
|
||||
],
|
||||
),
|
||||
o(
|
||||
ve,
|
||||
{ primary: "", onClick: se },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[14] ||
|
||||
(a[14] = [
|
||||
p(" Mitarbeiter hinzufügen ", -1),
|
||||
])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
]),
|
||||
o(
|
||||
ye,
|
||||
{
|
||||
headers: ee,
|
||||
items: K.value,
|
||||
loading: L.value,
|
||||
class: "clickable-table",
|
||||
"fixed-header": "",
|
||||
"onClick:row": re,
|
||||
},
|
||||
{
|
||||
"item.name": u(({ item: e }) => [
|
||||
y("div", F, [
|
||||
o(
|
||||
fe,
|
||||
{ name: e.first_name, "x-small": "" },
|
||||
null,
|
||||
8,
|
||||
["name"],
|
||||
),
|
||||
y(
|
||||
"span",
|
||||
N,
|
||||
f(e.first_name) + " " + f(e.last_name),
|
||||
1,
|
||||
),
|
||||
]),
|
||||
]),
|
||||
"item.last_invited": u(({ item: e }) => {
|
||||
return [
|
||||
e.last_invited
|
||||
? (d(),
|
||||
m(
|
||||
"span",
|
||||
z,
|
||||
f(
|
||||
((t = e.last_invited),
|
||||
new Date(t).toLocaleString(
|
||||
"de-DE",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)),
|
||||
),
|
||||
1,
|
||||
))
|
||||
: (d(),
|
||||
r(
|
||||
ge,
|
||||
{ key: 1, "x-small": "" },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[15] ||
|
||||
(a[15] = [p("Noch nie", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)),
|
||||
];
|
||||
var t;
|
||||
}),
|
||||
_: 2,
|
||||
},
|
||||
1032,
|
||||
["items", "loading"],
|
||||
),
|
||||
],
|
||||
64,
|
||||
))
|
||||
: (d(),
|
||||
m("div", x, [
|
||||
o(
|
||||
pe,
|
||||
{
|
||||
title: "Firmen auswählen",
|
||||
icon: "business",
|
||||
center: "",
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
a[12] ||
|
||||
(a[12] = p(
|
||||
" Wähle eine Firma in der Navigation aus oder ",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
ve,
|
||||
{ "x-small": "", onClick: le },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[11] ||
|
||||
(a[11] = [
|
||||
p("erstelle eine neue Firma", -1),
|
||||
])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
a[13] || (a[13] = p(". ", -1)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
])),
|
||||
]),
|
||||
o(
|
||||
_e,
|
||||
{
|
||||
modelValue: H.value,
|
||||
"onUpdate:modelValue":
|
||||
a[2] || (a[2] = (e) => (H.value = e)),
|
||||
title: J.value
|
||||
? "Firma bearbeiten"
|
||||
: "Neue Firma anlegen",
|
||||
icon: "business",
|
||||
onCancel: a[3] || (a[3] = (e) => (H.value = !1)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
H.value
|
||||
? (d(),
|
||||
m("div", E, [
|
||||
y("div", U, [
|
||||
y("div", S, [
|
||||
a[16] ||
|
||||
(a[16] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Firmenname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Q.value.name,
|
||||
"onUpdate:modelValue":
|
||||
a[1] ||
|
||||
(a[1] = (e) => (Q.value.name = e)),
|
||||
placeholder: "z.B. KLZ Cables",
|
||||
autofocus: "",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
]),
|
||||
y("div", A, [
|
||||
o(
|
||||
ve,
|
||||
{
|
||||
primary: "",
|
||||
block: "",
|
||||
loading: P.value,
|
||||
onClick: ie,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[17] ||
|
||||
(a[17] = [p("Speichern", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
),
|
||||
]),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["modelValue", "title"],
|
||||
),
|
||||
o(
|
||||
_e,
|
||||
{
|
||||
modelValue: R.value,
|
||||
"onUpdate:modelValue":
|
||||
a[9] || (a[9] = (e) => (R.value = e)),
|
||||
title: X.value
|
||||
? "Mitarbeiter bearbeiten"
|
||||
: "Neuen Mitarbeiter anlegen",
|
||||
icon: "person",
|
||||
onCancel: a[10] || (a[10] = (e) => (R.value = !1)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
R.value
|
||||
? (d(),
|
||||
m("div", T, [
|
||||
y("div", Z, [
|
||||
y("div", j, [
|
||||
a[18] ||
|
||||
(a[18] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Vorname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.first_name,
|
||||
"onUpdate:modelValue":
|
||||
a[4] ||
|
||||
(a[4] = (e) =>
|
||||
(Y.value.first_name = e)),
|
||||
placeholder: "Vorname",
|
||||
autofocus: "",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
y("div", $, [
|
||||
a[19] ||
|
||||
(a[19] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Nachname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.last_name,
|
||||
"onUpdate:modelValue":
|
||||
a[5] ||
|
||||
(a[5] = (e) => (Y.value.last_name = e)),
|
||||
placeholder: "Nachname",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
y("div", D, [
|
||||
a[20] ||
|
||||
(a[20] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"E-Mail",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.email,
|
||||
"onUpdate:modelValue":
|
||||
a[6] ||
|
||||
(a[6] = (e) => (Y.value.email = e)),
|
||||
placeholder: "E-Mail Adresse",
|
||||
type: "email",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
X.value
|
||||
? (d(), r(oe, { key: 0 }))
|
||||
: g("v-if", !0),
|
||||
X.value
|
||||
? (d(),
|
||||
m("div", O, [
|
||||
a[21] ||
|
||||
(a[21] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Temporäres Passwort",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue:
|
||||
Y.value.temporary_password,
|
||||
"onUpdate:modelValue":
|
||||
a[7] ||
|
||||
(a[7] = (e) =>
|
||||
(Y.value.temporary_password = e)),
|
||||
readonly: "",
|
||||
class: "password-input",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
a[22] ||
|
||||
(a[22] = y(
|
||||
"p",
|
||||
{ class: "field-note" },
|
||||
"Wird beim Senden der Zugangsdaten automatisch generiert.",
|
||||
-1,
|
||||
)),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
y("div", W, [
|
||||
o(
|
||||
ve,
|
||||
{
|
||||
primary: "",
|
||||
block: "",
|
||||
loading: P.value,
|
||||
onClick: de,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[23] ||
|
||||
(a[23] = [p("Daten speichern", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
),
|
||||
X.value
|
||||
? (d(),
|
||||
m(
|
||||
c,
|
||||
{ key: 0 },
|
||||
[
|
||||
o(oe),
|
||||
b(
|
||||
(d(),
|
||||
r(
|
||||
ve,
|
||||
{
|
||||
secondary: "",
|
||||
block: "",
|
||||
loading: G.value === Y.value.id,
|
||||
onClick:
|
||||
a[8] ||
|
||||
(a[8] = (e) =>
|
||||
(async function (e) {
|
||||
G.value = e.id;
|
||||
try {
|
||||
if (
|
||||
(await t.post(
|
||||
"/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501",
|
||||
[e.id],
|
||||
),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: `Zugangsdaten für ${e.first_name} versendet. 📧`,
|
||||
}),
|
||||
await te(B.value),
|
||||
R.value &&
|
||||
Y.value.id === e.id)
|
||||
) {
|
||||
const a = K.value.find(
|
||||
(a) => a.id === e.id,
|
||||
);
|
||||
a &&
|
||||
(Y.value.temporary_password =
|
||||
a.temporary_password);
|
||||
}
|
||||
} catch (e) {
|
||||
I.value = {
|
||||
type: "danger",
|
||||
message: `Fehler: ${e.message}`,
|
||||
};
|
||||
} finally {
|
||||
G.value = null;
|
||||
}
|
||||
})(Y.value)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(l, {
|
||||
name: "send",
|
||||
left: "",
|
||||
}),
|
||||
a[24] ||
|
||||
(a[24] = p(
|
||||
" Zugangsdaten senden ",
|
||||
-1,
|
||||
)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
)),
|
||||
[
|
||||
[
|
||||
xe,
|
||||
"Generiert PW, speichert es und sendet E-Mail",
|
||||
void 0,
|
||||
{ bottom: !0 },
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
64,
|
||||
))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["modelValue", "title"],
|
||||
),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
}),
|
||||
B = [],
|
||||
K = [];
|
||||
!(function (e, a) {
|
||||
if (e && "undefined" != typeof document) {
|
||||
var t,
|
||||
l = !0 === a.prepend ? "prepend" : "append",
|
||||
n = !0 === a.singleTag,
|
||||
i =
|
||||
"string" == typeof a.container
|
||||
? document.querySelector(a.container)
|
||||
: document.getElementsByTagName("head")[0];
|
||||
if (n) {
|
||||
var s = B.indexOf(i);
|
||||
(-1 === s && ((s = B.push(i) - 1), (K[s] = {})),
|
||||
(t = K[s] && K[s][l] ? K[s][l] : (K[s][l] = d())));
|
||||
} else t = d();
|
||||
(65279 === e.charCodeAt(0) && (e = e.substring(1)),
|
||||
t.styleSheet
|
||||
? (t.styleSheet.cssText += e)
|
||||
: t.appendChild(document.createTextNode(e)));
|
||||
}
|
||||
function d() {
|
||||
var e = document.createElement("style");
|
||||
if ((e.setAttribute("type", "text/css"), a.attributes))
|
||||
for (var t = Object.keys(a.attributes), n = 0; n < t.length; n++)
|
||||
e.setAttribute(t[n], a.attributes[t[n]]);
|
||||
var s = "prepend" === l ? "afterbegin" : "beforeend";
|
||||
return (i.insertAdjacentElement(s, e), e);
|
||||
}
|
||||
})(
|
||||
"\n.content-wrapper[data-v-3fd11e72] { padding: 32px; height: 100%; display: flex; flex-direction: column;\n}\n.company-item[data-v-3fd11e72] { cursor: pointer;\n}\n.header[data-v-3fd11e72] { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end;\n}\n.header-right[data-v-3fd11e72] { display: flex; gap: 12px;\n}\n.title[data-v-3fd11e72] { font-size: 24px; font-weight: 800; margin-bottom: 4px;\n}\n.subtitle[data-v-3fd11e72] { color: var(--theme--foreground-subdued); font-size: 14px;\n}\n.empty-state[data-v-3fd11e72] { height: 100%; display: flex; align-items: center; justify-content: center;\n}\n.user-cell[data-v-3fd11e72] { display: flex; align-items: center; gap: 12px;\n}\n.user-name[data-v-3fd11e72] { font-weight: 600;\n}\n.status-date[data-v-3fd11e72] { font-size: 12px; color: var(--theme--foreground-subdued);\n}\n.drawer-content[data-v-3fd11e72] { padding: 24px; display: flex; flex-direction: column; gap: 32px;\n}\n.form-section[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 20px;\n}\n.field[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 8px;\n}\n.label[data-v-3fd11e72] { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px;\n}\n.field-note[data-v-3fd11e72] { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px;\n}\n.drawer-actions[data-v-3fd11e72] { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;\n}\n.password-input[data-v-3fd11e72] textarea {\n\tfont-family: var(--family-monospace);\n\tfont-weight: 800;\n\tcolor: var(--theme--primary) !important;\n\tbackground: var(--theme--background-subdued) !important;\n}\n.clickable-table[data-v-3fd11e72] tbody tr { cursor: pointer; transition: background-color 0.2s ease;\n}\n.clickable-table[data-v-3fd11e72] tbody tr:hover { background-color: var(--theme--background-subdued) !important;\n}\n[data-v-3fd11e72] .v-list-item { cursor: pointer !important;\n}\n",
|
||||
{},
|
||||
);
|
||||
var L = a({
|
||||
id: "customer-manager",
|
||||
name: "Customer Manager",
|
||||
icon: "supervisor_account",
|
||||
routes: [
|
||||
{
|
||||
path: "",
|
||||
component: ((e, a) => {
|
||||
const t = e.__vccOpts || e;
|
||||
for (const [e, l] of a) t[e] = l;
|
||||
return t;
|
||||
})(q, [
|
||||
["__scopeId", "data-v-3fd11e72"],
|
||||
["__file", "module.vue"],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
export { L as default };
|
||||
29
packages/customer-manager/package.json
Normal file
29
packages/customer-manager/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||
"icon": "supervisor_account",
|
||||
"version": "1.7.0",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Customer Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
14
packages/customer-manager/src/index.ts
Normal file
14
packages/customer-manager/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'customer-manager',
|
||||
name: 'Customer Manager',
|
||||
icon: 'supervisor_account',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
377
packages/customer-manager/src/module.vue
Normal file
377
packages/customer-manager/src/module.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<private-view title="Customer Manager">
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="openCreateCompany" clickable>
|
||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neue Firma anlegen" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="company in companies"
|
||||
:key="company.id"
|
||||
:active="selectedCompany?.id === company.id"
|
||||
class="company-item"
|
||||
clickable
|
||||
@click="selectCompany(company)"
|
||||
>
|
||||
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="company.name" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
||||
{{ notice.message }}
|
||||
</v-notice>
|
||||
</template>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div v-if="!selectedCompany" class="empty-state">
|
||||
<v-info title="Firmen auswählen" icon="business" center>
|
||||
Wähle eine Firma in der Navigation aus oder
|
||||
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">{{ selectedCompany.name }}</h1>
|
||||
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button primary @click="openCreateEmployee">
|
||||
Mitarbeiter hinzufügen
|
||||
</v-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-table
|
||||
:headers="tableHeaders"
|
||||
:items="employees"
|
||||
:loading="loading"
|
||||
class="clickable-table"
|
||||
fixed-header
|
||||
@click:row="onRowClick"
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<div class="user-cell">
|
||||
<v-avatar :name="item.first_name" x-small />
|
||||
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.last_invited`]="{ item }">
|
||||
<span v-if="item.last_invited" class="status-date">
|
||||
{{ formatDate(item.last_invited) }}
|
||||
</span>
|
||||
<v-chip v-else x-small>Noch nie</v-chip>
|
||||
</template>
|
||||
</v-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Drawer: Company Form -->
|
||||
<v-drawer
|
||||
v-model="drawerCompanyActive"
|
||||
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
|
||||
icon="business"
|
||||
@cancel="drawerCompanyActive = false"
|
||||
>
|
||||
<div v-if="drawerCompanyActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firmenname</span>
|
||||
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Employee Form -->
|
||||
<v-drawer
|
||||
v-model="drawerEmployeeActive"
|
||||
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
|
||||
icon="person"
|
||||
@cancel="drawerEmployeeActive = false"
|
||||
>
|
||||
<div v-if="drawerEmployeeActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="employeeForm.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||
</div>
|
||||
|
||||
<v-divider v-if="isEditingEmployee" />
|
||||
|
||||
<div v-if="isEditingEmployee" class="field">
|
||||
<span class="label">Temporäres Passwort</span>
|
||||
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" />
|
||||
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</v-button>
|
||||
|
||||
<template v-if="isEditingEmployee">
|
||||
<v-divider />
|
||||
<v-button
|
||||
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
||||
secondary
|
||||
block
|
||||
:loading="invitingId === employeeForm.id"
|
||||
@click="inviteUser(employeeForm)"
|
||||
>
|
||||
<v-icon name="send" left /> Zugangsdaten senden
|
||||
</v-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
|
||||
const companies = ref<any[]>([]);
|
||||
const selectedCompany = ref<any>(null);
|
||||
const employees = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const invitingId = ref<string | null>(null);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
// Forms State
|
||||
const drawerCompanyActive = ref(false);
|
||||
const isEditingCompany = ref(false);
|
||||
const companyForm = ref({ id: '', name: '' });
|
||||
|
||||
const drawerEmployeeActive = ref(false);
|
||||
const isEditingEmployee = ref(false);
|
||||
const employeeForm = ref({
|
||||
id: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
temporary_password: ''
|
||||
});
|
||||
|
||||
const tableHeaders = [
|
||||
{ text: 'Name', value: 'name', sortable: true },
|
||||
{ text: 'E-Mail', value: 'email', sortable: true },
|
||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||
];
|
||||
|
||||
async function fetchCompanies() {
|
||||
const res = await api.get('/items/companies', {
|
||||
params: {
|
||||
fields: ['id', 'name'],
|
||||
sort: 'name',
|
||||
},
|
||||
});
|
||||
companies.value = res.data.data;
|
||||
}
|
||||
|
||||
async function selectCompany(company: any) {
|
||||
selectedCompany.value = company;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get('/items/client_users', {
|
||||
params: {
|
||||
filter: { company: { _eq: company.id } },
|
||||
fields: ['*'],
|
||||
sort: 'first_name',
|
||||
},
|
||||
});
|
||||
employees.value = res.data.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Company Actions
|
||||
function openCreateCompany() {
|
||||
isEditingCompany.value = false;
|
||||
companyForm.value = { id: '', name: '' };
|
||||
drawerCompanyActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditCompany() {
|
||||
if (!selectedCompany.value) return;
|
||||
companyForm.value = {
|
||||
id: selectedCompany.value.id,
|
||||
name: selectedCompany.value.name
|
||||
};
|
||||
isEditingCompany.value = true;
|
||||
await nextTick();
|
||||
drawerCompanyActive.value = true;
|
||||
}
|
||||
|
||||
async function saveCompany() {
|
||||
if (!companyForm.value.name) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditingCompany.value) {
|
||||
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
|
||||
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/companies', { name: companyForm.value.name });
|
||||
notice.value = { type: 'success', message: 'Firma angelegt!' };
|
||||
}
|
||||
drawerCompanyActive.value = false;
|
||||
await fetchCompanies();
|
||||
if (selectedCompany.value?.id === companyForm.value.id) {
|
||||
selectedCompany.value.name = companyForm.value.name;
|
||||
}
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Employee Actions
|
||||
function openCreateEmployee() {
|
||||
isEditingEmployee.value = false;
|
||||
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', temporary_password: '' };
|
||||
drawerEmployeeActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditEmployee(item: any) {
|
||||
employeeForm.value = {
|
||||
id: item.id || '',
|
||||
first_name: item.first_name || '',
|
||||
last_name: item.last_name || '',
|
||||
email: item.email || '',
|
||||
temporary_password: item.temporary_password || ''
|
||||
};
|
||||
isEditingEmployee.value = true;
|
||||
await nextTick();
|
||||
drawerEmployeeActive.value = true;
|
||||
}
|
||||
|
||||
async function saveEmployee() {
|
||||
if (!employeeForm.value.email || !selectedCompany.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditingEmployee.value) {
|
||||
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/client_users', {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email,
|
||||
company: selectedCompany.value.id
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
||||
}
|
||||
drawerEmployeeActive.value = false;
|
||||
await selectCompany(selectedCompany.value);
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteUser(user: any) {
|
||||
invitingId.value = user.id;
|
||||
try {
|
||||
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
|
||||
notice.value = { type: 'success', message: `Zugangsdaten für ${user.first_name} versendet. 📧` };
|
||||
await selectCompany(selectedCompany.value);
|
||||
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
|
||||
const updated = employees.value.find(e => e.id === user.id);
|
||||
if (updated) {
|
||||
employeeForm.value.temporary_password = updated.temporary_password;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
|
||||
} finally {
|
||||
invitingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onRowClick(event: any) {
|
||||
const item = event?.item || event;
|
||||
if (item && item.id) {
|
||||
openEditEmployee(item);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCompanies();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
|
||||
.company-item { cursor: pointer; }
|
||||
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||
.header-right { display: flex; gap: 12px; }
|
||||
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
|
||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
|
||||
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.user-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.user-name { font-weight: 600; }
|
||||
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
|
||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.password-input :deep(textarea) {
|
||||
font-family: var(--family-monospace);
|
||||
font-weight: 800;
|
||||
color: var(--theme--primary) !important;
|
||||
background: var(--theme--background-subdued) !important;
|
||||
}
|
||||
|
||||
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; }
|
||||
.clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
|
||||
:deep(.v-list-item) { cursor: pointer !important; }
|
||||
</style>
|
||||
@@ -2,11 +2,22 @@ import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"no-unused-vars": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import nextPlugin from "@next/eslint-plugin-next";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import hooksPlugin from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
import js from "@eslint/js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
export const nextConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
/**
|
||||
* Mintel Next.js ESLint Configuration (Flat Config)
|
||||
*
|
||||
* This configuration replaces the legacy 'eslint-config-next' which
|
||||
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
|
||||
*/
|
||||
export const nextConfig = tseslint.config(
|
||||
{
|
||||
plugins: {
|
||||
"react": reactPlugin,
|
||||
"react-hooks": hooksPlugin,
|
||||
"@next/next": nextPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
// Add common browser/node globals if needed,
|
||||
// though usually handled by base configs
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"prefer-const": "warn",
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...hooksPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs["core-web-vitals"].rules,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "warn"
|
||||
}
|
||||
"@next/next/no-img-element": "warn",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
}
|
||||
];
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -20,7 +20,10 @@
|
||||
"dependencies": {
|
||||
"@eslint/eslintrc": "^3.0.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
}
|
||||
}
|
||||
|
||||
29
packages/feedback-commander/package.json
Normal file
29
packages/feedback-commander/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mintel/extension-feedback-commander",
|
||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||
"icon": "view_kanban",
|
||||
"version": "1.7.0",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Feedback Commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
14
packages/feedback-commander/src/index.ts
Normal file
14
packages/feedback-commander/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'feedback-commander',
|
||||
name: 'Feedback Commander',
|
||||
icon: 'view_kanban',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
723
packages/feedback-commander/src/module.vue
Normal file
723
packages/feedback-commander/src/module.vue
Normal file
@@ -0,0 +1,723 @@
|
||||
<template>
|
||||
<private-view title="Feedback Commander">
|
||||
<template #headline>
|
||||
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
|
||||
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
|
||||
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<div class="sidebar-header">
|
||||
<v-text-overflow text="Websites" class="header-text" />
|
||||
</div>
|
||||
<v-list nav>
|
||||
<v-list-item
|
||||
:active="currentProject === 'all'"
|
||||
@click="currentProject = 'all'"
|
||||
clickable
|
||||
>
|
||||
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
|
||||
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-for="project in projects"
|
||||
:key="project"
|
||||
:active="currentProject === project"
|
||||
@click="currentProject = project"
|
||||
clickable
|
||||
>
|
||||
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<div class="feedback-container">
|
||||
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
|
||||
<v-info icon="inbox" title="Clean Inbox" center>
|
||||
All feedback has been processed. Great job!
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<div v-if="fetchError" class="empty-state">
|
||||
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
|
||||
<v-button @click="fetchData" secondary small>Retry</v-button>
|
||||
</div>
|
||||
|
||||
<div class="operational-layout" v-else-if="items.length">
|
||||
<!-- Detailed Triage Lane -->
|
||||
<aside class="triage-lane">
|
||||
<div class="lane-header">
|
||||
<v-select
|
||||
v-model="currentStatusFilter"
|
||||
:items="statusOptions"
|
||||
small
|
||||
placeholder="Status Filter"
|
||||
/>
|
||||
</div>
|
||||
<div class="lane-content scrollbar">
|
||||
<TransitionGroup name="list">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="feedback-card"
|
||||
:class="{ active: selectedItem?.id === item.id }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
|
||||
<div class="card-body">
|
||||
<header class="card-header">
|
||||
<span class="card-user">{{ item.user_name }}</span>
|
||||
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
|
||||
</header>
|
||||
<div class="card-text">{{ item.text }}</div>
|
||||
<footer class="card-footer">
|
||||
<div class="meta-tags">
|
||||
<v-chip x-small outline>{{ item.project }}</v-chip>
|
||||
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
|
||||
</div>
|
||||
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Elaborated Master-Detail Desk -->
|
||||
<main class="processing-desk scrollbar">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
|
||||
<header class="desk-header">
|
||||
<div class="headline-group">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
|
||||
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
|
||||
</div>
|
||||
<h2>{{ selectedItem.user_name }}'s Submission</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<v-button primary @click="openDeepLink(selectedItem)">
|
||||
<v-icon name="open_in_new" left /> Open & Highlight
|
||||
</v-button>
|
||||
<v-select
|
||||
v-model="selectedItem.status"
|
||||
:items="statuses"
|
||||
inline
|
||||
@update:model-value="updateStatus"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="desk-grid">
|
||||
<!-- Message Container -->
|
||||
<div class="main-column">
|
||||
<v-card class="content-card">
|
||||
<v-card-title>
|
||||
<v-icon name="format_quote" left />
|
||||
Feedback Content
|
||||
</v-card-title>
|
||||
<v-card-text class="feedback-body">
|
||||
<div v-if="selectedItem.screenshot" class="visual-proof">
|
||||
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
|
||||
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
|
||||
</div>
|
||||
<div class="main-text">{{ selectedItem.text }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<section class="reply-section">
|
||||
<div class="section-divider">
|
||||
<v-divider />
|
||||
<span class="divider-label">Internal Communication</span>
|
||||
<v-divider />
|
||||
</div>
|
||||
|
||||
<div class="thread">
|
||||
<TransitionGroup name="thread-list">
|
||||
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
|
||||
<header class="reply-header">
|
||||
<span class="reply-user">{{ reply.user_name }}</span>
|
||||
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
|
||||
</header>
|
||||
<div class="reply-text">{{ reply.text }}</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<div v-if="!comments.length" class="empty-state-mini">
|
||||
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
|
||||
<div class="composer-actions">
|
||||
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Technical Sidebar -->
|
||||
<aside class="meta-column">
|
||||
<v-card class="meta-card">
|
||||
<v-card-title>Context</v-card-title>
|
||||
<v-card-text class="meta-list">
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="public" x-small /> Website</label>
|
||||
<strong>{{ selectedItem.project }}</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="link" x-small /> Source Path</label>
|
||||
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
|
||||
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
|
||||
</div>
|
||||
<v-divider />
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="layers" x-small /> Element Trace</label>
|
||||
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
|
||||
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
|
||||
<code class="id-code">{{ selectedItem.id }}</code>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="help-box">
|
||||
<v-icon name="help_outline" x-small />
|
||||
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-selection-desk">
|
||||
<v-info icon="touch_app" title="Select Feedback" center>
|
||||
Choose an entry from the triage list to view details and process.
|
||||
</v-info>
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const items = ref([]);
|
||||
const comments = ref([]);
|
||||
const loading = ref(true);
|
||||
const fetchError = ref(null);
|
||||
const sending = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const currentProject = ref('all');
|
||||
const currentStatusFilter = ref('open');
|
||||
const replyText = ref('');
|
||||
|
||||
const statuses = [
|
||||
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
|
||||
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
|
||||
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ text: 'All Statuses', value: 'all' },
|
||||
...statuses
|
||||
];
|
||||
|
||||
const projects = computed(() => {
|
||||
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
|
||||
return Array.from(projSet).sort();
|
||||
});
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(item => {
|
||||
const matchProject = currentProject.value === 'all' || item.project === currentProject.value;
|
||||
const status = item.status || 'open';
|
||||
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
|
||||
return matchProject && matchStatus;
|
||||
});
|
||||
});
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const response = await api.get('/items/visual_feedback', {
|
||||
params: {
|
||||
sort: '-date_created,-id',
|
||||
limit: 300
|
||||
}
|
||||
});
|
||||
items.value = response.data.data;
|
||||
} catch (e: any) {
|
||||
fetchError.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectItem(item) {
|
||||
selectedItem.value = null;
|
||||
setTimeout(async () => {
|
||||
selectedItem.value = item;
|
||||
comments.value = [];
|
||||
try {
|
||||
const response = await api.get('/items/visual_feedback_comments', {
|
||||
params: {
|
||||
filter: { feedback_id: { _eq: item.id } },
|
||||
sort: '-date_created,-id'
|
||||
}
|
||||
});
|
||||
comments.value = response.data.data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
async function updateStatus(val) {
|
||||
if (!selectedItem.value) return;
|
||||
try {
|
||||
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
||||
status: val
|
||||
});
|
||||
fetchData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReply() {
|
||||
if (!replyText.value.trim() || !selectedItem.value) return;
|
||||
sending.value = true;
|
||||
try {
|
||||
const response = await api.post('/items/visual_feedback_comments', {
|
||||
feedback_id: selectedItem.value.id,
|
||||
user_name: 'Operator',
|
||||
text: replyText.value
|
||||
});
|
||||
comments.value.unshift(response.data.data);
|
||||
replyText.value = '';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
|
||||
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatUrl(url) {
|
||||
if (!url) return '';
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
|
||||
}
|
||||
|
||||
function getDeepLinkUrl(item) {
|
||||
if (!item || !item.url) return '';
|
||||
try {
|
||||
const url = new URL(item.url);
|
||||
url.searchParams.set('fb_id', item.id);
|
||||
return url.toString();
|
||||
} catch (e) {
|
||||
return item.url + '?fb_id=' + item.id;
|
||||
}
|
||||
}
|
||||
|
||||
function openDeepLink(item) {
|
||||
const url = getDeepLinkUrl(item);
|
||||
if (url) window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function openExternal(url) {
|
||||
if (url) window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function getAssetUrl(id) {
|
||||
if (!id) return '';
|
||||
return `/assets/${id}`;
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const s = statuses.find(st => st.value === status);
|
||||
return s ? s.color : 'var(--foreground-subdued)';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feedback-container {
|
||||
height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-subdued);
|
||||
}
|
||||
|
||||
.operational-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Triage Lane Polish */
|
||||
.triage-lane {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-normal);
|
||||
border-right: 1px solid var(--border-normal);
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.lane-header {
|
||||
padding: 16px;
|
||||
background: var(--background-normal);
|
||||
border-bottom: 1px solid var(--border-normal);
|
||||
}
|
||||
|
||||
.lane-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-card {
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-subdued);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.feedback-card:hover {
|
||||
border-color: var(--border-normal);
|
||||
background: var(--background-subdued);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.feedback-card.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--background-accent);
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.card-status-bar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.card-user { font-weight: bold; color: var(--foreground-normal); }
|
||||
.card-date { color: var(--foreground-subdued); }
|
||||
|
||||
.card-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground-normal);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Processing Desk Refinement */
|
||||
.processing-desk {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.desk-content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.desk-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 2px solid var(--border-normal);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.headline-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-text { letter-spacing: 0.5px; }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.desk-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
padding: 24px;
|
||||
color: var(--foreground-normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.visual-proof {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proof-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--foreground-subdued);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.screenshot-img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-normal);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
background: var(--background-subdued);
|
||||
}
|
||||
|
||||
.main-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.reply-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.divider-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--foreground-subdued);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.reply-bubble {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-subdued);
|
||||
}
|
||||
|
||||
.reply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reply-user { font-weight: 800; color: var(--primary); }
|
||||
.reply-date { color: var(--foreground-subdued); }
|
||||
|
||||
.reply-text { font-size: 14px; line-height: 1.5; }
|
||||
|
||||
.composer {
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-normal);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-item label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: var(--foreground-subdued);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.truncate-path {
|
||||
color: var(--primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trace-code, .id-code {
|
||||
background: var(--background-subdued);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.coords { font-weight: bold; font-family: var(--family-monospace); }
|
||||
|
||||
.help-box {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.no-selection-desk {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--foreground-subdued);
|
||||
background: var(--background-subdued);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border-normal);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
|
||||
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.fade-enter-from { opacity: 0; transform: translateY(10px); }
|
||||
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
|
||||
|
||||
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
|
||||
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
|
||||
|
||||
.scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
|
||||
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
|
||||
</style>
|
||||
15
packages/gatekeeper/dev-output.log
Normal file
15
packages/gatekeeper/dev-output.log
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
> @mintel/gatekeeper@1.0.0 dev
|
||||
> next dev
|
||||
|
||||
⚠ Port 3000 is in use, trying 3001 instead.
|
||||
▲ Next.js 15.1.6
|
||||
- Local: http://localhost:3001
|
||||
- Network: http://192.168.1.126:3001
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
✓ Starting...
|
||||
warn - It seems like you don't have a global error handler set up. It is recommended that you add a global-error.js file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
✓ Ready in 2.7s
|
||||
[?25h
|
||||
5
packages/gatekeeper/messages/en.json
Normal file
5
packages/gatekeeper/messages/en.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"LoginPage": {
|
||||
"title": "Gatekeeper"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
|
||||
import { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Gatekeeper specific overrides
|
||||
basePath: '/gatekeeper',
|
||||
};
|
||||
|
||||
export default mintelNextConfig(nextConfig);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.0.0",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.1.6",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
@@ -22,6 +24,7 @@
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/next-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
7
packages/gatekeeper/postcss.config.cjs
Normal file
7
packages/gatekeeper/postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/* global module */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
12
packages/gatekeeper/public/icon-white.svg
Normal file
12
packages/gatekeeper/public/icon-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
12
packages/gatekeeper/public/logo-black.svg
Normal file
12
packages/gatekeeper/public/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/gatekeeper/public/logo-white.svg
Normal file
12
packages/gatekeeper/public/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -9,17 +9,79 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const session = cookieStore.get(authCookieName);
|
||||
|
||||
if (session?.value === password) {
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||
|
||||
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
|
||||
try {
|
||||
const url = new URL(originalUrl, `${proto}://${host}`);
|
||||
if (url.searchParams.get("gk_bypass") === password) {
|
||||
// Remove the bypass parameter from the redirect URL
|
||||
url.searchParams.delete("gk_bypass");
|
||||
const cleanUrl = url.pathname + url.search;
|
||||
const absoluteCleanUrl = `${proto}://${host}${cleanUrl}`;
|
||||
|
||||
const response = NextResponse.redirect(absoluteCleanUrl);
|
||||
|
||||
// Set the session cookie so the bypass is persistent
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: "Bypass",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
response.cookies.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (_e) {
|
||||
// URL parsing failed, proceed with normal logic
|
||||
}
|
||||
|
||||
let isAuthenticated = false;
|
||||
let identity = "Guest";
|
||||
|
||||
if (session?.value) {
|
||||
if (session.value === password) {
|
||||
isAuthenticated = true;
|
||||
} else {
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
if (payload.identity) {
|
||||
isAuthenticated = true;
|
||||
identity = payload.identity;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or old format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return new NextResponse("OK", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"X-Auth-User": identity,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
const gatekeeperUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
|
||||
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
|
||||
|
||||
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
29
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
29
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const session = cookieStore.get(authCookieName);
|
||||
|
||||
if (!session?.value) {
|
||||
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||
}
|
||||
|
||||
let identity = "Guest";
|
||||
let company = null;
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
identity = payload.identity || "Guest";
|
||||
company = payload.company || null;
|
||||
} catch (_e) {
|
||||
// Old format probably just the password
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
identity: identity,
|
||||
company: company,
|
||||
});
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Lock, ShieldCheck, ArrowRight } from "lucide-react";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const redirectUrl = (params.redirect as string) || "/";
|
||||
const error = params.error === "1";
|
||||
|
||||
const projectName = process.env.PROJECT_NAME || "Mintel";
|
||||
const projectColor = process.env.PROJECT_COLOR || "#82ed20";
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const password = formData.get("password");
|
||||
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
|
||||
if (password === expectedPassword) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, expectedPassword, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
redirect(
|
||||
`/gatekeeper/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-mintel-dark">
|
||||
{/* Background Decor */}
|
||||
<div className="absolute inset-0 bg-grid pointer-events-none opacity-20" />
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, ${projectColor}11 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md px-6 animate-in fade-in zoom-in duration-700">
|
||||
{/* Logo / Icon */}
|
||||
<div className="flex justify-center mb-12">
|
||||
<div
|
||||
className="w-20 h-20 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl"
|
||||
style={{ borderBottom: `2px solid ${projectColor}44` }}
|
||||
>
|
||||
<ShieldCheck
|
||||
className="w-10 h-10"
|
||||
style={{ color: projectColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.03] backdrop-blur-3xl border border-white/10 p-10 rounded-[48px] shadow-2xl relative overflow-hidden group">
|
||||
{/* Subtle accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-1 opacity-50"
|
||||
style={{
|
||||
background: `linear-gradient(to right, transparent, ${projectColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-3xl font-black mb-3 tracking-tighter uppercase italic flex items-center justify-center gap-2">
|
||||
{projectName.split(" ")[0]}
|
||||
<span style={{ color: projectColor }}>GATEKEEPER</span>
|
||||
</h1>
|
||||
<p className="text-white/40 text-sm font-medium">
|
||||
Restricted Infrastructure Access
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-200 p-4 rounded-2xl mb-8 text-sm flex items-center gap-3 animate-pulse">
|
||||
<Lock className="w-5 h-5 flex-shrink-0" />
|
||||
<span>Invalid access password. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-8">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black uppercase tracking-[0.3em] text-white/20 ml-5">
|
||||
Authentication Code
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full bg-white/5 border border-white/10 rounded-3xl px-8 py-6 focus:outline-none focus:ring-2 transition-all text-xl tracking-[0.5em] text-center placeholder:tracking-normal placeholder:text-white/10"
|
||||
style={{ "--tw-ring-color": `${projectColor}33` } as any}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full font-black uppercase tracking-[0.2em] py-6 rounded-3xl transition-all active:scale-[0.98] flex items-center justify-center gap-3 shadow-lg hover:shadow-mintel-green/10"
|
||||
style={{ backgroundColor: projectColor, color: "#000" }}
|
||||
>
|
||||
Verify Identity
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-[10px] font-bold text-white/10 uppercase tracking-[0.5em]">
|
||||
© 2026 {projectName} Infrastructure
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,83 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #000c1f;
|
||||
--foreground: #ffffff;
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-sans font-bold text-slate-900 tracking-tighter;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-4 text-base leading-relaxed text-slate-700;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
@layer components {
|
||||
.narrow-container {
|
||||
@apply max-w-4xl mx-auto px-6 py-10;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.2s ease-in-out 0s 2;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Newsreader } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
style: "italic",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
@@ -12,7 +21,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
244
packages/gatekeeper/src/app/login/page.tsx
Normal file
244
packages/gatekeeper/src/app/login/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const redirectUrl = (params.redirect as string) || "/";
|
||||
const error = params.error === "1";
|
||||
|
||||
const projectName = process.env.PROJECT_NAME || "Mintel";
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const adminPassword = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
|
||||
let userIdentity = "";
|
||||
let userCompany: any = null;
|
||||
|
||||
// 1. Check Global Admin (from ENV)
|
||||
if (
|
||||
adminEmail &&
|
||||
adminPassword &&
|
||||
email === adminEmail &&
|
||||
password === adminPassword
|
||||
) {
|
||||
userIdentity = "Admin";
|
||||
}
|
||||
// 2. Check Generic Code (Guest)
|
||||
else if (!email && password === expectedCode) {
|
||||
userIdentity = "Guest";
|
||||
}
|
||||
// 3. Check Lightweight Client Users (dedicated collection)
|
||||
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
||||
try {
|
||||
const clientUsersRes = await fetch(
|
||||
`${process.env.INFRA_DIRECTUS_URL}/items/client_users?filter[email][_eq]=${encodeURIComponent(
|
||||
email,
|
||||
)}&fields=*,company.*`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.INFRA_DIRECTUS_TOKEN}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (clientUsersRes.ok) {
|
||||
const { data: users } = await clientUsersRes.json();
|
||||
const clientUser = users[0];
|
||||
|
||||
// ⚠️ NOTE: Plain text check for demo/dev, should use argon2 in production
|
||||
if (
|
||||
clientUser &&
|
||||
(clientUser.password === password ||
|
||||
clientUser.temporary_password === password)
|
||||
) {
|
||||
userIdentity = clientUser.first_name || clientUser.email;
|
||||
userCompany = {
|
||||
id: clientUser.company?.id,
|
||||
name: clientUser.company?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Client User Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback to Directus Staff Auth if still not identified
|
||||
if (!userIdentity && email && password && process.env.DIRECTUS_URL) {
|
||||
try {
|
||||
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (loginRes.ok) {
|
||||
const { data } = await loginRes.json();
|
||||
const accessToken = data.access_token;
|
||||
|
||||
// Fetch user info with company depth
|
||||
const userRes = await fetch(
|
||||
`${process.env.DIRECTUS_URL}/users/me?fields=*,company.*`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (userRes.ok) {
|
||||
const { data: user } = await userRes.json();
|
||||
userIdentity = user.first_name || user.email;
|
||||
userCompany = {
|
||||
id: user.company?.id,
|
||||
name: user.company?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Directus Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIdentity) {
|
||||
const cookieStore = await cookies();
|
||||
// Store identity in the cookie (simplified for now, ideally signed)
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: userIdentity,
|
||||
company: userCompany,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
|
||||
{/* Background Decor - Signature mintel.me style */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
|
||||
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
|
||||
<div className="space-y-12 sm:space-y-16 animate-fade-in">
|
||||
{/* Top Icon Box - Signature mintel.me Black Square */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
|
||||
<Image
|
||||
src="/icon-white.svg"
|
||||
alt="Mintel"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12 animate-slide-up">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
|
||||
{projectName} <span className="text-slate-300">Gatekeeper</span>
|
||||
</h1>
|
||||
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
Infrastructure Protection
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Access Denied. Try Again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-4">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="EMAIL (OPTIONAL)"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="ACCESS CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
|
||||
>
|
||||
Unlock Access
|
||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Bottom Section - Full Branding Parity */}
|
||||
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
|
||||
<div className="h-px w-8 bg-slate-100" />
|
||||
<div className="opacity-80 transition-opacity hover:opacity-100">
|
||||
<Image
|
||||
src="/logo-black.svg"
|
||||
alt={projectName}
|
||||
width={140}
|
||||
height={40}
|
||||
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
|
||||
© 2026 MINTEL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
packages/gatekeeper/src/app/page.tsx
Normal file
5
packages/gatekeeper/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
7
packages/gatekeeper/src/i18n/request.ts
Normal file
7
packages/gatekeeper/src/i18n/request.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMintelI18nRequestConfig } from "@mintel/next-utils";
|
||||
|
||||
export default createMintelI18nRequestConfig(
|
||||
["en"],
|
||||
"en",
|
||||
(locale) => import(`../../messages/${locale}.json`),
|
||||
);
|
||||
61
packages/gatekeeper/tailwind.config.cjs
Normal file
61
packages/gatekeeper/tailwind.config.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
/* global module, require */
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
xl: "1rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "2rem",
|
||||
full: "9999px",
|
||||
},
|
||||
colors: {
|
||||
slate: {
|
||||
850: "#1e293b",
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "Inter", "system-ui", "sans-serif"],
|
||||
serif: ["var(--font-newsreader)", "Georgia", "serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fadeIn 0.5s ease-in-out",
|
||||
"slide-up": "slideUp 0.6s ease-out",
|
||||
"slide-down": "slideDown 0.6s ease-out",
|
||||
shake: "shake 0.2s ease-in-out 0s 2",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { transform: "translateY(20px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
slideDown: {
|
||||
"0%": { transform: "translateY(-20px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
shake: {
|
||||
"0%, 100%": { transform: "translateX(0)" },
|
||||
"25%": { transform: "translateX(-4px)" },
|
||||
"75%": { transform: "translateX(4px)" },
|
||||
},
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
industrial: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
mintel: {
|
||||
green: "#82ed20",
|
||||
blue: "#001a4d",
|
||||
dark: "#000c1f",
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
export default {
|
||||
const config = {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: {
|
||||
"header-max-length": [2, "always", 150],
|
||||
"header-max-length": [2, "always", 250],
|
||||
"subject-case": [0],
|
||||
"subject-full-stop": [0],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
export default {
|
||||
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||
/* global process */
|
||||
import path from "node:path";
|
||||
|
||||
const buildLintCommand = (filenames) => {
|
||||
const isNext =
|
||||
process.env.npm_package_devDependencies_next ||
|
||||
process.env.npm_package_dependencies_next;
|
||||
|
||||
if (isNext) {
|
||||
return `next lint --fix --file ${filenames
|
||||
.map((f) => path.relative(process.cwd(), f))
|
||||
.join(" --file ")}`;
|
||||
}
|
||||
|
||||
return "eslint --fix --no-warn-ignored";
|
||||
};
|
||||
|
||||
const config = {
|
||||
"*.{js,jsx,ts,tsx}": [buildLintCommand, "prettier --write"],
|
||||
"*.{json,md,css,scss}": ["prettier --write"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/husky-config",
|
||||
"version": "1.0.0",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
33
packages/infra/docker/Dockerfile.app-template
Normal file
33
packages/infra/docker/Dockerfile.app-template
Normal file
@@ -0,0 +1,33 @@
|
||||
# Start from the pre-built Nextjs Base image
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build-time environment variables for Next.js
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
|
||||
# Build the specific application
|
||||
RUN pnpm --filter ${APP_NAME:-app} build
|
||||
|
||||
# Production runner image
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Copy standalone output and static files
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/public ./apps/${APP_NAME:-app}/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/.next/static ./apps/${APP_NAME:-app}/.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
CMD ["node", "apps/${APP_NAME:-app}/server.js"]
|
||||
12
packages/infra/docker/Dockerfile.directus
Normal file
12
packages/infra/docker/Dockerfile.directus
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM directus/directus:11
|
||||
|
||||
# Add any custom extensions or configurations here if needed
|
||||
# COPY ./extensions /directus/extensions
|
||||
|
||||
# Default environment for optimized production use
|
||||
ENV LOGGER_LEVEL="info"
|
||||
ENV WEBSOCKETS_ENABLED="true"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8055/health || exit 1
|
||||
@@ -1,47 +1,58 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Step 1: Builder stage
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm
|
||||
ENV CI=true
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && pnpm i --frozen-lockfile
|
||||
# Copy manifest files specifically for better layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
||||
COPY packages/gatekeeper/package.json ./packages/gatekeeper/package.json
|
||||
COPY packages/next-utils/package.json ./packages/next-utils/package.json
|
||||
COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
|
||||
COPY packages/next-config/package.json ./packages/next-config/package.json
|
||||
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
|
||||
COPY packages/infra/package.json ./packages/infra/package.json
|
||||
COPY packages/cms-infra/package.json ./packages/cms-infra/package.json
|
||||
COPY packages/mail/package.json ./packages/mail/package.json
|
||||
COPY packages/cli/package.json ./packages/cli/package.json
|
||||
COPY packages/observability/package.json ./packages/observability/package.json
|
||||
COPY packages/next-observability/package.json ./packages/next-observability/package.json
|
||||
COPY packages/husky-config/package.json ./packages/husky-config/package.json
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Use a secret for NPM_TOKEN and a cache mount for the pnpm store
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Copy the rest of the source
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Build Gatekeeper and its dependencies
|
||||
RUN --mount=type=cache,target=/app/packages/gatekeeper/.next/cache \
|
||||
pnpm --filter @mintel/gatekeeper... build
|
||||
RUN mkdir -p packages/gatekeeper/public
|
||||
|
||||
# Build the application
|
||||
RUN corepack enable pnpm && pnpm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# Step 2: Runner stage
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "packages/gatekeeper/server.js"]
|
||||
|
||||
@@ -1,66 +1,38 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Step 1: Builder image
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
else npm i; fi
|
||||
# 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
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# 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 . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build-time environment variables for Next.js
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
|
||||
# Build the application
|
||||
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else npm run build; fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
RUN pnpm --filter "./packages/*" -r build
|
||||
|
||||
22
packages/infra/docker/Dockerfile.runtime
Normal file
22
packages/infra/docker/Dockerfile.runtime
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,6 +1,5 @@
|
||||
services:
|
||||
app:
|
||||
image: registry.infra.mintel.me/mintel/${APP_NAME:-app}:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
@@ -40,7 +39,6 @@ services:
|
||||
|
||||
gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||
container_name: ${PROJECT_NAME}-gatekeeper
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
@@ -55,7 +53,7 @@ services:
|
||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||
|
||||
directus:
|
||||
image: directus/directus:11
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
|
||||
@@ -24,6 +24,8 @@ jobs:
|
||||
prepare:
|
||||
name: 🔍 Prepare Environment
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
target: ${{ steps.determine.outputs.target }}
|
||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||
@@ -39,17 +41,23 @@ jobs:
|
||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||
commit_msg: ${{ steps.determine.outputs.commit_msg }}
|
||||
steps:
|
||||
- name: 🧹 Maintenance (High Density Cleanup)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=6h"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 2
|
||||
|
||||
- name: 🔍 Environment & Version ermitteln
|
||||
id: determine
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
SHORT_SHA="${{ github.sha }}"
|
||||
SHORT_SHA="${SHORT_SHA:0:9}"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9)
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
|
||||
|
||||
# Base Domain (e.g. example.com)
|
||||
@@ -79,7 +87,6 @@ jobs:
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$TAG"
|
||||
ENV_FILE=".env.prod"
|
||||
TRAEFIK_HOST="\${DOMAIN_BASE}, www.\${DOMAIN_BASE}" # Note: Host() backticks usually needed in compose
|
||||
TRAEFIK_HOST="\`\${DOMAIN_BASE}\`, \`www.\${DOMAIN_BASE}\`"
|
||||
NEXT_PUBLIC_BASE_URL="https://\${DOMAIN_BASE}"
|
||||
DIRECTUS_URL="https://cms.\${DOMAIN_BASE}"
|
||||
@@ -88,7 +95,7 @@ jobs:
|
||||
IS_PROD="true"
|
||||
GOTIFY_TITLE="🚀 Production-Release"
|
||||
GOTIFY_PRIORITY=6
|
||||
elif [[ "$TAG" =~ -rc\. || "$TAG" =~ -beta\. || "$TAG" =~ -alpha\. ]]; then
|
||||
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then
|
||||
TARGET="staging"
|
||||
IMAGE_TAG="$TAG"
|
||||
ENV_FILE=".env.staging"
|
||||
@@ -107,19 +114,21 @@ jobs:
|
||||
TARGET="skip"
|
||||
fi
|
||||
|
||||
echo "target=$TARGET" >> $GITHUB_OUTPUT
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
|
||||
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
|
||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT
|
||||
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT
|
||||
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT
|
||||
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
|
||||
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
|
||||
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
|
||||
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "target=$TARGET"
|
||||
echo "image_tag=$IMAGE_TAG"
|
||||
echo "env_file=$ENV_FILE"
|
||||
echo "traefik_host=$TRAEFIK_HOST"
|
||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
||||
echo "directus_url=$DIRECTUS_URL"
|
||||
echo "directus_host=$DIRECTUS_HOST"
|
||||
echo "project_name=$PROJECT_NAME"
|
||||
echo "is_prod=$IS_PROD"
|
||||
echo "gotify_title=$GOTIFY_TITLE"
|
||||
echo "gotify_priority=$GOTIFY_PRIORITY"
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
echo "commit_msg=$COMMIT_MSG"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: Quality Assurance (Lint & Test)
|
||||
@@ -129,6 +138,8 @@ jobs:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -137,17 +148,23 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: 🧪 Run Checks
|
||||
- name: 🧪 Run Checks in Parallel
|
||||
if: github.event.inputs.skip_long_checks != 'true'
|
||||
run: |
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run lint &
|
||||
LINT_PID=$!
|
||||
npm run typecheck &
|
||||
TYPE_PID=$!
|
||||
npm run test &
|
||||
TEST_PID=$!
|
||||
|
||||
wait $LINT_PID || exit 1
|
||||
wait $TYPE_PID || exit 1
|
||||
wait $TEST_PID || exit 1
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push
|
||||
@@ -157,25 +174,38 @@ jobs:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Docker Build & Push
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$IMAGE_TAG \
|
||||
--push .
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache,mode=max
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
@@ -185,18 +215,18 @@ jobs:
|
||||
needs: [prepare, build, qa]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🚀 Deploy via SSH
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
@@ -208,12 +238,13 @@ jobs:
|
||||
# Generated by CI - $TARGET - $(date -u)
|
||||
NODE_ENV=production
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
ENV_FILE=$ENV_FILE
|
||||
|
||||
# App Config
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
||||
NEXT_PUBLIC_TARGET=$TARGET
|
||||
|
||||
# Directus Config
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
@@ -237,10 +268,11 @@ jobs:
|
||||
ssh root@${{ secrets.SSH_HOST }} IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
||||
set -e
|
||||
cd "/home/deploy/sites/${{ github.event.repository.name }}"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
docker system prune -f --filter "until=168h"
|
||||
docker system prune -f --filter "until=24h"
|
||||
EOF
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -251,11 +283,25 @@ jobs:
|
||||
needs: [prepare, deploy]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
- name: 🔔 Gotify - Success
|
||||
if: needs.deploy.result == 'success'
|
||||
run: |
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
|
||||
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**" \
|
||||
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }}\nRun: ${{ github.run_id }}" \
|
||||
-F "priority=4" || true
|
||||
|
||||
- name: 🔔 Gotify - Failure
|
||||
if: |
|
||||
needs.prepare.result == 'failure' ||
|
||||
needs.qa.result == 'failure' ||
|
||||
needs.build.result == 'failure' ||
|
||||
needs.deploy.result == 'failure'
|
||||
run: |
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ github.event.repository.name }}" \
|
||||
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target || 'unknown' }}**\n\nRun: ${{ github.run_id }}\nBitte Logs prüfen!" \
|
||||
-F "priority=8" || true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/infra",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
60
packages/infra/scripts/prune-registry.sh
Normal file
60
packages/infra/scripts/prune-registry.sh
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_DATA="/opt/infra/registry/data/docker/registry/v2"
|
||||
KEEP_TAGS=3
|
||||
|
||||
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
|
||||
|
||||
# 1. Prune Registry Tags (Filesystem level)
|
||||
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||
repo_name=$(basename "$repo_dir")
|
||||
tags_dir="$repo_dir/_manifests/tags"
|
||||
|
||||
if [ -d "$tags_dir" ]; then
|
||||
echo "🔍 Processing repository: mintel/$repo_name"
|
||||
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Run Garbage Collection
|
||||
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..."
|
||||
docker system prune -af --filter "until=24h"
|
||||
docker volume prune -f
|
||||
|
||||
echo "✅ Maintenance complete!"
|
||||
df -h /
|
||||
68
packages/infra/scripts/sync-directus.sh
Normal file
68
packages/infra/scripts/sync-directus.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mintel Directus Sync Engine
|
||||
# Synchronizes Directus Data (Postgres + Uploads) between Local and Remote
|
||||
|
||||
REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}"
|
||||
ACTION=$1
|
||||
ENV=$2
|
||||
|
||||
# Help
|
||||
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
|
||||
echo "Usage: mintel-sync [push|pull] [testing|staging|production]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " push Sync LOCAL data -> REMOTE"
|
||||
echo " pull Sync REMOTE data -> LOCAL"
|
||||
echo ""
|
||||
echo "Environments:"
|
||||
echo " testing, staging, production"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///')
|
||||
case $ENV in
|
||||
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
|
||||
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
|
||||
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
|
||||
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
|
||||
esac
|
||||
|
||||
REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
|
||||
|
||||
# DB Details
|
||||
DB_USER="directus"
|
||||
DB_NAME="directus"
|
||||
|
||||
echo "🔍 Detecting local database..."
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||
echo "❌ Local directus-db container not found. Running?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ACTION" == "push" ]; then
|
||||
echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..."
|
||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
|
||||
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
|
||||
|
||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
||||
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
|
||||
|
||||
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
|
||||
rm dump.sql
|
||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||
echo "✨ Push complete!"
|
||||
|
||||
elif [ "$ACTION" == "pull" ]; then
|
||||
echo "📥 Pulling $ENV -> LOCAL..."
|
||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
|
||||
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
|
||||
|
||||
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
|
||||
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
|
||||
rm dump.sql
|
||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||
echo "✨ Pull complete!"
|
||||
fi
|
||||
5
packages/infra/templates/website/.gitignore
vendored
5
packages/infra/templates/website/.gitignore
vendored
@@ -27,3 +27,8 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# directus
|
||||
/directus/uploads
|
||||
/directus/extensions
|
||||
/.env
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
import client, { ensureAuthenticated } from "../src/lib/directus";
|
||||
import {
|
||||
createMintelDirectusClient,
|
||||
ensureDirectusAuthenticated,
|
||||
} from "@mintel/next-utils";
|
||||
import { updateSettings } from "@directus/sdk";
|
||||
|
||||
const client = createMintelDirectusClient();
|
||||
|
||||
async function setupBranding() {
|
||||
console.log("🎨 Setup Directus Branding...");
|
||||
await ensureAuthenticated();
|
||||
const prjName = process.env.PROJECT_NAME || "Mintel Project";
|
||||
const prjColor = process.env.PROJECT_COLOR || "#82ed20";
|
||||
|
||||
console.log(`🎨 Setup Directus Branding for ${prjName}...`);
|
||||
await ensureDirectusAuthenticated(client);
|
||||
|
||||
const cssInjection = `
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
body, .v-app { font-family: 'Inter', sans-serif !important; }
|
||||
.public-view .v-card {
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 32px !important;
|
||||
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
.v-navigation-drawer { background: #000c24 !important; }
|
||||
</style>
|
||||
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
|
||||
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">MINTEL INFRASTRUCTURE ENGINE</p>
|
||||
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">${prjName.toUpperCase()} <span style="color: ${prjColor};">RELIABILITY.</span></h1>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
await client.request(
|
||||
updateSettings({
|
||||
project_name: process.env.PROJECT_NAME || "Mintel Project",
|
||||
project_color: process.env.PROJECT_COLOR || "#82ed20",
|
||||
project_name: prjName,
|
||||
project_color: prjColor,
|
||||
public_note: cssInjection,
|
||||
theme_light_overrides: {
|
||||
primary: process.env.PROJECT_COLOR || "#82ed20",
|
||||
primary: prjColor,
|
||||
borderRadius: "16px",
|
||||
navigationBackground: "#000c24",
|
||||
navigationForeground: "#ffffff",
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
|
||||
7
packages/mail/CHANGELOG.md
Normal file
7
packages/mail/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# @mintel/mail
|
||||
|
||||
## 1.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 96ec2c7: Initial release of the branded email system package.
|
||||
46
packages/mail/package.json
Normal file
46
packages/mail/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@mintel/mail",
|
||||
"version": "1.7.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"./templates/*": {
|
||||
"types": "./dist/templates/*.d.ts",
|
||||
"import": "./dist/templates/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.0.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.4"
|
||||
}
|
||||
}
|
||||
12
packages/mail/src/assets/logo-black.svg
Normal file
12
packages/mail/src/assets/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/mail/src/assets/logo-white.svg
Normal file
12
packages/mail/src/assets/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
29
packages/mail/src/components/MintelLogo.tsx
Normal file
29
packages/mail/src/components/MintelLogo.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import { Link, Img } from "@react-email/components";
|
||||
|
||||
export interface MintelLogoProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const MintelLogo = ({ size = 200 }: MintelLogoProps) => {
|
||||
// Original Logo is 545x260, we scale it
|
||||
const width = size;
|
||||
const height = (size * 260) / 545;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="https://mintel.me"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDU0NSAyNjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoyOyI+CiAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwxLC0xMjg2LC0xMTUwKSI+CiAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMSwtMCwtMCwxLDEyODYsMTE1MCkiPgogICAgICAgICAgICA8dXNlIHhsaW5rOmhyZWY9IiNfSW1hZ2UxIiB4PSI0MS41NjkiIHk9IjMxLjM4NSIgd2lkdGg9IjQ2MnB4IiBoZWlnaHQ9IjE5N3B4Ii8+CiAgICAgICAgPC9nPgogICAgPC9nPgogICAgPGRlZnM+CiAgICAgICAgPGltYWdlIGlkPSJfSW1hZ2UxIiB3aWR0aD0iNDYycHgiIGhlaWdodD0iMTk3cHgiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQU5TVWhFVWdBQUFjNFNBQUFERkNBWUFBQUNYQlJXMEFBQWdBRWxFUVZSNFhMU0JTQlNScGRmZWU0eE9lTXVlemV6NnoxY3VaeXpNeXpNeDU3cnI5ZXY5ZTFlNTVxNU56WXpNeFpPYmRkNWRNdG93MHcwMHcwMHcwMHcwMHV0eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4cDVxNHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4O0VYSVRDT0RFOiAwIgogICAgPC9kZWZzPgo8L3N2Zz4K"
|
||||
alt="Mintel Logo"
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
25
packages/mail/src/index.test.tsx
Normal file
25
packages/mail/src/index.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as React from "react";
|
||||
import { render } from "./index";
|
||||
import { MintelLogo } from "./components/MintelLogo";
|
||||
import { ContactFormNotification } from "./templates/ContactFormNotification";
|
||||
|
||||
describe("@mintel/mail rendering", () => {
|
||||
it("should render the MintelLogo to HTML", async () => {
|
||||
const html = await render(React.createElement(MintelLogo));
|
||||
expect(html).toContain("Mintel Logo");
|
||||
});
|
||||
|
||||
it("should render a ContactFormNotification to HTML", async () => {
|
||||
const html = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
message: "Hello World",
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("New Submission");
|
||||
expect(html).toContain("Test User");
|
||||
expect(html).toContain("test@example.com");
|
||||
});
|
||||
});
|
||||
24
packages/mail/src/index.ts
Normal file
24
packages/mail/src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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";
|
||||
49
packages/mail/src/layouts/BaseLayout.tsx
Normal file
49
packages/mail/src/layouts/BaseLayout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
export interface BaseLayoutProps {
|
||||
preview: string;
|
||||
children: React.ReactNode;
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export const BaseLayout = ({ preview, children }: BaseLayoutProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{preview}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={content}>{children}</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
const main = {
|
||||
backgroundColor: "#0a0a0a",
|
||||
color: "#ffffff",
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#0f0f0f",
|
||||
margin: "0 auto",
|
||||
padding: "40px 0",
|
||||
maxWidth: "600px",
|
||||
border: "1px solid #1a1a1a",
|
||||
borderRadius: "12px",
|
||||
};
|
||||
|
||||
const content = {
|
||||
padding: "0 40px",
|
||||
};
|
||||
80
packages/mail/src/layouts/ClientLayout.tsx
Normal file
80
packages/mail/src/layouts/ClientLayout.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as React from "react";
|
||||
import { Hr, Section, Text, Img } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export interface ClientLayoutProps {
|
||||
preview: string;
|
||||
children: React.ReactNode;
|
||||
clientLogo?: string;
|
||||
clientName: string;
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export const ClientLayout = ({
|
||||
preview,
|
||||
children,
|
||||
clientLogo,
|
||||
clientName,
|
||||
brandColor = "#82ed20",
|
||||
}: ClientLayoutProps) => {
|
||||
return (
|
||||
<BaseLayout preview={preview} brandColor={brandColor}>
|
||||
<Section style={header}>
|
||||
{clientLogo ? (
|
||||
<Img src={clientLogo} alt={clientName} height="40" style={logo} />
|
||||
) : (
|
||||
<Text style={logoText(brandColor)}>{clientName}</Text>
|
||||
)}
|
||||
</Section>
|
||||
<Hr style={hr} />
|
||||
<Section style={mainContent}>{children}</Section>
|
||||
<Hr style={hr} />
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
© 2026 {clientName}. All rights reserved.
|
||||
</Text>
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const header = {
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const logo = {
|
||||
margin: "0 auto",
|
||||
display: "block",
|
||||
};
|
||||
|
||||
const logoText = (color: string) => ({
|
||||
margin: "0 auto",
|
||||
textAlign: "center" as const,
|
||||
fontSize: "24px",
|
||||
fontWeight: 900,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.02em",
|
||||
borderLeft: `4px solid ${color}`,
|
||||
paddingLeft: "12px",
|
||||
});
|
||||
|
||||
const mainContent = {
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: "#222222",
|
||||
margin: "20px 0",
|
||||
};
|
||||
|
||||
const footer = {
|
||||
marginTop: "32px",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "10px",
|
||||
color: "#333333",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
53
packages/mail/src/layouts/MintelLayout.tsx
Normal file
53
packages/mail/src/layouts/MintelLayout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
import { MintelLogo } from "../components/MintelLogo";
|
||||
|
||||
export interface MintelLayoutProps {
|
||||
preview: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
|
||||
return (
|
||||
<BaseLayout preview={preview}>
|
||||
<Section style={header}>
|
||||
<MintelLogo />
|
||||
</Section>
|
||||
<Hr style={hr} />
|
||||
<Section style={mainContent}>{children}</Section>
|
||||
<Hr style={hr} />
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
© 2026 Mintel Infrastructure. Secure Communication Channel.
|
||||
</Text>
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const header = {
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const mainContent = {
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: "#222222",
|
||||
margin: "20px 0",
|
||||
};
|
||||
|
||||
const footer = {
|
||||
marginTop: "32px",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "12px",
|
||||
color: "#444444",
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
57
packages/mail/src/templates/ConfirmationMessage.tsx
Normal file
57
packages/mail/src/templates/ConfirmationMessage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Text } from "@react-email/components";
|
||||
import { ClientLayout } from "../layouts/ClientLayout";
|
||||
|
||||
export interface ConfirmationMessageProps {
|
||||
name: string;
|
||||
clientName: string;
|
||||
clientLogo?: string;
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export const ConfirmationMessage = ({
|
||||
name,
|
||||
clientName,
|
||||
clientLogo,
|
||||
brandColor,
|
||||
}: ConfirmationMessageProps) => {
|
||||
const preview = `Thank you for your message, ${name}`;
|
||||
|
||||
return (
|
||||
<ClientLayout
|
||||
preview={preview}
|
||||
clientName={clientName}
|
||||
clientLogo={clientLogo}
|
||||
brandColor={brandColor}
|
||||
>
|
||||
<Heading style={h1}>Thank You</Heading>
|
||||
<Text style={text}>Hello {name},</Text>
|
||||
<Text style={text}>
|
||||
Thank you for contacting us. We have received your message and will get
|
||||
back to you as soon as possible.
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Best regards,
|
||||
<br />
|
||||
The {clientName} Team
|
||||
</Text>
|
||||
</ClientLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationMessage;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 16px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const text = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "16px 0",
|
||||
};
|
||||
118
packages/mail/src/templates/ContactFormNotification.tsx
Normal file
118
packages/mail/src/templates/ContactFormNotification.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Section, Text, Row, Column } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface ContactFormNotificationProps {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
productName?: string;
|
||||
}
|
||||
|
||||
export const ContactFormNotification = ({
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName,
|
||||
}: ContactFormNotificationProps) => {
|
||||
const preview = `New message from ${name}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>New Submission</Heading>
|
||||
<Text style={intro}>
|
||||
A new message has been received via the contact form.
|
||||
</Text>
|
||||
|
||||
<Section style={detailsContainer}>
|
||||
<Row>
|
||||
<Column style={labelCol}>
|
||||
<Text style={label}>Name</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={value}>{name}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column style={labelCol}>
|
||||
<Text style={label}>Email</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={value}>{email}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
{productName && (
|
||||
<Row>
|
||||
<Column style={labelCol}>
|
||||
<Text style={label}>Product</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={value}>{productName}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={messageSection}>
|
||||
<Text style={label}>Message</Text>
|
||||
<Text style={messageText}>{message}</Text>
|
||||
</Section>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactFormNotification;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 16px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const detailsContainer = {
|
||||
backgroundColor: "#151515",
|
||||
padding: "24px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "24px",
|
||||
};
|
||||
|
||||
const labelCol = {
|
||||
width: "100px",
|
||||
};
|
||||
|
||||
const label = {
|
||||
fontSize: "10px",
|
||||
fontWeight: "900",
|
||||
textTransform: "uppercase" as const,
|
||||
color: "#444444",
|
||||
margin: "0 0 4px",
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
|
||||
const value = {
|
||||
fontSize: "16px",
|
||||
color: "#ffffff",
|
||||
margin: "0 0 12px",
|
||||
};
|
||||
|
||||
const messageSection = {
|
||||
padding: "0 24px",
|
||||
};
|
||||
|
||||
const messageText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
fontStyle: "italic",
|
||||
borderLeft: "2px solid #222222",
|
||||
paddingLeft: "16px",
|
||||
margin: "12px 0 0",
|
||||
};
|
||||
14
packages/mail/tsconfig.json
Normal file
14
packages/mail/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
23
packages/mail/vitest.config.ts
Normal file
23
packages/mail/vitest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
alias: {
|
||||
"prettier/plugins/html": path.resolve(
|
||||
process.cwd(),
|
||||
"../../node_modules/prettier/plugins/html.js",
|
||||
),
|
||||
"prettier/parser-html": path.resolve(
|
||||
process.cwd(),
|
||||
"../../node_modules/prettier/plugins/html.js",
|
||||
),
|
||||
},
|
||||
server: {
|
||||
deps: {
|
||||
inline: [/@react-email/],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user