Compare commits

..

24 Commits

Author SHA1 Message Date
61e78ea672 feat: integrate observability
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m50s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m38s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 7m6s
2026-02-07 10:23:51 +01:00
6501eac38a fix: gatekeeper access error 2026-02-07 09:46:31 +01:00
7f9206ae77 feat: Remove hardcoded /gatekeeper base path, update image paths, and introduce configurable base URL and cookie domain for improved routing and session management.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m4s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m3s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m18s
2026-02-06 13:54:47 +01:00
6229f8e886 feat: Add Next.js basePath and relocate the login page to /login. 2026-02-06 13:39:38 +01:00
8ac090aff3 feat: Set up new Tailwind CSS configuration, global styling, and initial application pages.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m36s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m18s
2026-02-06 00:20:55 +01:00
696f9d361d feat: Configure package entry points and exports for better module resolution, update build scripts to include templates, and add React peer dependencies.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m26s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m8s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m1s
2026-02-05 13:00:34 +01:00
31840da9e7 feat: Enable global CLI linking and direct mintel command execution, updating installation instructions and project path resolution. 2026-02-05 12:12:56 +01:00
96ec2c7d8d feat: Publish initial version of the branded email system package.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m58s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m30s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 6m48s
2026-02-05 01:46:36 +01:00
9029375247 test: add rendering tests for MintelLogo and ContactFormNotification mail components.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m36s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 4m55s
2026-02-05 01:27:47 +01:00
95d0a1622f feat: Introduce a new mail package for email templates and update the gatekeeper login page with new logo assets.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 42s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been skipped
2026-02-05 01:05:49 +01:00
646d615e76 fix(gatekeeper): ensure public directory exists in Dockerfile to prevent COPY failure
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m29s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m28s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 7m4s
2026-02-04 22:15:44 +01:00
51409099fc feat: Increase registry pruning aggressiveness by reducing tag retention, adding version and buildcache tag deletion, and shortening Docker system prune duration. (includes chore: relax header-max-length)
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m30s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 6m38s
2026-02-04 18:43:33 +01:00
22cd20e639 fix: build workspace dependencies before gatekeeper in Docker 2026-02-04 18:39:12 +01:00
e7cc1c8ca5 fix: stabilize Docker builds by standardizing on ARM64 and explicit stage naming (klz-2026 pattern)
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m42s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m29s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 4m9s
2026-02-04 18:13:54 +01:00
0ccb15a929 refactor: restructure Dockerfile.gatekeeper into base, builder, and runner stages for improved multi-stage build. 2026-02-04 12:10:51 +01:00
a94ddcfbb2 refactor: rename Dockerfile build stage from base to builder
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m28s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m49s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 23m39s
2026-02-04 01:35:45 +01:00
d3a9af140c feat: add script to prune Docker registry tags, perform garbage collection, and prune host Docker resources. 2026-02-04 01:01:00 +01:00
0dc3ba0da4 chore: Reorder pnpm build arguments in Dockerfile.nextjs.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m30s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m31s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 25m10s
2026-02-03 22:40:10 +01:00
1a94465dba feat: streamline Docker builds with .dockerignore and pass NPM_TOKEN as a build secret for pnpm install.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m34s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m40s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 3m6s
2026-02-03 22:13:34 +01:00
7e256025ea refactor: remove pnpm cache configuration from Node.js setup actions in pipeline workflows. 2026-02-03 19:41:31 +01:00
e843de42da ci: Add catthehacker/ubuntu:act-latest container image to Gitea workflow jobs.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 12m11s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 49s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m32s
2026-02-03 19:28:35 +01:00
4d1b2231e3 refactor: update pipeline to use Docker actions for registry login, build, and push.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 2m21s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m35s
2026-02-03 19:10:35 +01:00
71f47f9037 refactor: extract language utilities to lang.ts and adjust CI pipeline triggers.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 12m31s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 10s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m43s
2026-02-03 18:51:25 +01:00
79d41b6a73 feat: conditionally apply next-intl plugin and fix shared eslint ignore patterns
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 41s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been skipped
2026-02-03 16:55:20 +01:00
76 changed files with 3433 additions and 385 deletions

View File

@@ -0,0 +1,5 @@
---
"@mintel/mail": minor
---
Initial release of the branded email system package.

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.next
.git
.npmrc
dist
build
out
coverage
.vercel
.turbo
*.log
.DS_Store

View File

@@ -2,13 +2,8 @@ name: Monorepo Pipeline
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -18,6 +13,8 @@ jobs:
qa:
name: 🧪 Quality Assurance
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -31,7 +28,6 @@ jobs:
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -52,6 +48,8 @@ jobs:
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -70,7 +68,6 @@ jobs:
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -86,6 +83,8 @@ jobs:
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -94,49 +93,64 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
uses: docker/login-action@v3
with:
registry: registry.infra.mintel.me
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: 🏗️ Build & Push Nextjs Build-Base
env:
TAG: ${{ github.ref_name }}
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.infra.mintel.me/mintel/nextjs:$TAG \
-t registry.infra.mintel.me/mintel/nextjs:latest \
-f packages/infra/docker/Dockerfile.nextjs \
--push .
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.nextjs
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/nextjs:${{ github.ref_name }}
registry.infra.mintel.me/mintel/nextjs:latest
- name: 🏗️ Build & Push Production Runtime
env:
TAG: ${{ github.ref_name }}
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.infra.mintel.me/mintel/runtime:$TAG \
-t registry.infra.mintel.me/mintel/runtime:latest \
-f packages/infra/docker/Dockerfile.runtime \
--push .
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.runtime
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/runtime:${{ github.ref_name }}
registry.infra.mintel.me/mintel/runtime:latest
- name: 🏗️ Build & Push Gatekeeper (Product)
env:
TAG: ${{ github.ref_name }}
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.infra.mintel.me/mintel/gatekeeper:$TAG \
-t registry.infra.mintel.me/mintel/gatekeeper:latest \
-f packages/infra/docker/Dockerfile.gatekeeper \
--push .
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.gatekeeper
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/gatekeeper:${{ github.ref_name }}
registry.infra.mintel.me/mintel/gatekeeper:latest
- name: 🏗️ Build & Push Directus (Base)
env:
TAG: ${{ github.ref_name }}
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.infra.mintel.me/mintel/directus:$TAG \
-t registry.infra.mintel.me/mintel/directus:latest \
-f packages/infra/docker/Dockerfile.directus \
--push .
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.directus
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/directus:${{ github.ref_name }}
registry.infra.mintel.me/mintel/directus:latest

View File

@@ -22,6 +22,9 @@
},
"dependencies": {
"@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@sentry/nextjs": "^8.55.0",
"next": "15.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",

View 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",
});

View 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,
});

View 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,
});

View 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,
});

View File

@@ -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>
);
}

View 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,
});

View 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;

View 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;
}

View File

@@ -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.

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env node
import { Command } from "commander";
import fs from "fs-extra";
import path from "path";
@@ -87,7 +88,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}...`));
@@ -124,6 +125,7 @@ program
react: "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@directus/sdk": "^21.0.0",
},
devDependencies: {
@@ -262,11 +264,15 @@ export default createMintelI18nRequestConfig(
// Create instrumentation.ts
await fs.writeFile(
path.join(fullPath, "src/instrumentation.ts"),
`import * as Sentry from '@sentry/nextjs';
`import { Sentry } from '@mintel/next-observability';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Server-side initialization
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
@@ -396,8 +402,12 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Notifications (Gotify)
GOTIFY_URL=
GOTIFY_TOKEN=
`;
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);

View File

@@ -13,10 +13,14 @@ export const nextConfig = [
{
ignores: [
"**/dist/**",
"**/build/**",
"**/out/**",
"**/coverage/**",
"**/.next/**",
"**/node_modules/**",
"**/.gitea/**",
"**/.changeset/**",
"**/.vercel/**",
],
},
...compat.extends("next/core-web-vitals", "next/typescript"),

View 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

View File

@@ -15,7 +15,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
"next": "15.1.6",
"next-intl": "^3.26.5",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0"
@@ -24,6 +24,7 @@
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -19,7 +19,11 @@ export async function GET(req: NextRequest) {
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)}`;
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);
}

View File

@@ -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]">
&copy; 2026 {projectName} Infrastructure
</p>
</div>
</div>
</div>
);
}

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -0,0 +1,133 @@
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 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;
const cookieDomain = process.env.COOKIE_DOMAIN;
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",
...(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-6">
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="relative group">
<input
type="password"
name="password"
required
autoFocus
autoComplete="current-password"
placeholder="GATEKEEPER CODE"
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
/>
</div>
<button
type="submit"
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100"
>
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">
&copy; 2026 MINTEL
</p>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/login");
}

View File

@@ -0,0 +1,59 @@
/** @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)",
},
},
},
plugins: [require("@tailwindcss/typography")],
};

View File

@@ -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: [],
};

View File

@@ -1,7 +1,7 @@
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],
},

View File

@@ -1,38 +1,36 @@
FROM node:20-alpine AS base
# Step 1: Builder stage
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
# Enable pnpm
RUN corepack enable pnpm
# Install dependencies (using monorepo root context)
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
COPY packages/next-utils/package.json ./packages/next-utils/
COPY packages/tsconfig/package.json ./packages/tsconfig/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/next-config/package.json ./packages/next-config/
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
pnpm i --frozen-lockfile
# Copy source
# Copy source (honoring .dockerignore)
COPY . .
# Build Gatekeeper
RUN pnpm --filter @mintel/gatekeeper build
# Use a secret for NPM_TOKEN to authenticate with private registry
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm i --frozen-lockfile
# Runner
FROM base AS runner
# Build Gatekeeper and its dependencies
RUN pnpm --filter @mintel/gatekeeper... build
RUN mkdir -p packages/gatekeeper/public
# Step 2: Runner stage
FROM node:20-alpine AS runner
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/packages/gatekeeper/public ./packages/gatekeeper/public
COPY --from=builder /app/packages/gatekeeper/.next/standalone ./
COPY --from=builder /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
# Set the correct permission for prerender cache
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/public ./packages/gatekeeper/public
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
USER nextjs
EXPOSE 3000

View File

@@ -1,24 +1,19 @@
FROM node:20-alpine AS base
# Step 1: Builder image
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
# Enable pnpm
RUN corepack enable pnpm
# Copy root configurations
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
# Copy all package.json files to allow pnpm install to be cached
COPY packages/*/package.json ./packages/
COPY apps/*/package.json ./apps/
# Install dependencies for the entire monorepo
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
pnpm i --frozen-lockfile
# Copy the rest of the source code
# Step 2: Install dependencies
# We copy everything first because we have a .dockerignore
# and we need the workspace structure for pnpm to work correctly
COPY . .
# Post-install/Build shared packages if needed
RUN pnpm -r build --filter="./packages/*"
# Use a secret for NPM_TOKEN to authenticate with private registry
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm i --frozen-lockfile
# Step 3: Build shared packages
RUN pnpm --filter "./packages/*" -r build

View File

@@ -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 }}
@@ -136,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
@@ -144,7 +148,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -171,6 +174,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
@@ -179,23 +184,28 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
uses: docker/login-action@v3
with:
registry: registry.infra.mintel.me
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: 🏗️ 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" \
--build-arg NEXT_PUBLIC_TARGET="${{ needs.prepare.outputs.target }}" \
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$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 \
--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
@@ -205,6 +215,8 @@ 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 }}
@@ -271,6 +283,8 @@ jobs:
needs: [prepare, deploy]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔔 Gotify - Success
if: needs.deploy.result == 'success'

View 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 /

View File

@@ -0,0 +1,45 @@
{
"name": "@mintel/mail",
"version": "1.2.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",
"tsup": "^8.3.5",
"typescript": "^5.0.0",
"vitest": "^3.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View 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>
);
};

View 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");
});
});

View 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";

View File

@@ -0,0 +1,53 @@
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,
brandColor = "#82ed20",
}: 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",
};

View 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}>
&copy; 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",
};

View 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} brandColor="#82ed20">
<Section style={header}>
<MintelLogo />
</Section>
<Hr style={hr} />
<Section style={mainContent}>{children}</Section>
<Hr style={hr} />
<Section style={footer}>
<Text style={footerText}>
&copy; 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",
};

View 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",
};

View 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",
};

View 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"]
}

View File

@@ -1,7 +1,7 @@
import createNextIntlPlugin from "next-intl/plugin";
import { withSentryConfig } from "@sentry/nextjs";
const withNextIntl = createNextIntlPlugin();
import fs from "node:fs";
import path from "node:path";
/** @type {import('next').NextConfig} */
export const baseNextConfig = {
@@ -34,10 +34,30 @@ export const baseNextConfig = {
};
const withMintelConfig = (config) => {
const nextIntlConfig = withNextIntl({ ...baseNextConfig, ...config });
const i18nPaths = [
"src/i18n/request.ts",
"src/i18n/request.tsx",
"i18n/request.ts",
"i18n/request.tsx",
"src/i18n.ts",
"src/i18n.tsx",
"i18n.ts",
"i18n.tsx",
];
const hasI18nConfig = i18nPaths.some((p) =>
fs.existsSync(path.resolve(process.cwd(), p)),
);
let nextConfig = { ...baseNextConfig, ...config };
if (hasI18nConfig) {
const withNextIntl = createNextIntlPlugin();
nextConfig = withNextIntl(nextConfig);
}
return withSentryConfig(
nextIntlConfig,
nextConfig,
{
silent: !process.env.CI,
treeshake: { removeDebugLogging: true },

View File

@@ -15,7 +15,7 @@
}
},
"dependencies": {
"next-intl": "^3.0.0",
"next-intl": "^4.8.2",
"@sentry/nextjs": "^8.0.0"
}
}

View File

@@ -0,0 +1,49 @@
{
"name": "@mintel/next-observability",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.cjs"
}
},
"type": "module",
"scripts": {
"build": "tsup src/index.ts src/client.ts --format cjs,esm --dts --splitting",
"dev": "tsup src/index.ts src/client.ts --format cjs,esm --watch --dts --splitting",
"lint": "eslint src/"
},
"dependencies": {
"@mintel/observability": "workspace:*",
"@sentry/nextjs": "^8.55.0",
"next": "15.1.6"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.39.2",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useAnalytics } from "./context";
/**
* Automatically tracks pageviews on client-side route changes in Next.js.
*/
export function AnalyticsAutoTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
const analytics = useAnalytics();
useEffect(() => {
if (!pathname) return;
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
analytics.trackPageview(url);
}, [pathname, searchParams, analytics]);
return null;
}

View File

@@ -0,0 +1,50 @@
"use client";
import { createContext, useContext, ReactNode, useMemo } from "react";
import type { AnalyticsService } from "@mintel/observability";
import {
NoopAnalyticsService,
UmamiAnalyticsService,
} from "@mintel/observability";
const AnalyticsContext = createContext<AnalyticsService>(
new NoopAnalyticsService(),
);
export interface AnalyticsContextProviderProps {
service?: AnalyticsService;
config?: {
enabled: boolean;
websiteId?: string;
apiEndpoint: string;
};
children: ReactNode;
}
export function AnalyticsContextProvider({
service,
config,
children,
}: AnalyticsContextProviderProps) {
const activeService = useMemo(() => {
if (service) return service;
if (config) return new UmamiAnalyticsService(config);
return new NoopAnalyticsService();
}, [service, config]);
return (
<AnalyticsContext.Provider value={activeService}>
{children}
</AnalyticsContext.Provider>
);
}
export function useAnalytics() {
const context = useContext(AnalyticsContext);
if (!context) {
throw new Error(
"useAnalytics must be used within an AnalyticsContextProvider",
);
}
return context;
}

View File

@@ -0,0 +1,4 @@
"use client";
export * from "./analytics/context";
export * from "./analytics/auto-tracker";

View File

@@ -0,0 +1,26 @@
import * as Sentry from "@sentry/nextjs";
export interface SentryConfig {
dsn?: string;
enabled?: boolean;
tracesSampleRate?: number;
tunnel?: string;
replaysOnErrorSampleRate?: number;
replaysSessionSampleRate?: number;
}
/**
* Standardized Sentry initialization for Mintel projects.
*/
export function initSentry(config: SentryConfig) {
Sentry.init({
dsn: config.dsn,
enabled: config.enabled ?? Boolean(config.dsn || config.tunnel),
tracesSampleRate: config.tracesSampleRate ?? 0,
tunnel: config.tunnel,
replaysOnErrorSampleRate: config.replaysOnErrorSampleRate ?? 1.0,
replaysSessionSampleRate: config.replaysSessionSampleRate ?? 0.1,
});
}
export { Sentry };

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Logic for Umami Smart Proxy Route Handler.
*/
export function createUmamiProxyHandler(config: {
websiteId?: string;
apiEndpoint: string;
}) {
return async function POST(request: NextRequest) {
try {
const body = await request.json();
const { type, payload } = body;
if (!config.websiteId) {
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
const enhancedPayload = {
...payload,
website: config.websiteId,
};
const response = await fetch(`${config.apiEndpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": request.headers.get("user-agent") || "Mintel-Proxy",
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
},
body: JSON.stringify({ type, payload: enhancedPayload }),
});
if (!response.ok) {
const errorText = await response.text();
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: "ok" });
} catch (error) {
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
}
/**
* Logic for Sentry/GlitchTip Relay Route Handler.
*/
export function createSentryRelayHandler(config: { dsn?: string }) {
return async function POST(request: NextRequest) {
try {
const envelope = await request.text();
const lines = envelope.split("\n");
if (lines.length < 1) {
return NextResponse.json({ error: "Empty envelope" }, { status: 400 });
}
if (!config.dsn) {
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
const dsnUrl = new URL(config.dsn);
const projectId = dsnUrl.pathname.replace("/", "");
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
const response = await fetch(relayUrl, {
method: "POST",
body: envelope,
headers: {
"Content-Type": "application/x-sentry-envelope",
},
});
if (!response.ok) {
const errorText = await response.text();
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: "ok" });
} catch (error) {
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
}

View File

@@ -0,0 +1,2 @@
export * from "./handlers/index";
export * from "./errors/sentry";

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig/base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx",
"esModuleInterop": true
},
"include": ["src"]
}

View File

@@ -17,7 +17,7 @@
"dependencies": {
"@directus/sdk": "^21.0.0",
"next": "15.1.6",
"next-intl": "^3.0.0",
"next-intl": "^4.8.2",
"zod": "^3.0.0"
},
"devDependencies": {

View File

@@ -1,20 +1,36 @@
import { z } from 'zod';
import { z } from "zod";
export const mintelEnvSchema = {
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
NEXT_PUBLIC_BASE_URL: z.string().url(),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'),
// Analytics (Proxy Pattern)
UMAMI_WEBSITE_ID: z.string().optional(),
UMAMI_API_ENDPOINT: z
.string()
.url()
.default("https://analytics.infra.mintel.me"),
// Error Tracking
SENTRY_DSN: z.string().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Notifications
GOTIFY_URL: z.string().url().optional(),
GOTIFY_TOKEN: z.string().optional(),
LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
.default("info"),
MAIL_HOST: z.string().optional(),
MAIL_PORT: z.coerce.number().default(587),
MAIL_USERNAME: z.string().optional(),
MAIL_PASSWORD: z.string().optional(),
MAIL_FROM: z.string().optional(),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
z.array(z.string()).default([])
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
z.array(z.string()).default([]),
),
};
@@ -24,11 +40,26 @@ export function validateMintelEnv(schemaExtension = {}) {
...schemaExtension,
});
const isBuildTime =
process.env.NEXT_PHASE === "phase-production-build" ||
process.env.SKIP_ENV_VALIDATION === "true";
const result = fullSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
throw new Error('Invalid environment variables');
if (isBuildTime) {
console.warn(
"⚠️ Some environment variables are missing during build, but skipping strict validation.",
);
// Return partial data to allow build to continue
return process.env as unknown as z.infer<typeof fullSchema>;
}
console.error(
"❌ Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment variables");
}
return result.data;

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isValidLang } from "../src/index";
import { isValidLang } from "./lang";
describe("next-utils", () => {
it("should validate languages correctly", () => {

View File

@@ -30,12 +30,7 @@ export async function rateLimit(
submissions[identifier] = now;
}
export const languages = ["en", "de"] as const;
export type Lang = (typeof languages)[number];
export function isValidLang(lang: string): lang is Lang {
return (languages as readonly string[]).includes(lang);
}
export * from "./lang";
export * from "./i18n";
export * from "./env";

View File

@@ -0,0 +1,6 @@
export const languages = ["en", "de"] as const;
export type Lang = (typeof languages)[number];
export function isValidLang(lang: string): lang is Lang {
return (languages as readonly string[]).includes(lang);
}

View File

@@ -0,0 +1,123 @@
# @mintel/observability
Standardized observability package for the Mintel ecosystem, providing Umami analytics and Sentry/GlitchTip error tracking with a focus on privacy and ad-blocker resilience.
## Features
- **Umami Smart Proxy**: Track analytics without external scripts and hide your Website ID.
- **Sentry Relay**: Bypass ad-blockers for error tracking by relaying envelopes through your own server.
- **Unified API**: consistent interface for tracking across multiple projects.
## Installation
```bash
pnpm add @mintel/observability @sentry/nextjs
```
## Usage
### 1. Unified Environment (via @mintel/next-utils)
Define the following environment variables:
```bash
# Analytics
UMAMI_WEBSITE_ID=your-website-id
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking
SENTRY_DSN=your-sentry-dsn
```
Note: No `NEXT_PUBLIC_` prefix is required for these anymore, as they are handled by server-side proxies.
### 2. Analytics Setup
In your root layout:
```tsx
import {
AnalyticsContextProvider,
AnalyticsAutoTracker,
UmamiAnalyticsService,
} from "@mintel/observability";
const analytics = new UmamiAnalyticsService({
enabled: true,
websiteId: process.env.UMAMI_WEBSITE_ID, // Server-side
apiEndpoint:
typeof window === "undefined" ? process.env.UMAMI_API_ENDPOINT : "/stats",
});
export default function Layout({ children }) {
return (
<AnalyticsContextProvider service={analytics}>
<AnalyticsAutoTracker />
{children}
</AnalyticsContextProvider>
);
}
```
### 3. Route Handlers
Create a proxy for Umami:
`app/stats/api/send/route.ts`
```ts
import { createUmamiProxyHandler } from "@mintel/observability";
export const POST = await createUmamiProxyHandler({
websiteId: process.env.UMAMI_WEBSITE_ID,
apiEndpoint: process.env.UMAMI_API_ENDPOINT,
});
```
Create a relay for Sentry:
`app/errors/api/relay/route.ts`
```ts
import { createSentryRelayHandler } from "@mintel/observability";
export const POST = await createSentryRelayHandler({
dsn: process.env.SENTRY_DSN,
});
```
### 4. Notification Setup (Server-side)
```ts
import { GotifyNotificationService } from "@mintel/observability";
const notifications = new GotifyNotificationService({
enabled: true,
url: process.env.GOTIFY_URL,
token: process.env.GOTIFY_TOKEN,
});
await notifications.notify({
title: "Lead Capture",
message: "New contact form submission",
priority: 5,
});
```
### 5. Sentry Configuration
Use `initSentry` in your `sentry.server.config.ts` and `sentry.client.config.ts`.
On the client, use the tunnel:
```ts
initSentry({
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder
tunnel: "/errors/api/relay",
});
```
## Architecture
This package implements the **Smart Proxy** pattern:
- The client NEVER knows the real `UMAMI_WEBSITE_ID`.
- Tracking events are sent to your own domain (`/stats/api/send`).
- Your server injects the secret ID and forwards to Umami.
- This bypasses ad-blockers and keeps your configuration secure.

View File

@@ -0,0 +1,34 @@
{
"name": "@mintel/observability",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"type": "module",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"eslint": "^9.39.2",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^2.0.0"
}
}

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";
import { NoopAnalyticsService } from "./noop";
describe("NoopAnalyticsService", () => {
it("should not throw on track", () => {
const service = new NoopAnalyticsService();
expect(() => service.track("test")).not.toThrow();
});
it("should not throw on trackPageview", () => {
const service = new NoopAnalyticsService();
expect(() => service.trackPageview()).not.toThrow();
});
});

View File

@@ -0,0 +1,15 @@
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
/**
* No-operation analytics service.
* Used when analytics are disabled or for local development.
*/
export class NoopAnalyticsService implements AnalyticsService {
track(eventName: string, props?: AnalyticsEventProperties): void {
// Do nothing
}
trackPageview(url?: string): void {
// Do nothing
}
}

View File

@@ -0,0 +1,31 @@
/**
* Type definition for analytics event properties.
*/
export type AnalyticsEventProperties = Record<
string,
string | number | boolean | null | undefined
>;
/**
* Interface for analytics service implementations.
*
* This interface defines the contract for all analytics services,
* allowing for different implementations (Umami, Google Analytics, etc.)
* while maintaining a consistent API.
*/
export interface AnalyticsService {
/**
* Track a custom event with optional properties.
*
* @param eventName - The name of the event to track
* @param props - Optional event properties (metadata)
*/
track(eventName: string, props?: AnalyticsEventProperties): void;
/**
* Track a pageview.
*
* @param url - The URL to track (defaults to current location)
*/
trackPageview(url?: string): void;
}

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UmamiAnalyticsService } from "./umami";
describe("UmamiAnalyticsService", () => {
const mockConfig = {
websiteId: "test-website-id",
apiEndpoint: "https://analytics.test",
enabled: true,
};
const mockLogger = {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
trace: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
it("should not send payload if disabled", async () => {
const service = new UmamiAnalyticsService({
...mockConfig,
enabled: false,
});
service.track("test-event");
expect(global.fetch).not.toHaveBeenCalled();
});
it("should send payload with correct data for track", async () => {
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
(global.fetch as any).mockResolvedValue({ ok: true });
service.track("test-event", { foo: "bar" });
// Wait for async sendPayload
await new Promise((resolve) => setTimeout(resolve, 0));
expect(global.fetch).toHaveBeenCalledWith(
"https://analytics.test/api/send",
expect.objectContaining({
method: "POST",
body: expect.stringContaining('"type":"event"'),
}),
);
const callBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(callBody.payload.name).toBe("test-event");
expect(callBody.payload.data.foo).toBe("bar");
expect(callBody.payload.website).toBe("test-website-id");
});
it("should log warning if send fails", async () => {
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
(global.fetch as any).mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve("Internal error"),
});
service.track("test-event");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockLogger.warn).toHaveBeenCalledWith(
"Umami API responded with error",
expect.objectContaining({ status: 500 }),
);
});
});

View File

@@ -0,0 +1,115 @@
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
export interface UmamiConfig {
websiteId?: string;
apiEndpoint: string; // The endpoint to send to (proxied or direct)
enabled: boolean;
}
export interface Logger {
debug(msg: string, data?: any): void;
warn(msg: string, data?: any): void;
error(msg: string, data?: any): void;
trace(msg: string, data?: any): void;
}
/**
* Umami Analytics Service Implementation (Script-less/Proxy edition).
*/
export class UmamiAnalyticsService implements AnalyticsService {
private logger?: Logger;
constructor(
private config: UmamiConfig,
logger?: Logger,
) {
this.logger = logger;
}
private async sendPayload(type: "event", data: Record<string, any>) {
if (!this.config.enabled) return;
const isClient = typeof window !== "undefined";
const websiteId = this.config.websiteId;
if (!isClient && !websiteId) {
this.logger?.warn(
"Umami tracking called on server but no Website ID configured",
);
return;
}
try {
const payload = {
website: websiteId,
hostname: isClient ? window.location.hostname : "server",
screen: isClient
? `${window.screen.width}x${window.screen.height}`
: undefined,
language: isClient ? navigator.language : undefined,
referrer: isClient ? document.referrer : undefined,
...data,
};
this.logger?.trace("Sending analytics payload", { type, url: data.url });
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(`${this.config.apiEndpoint}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": isClient ? navigator.userAgent : "Mintel-Server",
},
body: JSON.stringify({ type, payload }),
keepalive: true,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
this.logger?.warn("Umami API responded with error", {
status: response.status,
error: errorText.slice(0, 100),
});
}
} catch (fetchError) {
clearTimeout(timeoutId);
if ((fetchError as Error).name === "AbortError") {
this.logger?.error("Umami request timed out");
} else {
throw fetchError;
}
}
} catch (error) {
this.logger?.error("Failed to send analytics", {
error: (error as Error).message,
});
}
}
track(eventName: string, props?: AnalyticsEventProperties) {
this.sendPayload("event", {
name: eventName,
data: props,
url:
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined,
});
}
trackPageview(url?: string) {
this.sendPayload("event", {
url:
url ||
(typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined),
});
}
}

View File

@@ -0,0 +1,9 @@
// Analytics
export * from "./analytics/service";
export * from "./analytics/umami";
export * from "./analytics/noop";
// Notifications
export * from "./notifications/service";
export * from "./notifications/gotify";
export * from "./notifications/noop";

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GotifyNotificationService } from "./gotify";
describe("GotifyNotificationService", () => {
const mockConfig = {
url: "https://gotify.test",
token: "test-token",
enabled: true,
};
const mockLogger = {
error: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
it("should not notify if disabled", async () => {
const service = new GotifyNotificationService({
...mockConfig,
enabled: false,
});
await service.notify({ title: "test", message: "test" });
expect(global.fetch).not.toHaveBeenCalled();
});
it("should send correct payload to Gotify", async () => {
const service = new GotifyNotificationService(mockConfig, mockLogger);
(global.fetch as any).mockResolvedValue({ ok: true });
await service.notify({
title: "Alert",
message: "Critical issue",
priority: 8,
});
expect(global.fetch).toHaveBeenCalledWith(
"https://gotify.test/message?token=test-token",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Alert",
message: "Critical issue",
priority: 8,
}),
}),
);
});
it("should handle missing trailing slash in URL", async () => {
const service = new GotifyNotificationService({
...mockConfig,
url: "https://gotify.test",
});
(global.fetch as any).mockResolvedValue({ ok: true });
await service.notify({ title: "test", message: "test" });
expect((global.fetch as any).mock.calls[0][0]).toBe(
"https://gotify.test/message?token=test-token",
);
});
it("should log error if notify fails", async () => {
const service = new GotifyNotificationService(mockConfig, mockLogger);
(global.fetch as any).mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve("Unauthorized"),
});
await service.notify({ title: "test", message: "test" });
expect(mockLogger.error).toHaveBeenCalledWith(
"Gotify notification failed",
expect.objectContaining({ status: 401 }),
);
});
});

View File

@@ -0,0 +1,56 @@
import { NotificationOptions, NotificationService } from "./service";
export interface GotifyConfig {
url: string;
token: string;
enabled: boolean;
}
/**
* Gotify Notification Service implementation.
*/
export class GotifyNotificationService implements NotificationService {
constructor(
private config: GotifyConfig,
private logger?: { error(msg: string, data?: any): void },
) {}
async notify(options: NotificationOptions): Promise<void> {
if (!this.config.enabled) return;
try {
const { title, message, priority = 4 } = options;
// Ensure we have a trailing slash for base URL, then append 'message'
const baseUrl = this.config.url.endsWith("/")
? this.config.url
: `${this.config.url}/`;
const url = new URL("message", baseUrl);
url.searchParams.set("token", this.config.token);
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
message,
priority,
}),
});
if (!response.ok) {
const errorText = await response.text();
this.logger?.error("Gotify notification failed", {
status: response.status,
error: errorText.slice(0, 100),
});
}
} catch (error) {
this.logger?.error("Gotify notification error", {
error: (error as Error).message,
});
}
}
}

View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import { NoopNotificationService } from "./noop";
describe("NoopNotificationService", () => {
it("should not throw on notify", async () => {
const service = new NoopNotificationService();
await expect(
service.notify({ title: "test", message: "test" }),
).resolves.not.toThrow();
});
});

View File

@@ -0,0 +1,10 @@
import { NotificationService } from "./service";
/**
* No-operation notification service.
*/
export class NoopNotificationService implements NotificationService {
async notify(): Promise<void> {
// Do nothing
}
}

View File

@@ -0,0 +1,16 @@
export interface NotificationOptions {
title: string;
message: string;
priority?: number;
}
/**
* Interface for notification service implementations.
* Allows for different implementations (Gotify, Slack, Email, etc.)
*/
export interface NotificationService {
/**
* Send a notification.
*/
notify(options: NotificationOptions): Promise<void>;
}

View File

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

1350
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff