Compare commits

...

16 Commits

Author SHA1 Message Date
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
31 changed files with 1409 additions and 200 deletions

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

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

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

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

@@ -55,12 +55,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
{/* 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"
className="w-48 h-24 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl p-6"
style={{ borderBottom: `2px solid ${projectColor}44` }}
>
<ShieldCheck
className="w-10 h-10"
style={{ color: projectColor }}
<img
src="/logo-white.svg"
alt={projectName}
className="w-full h-auto opacity-90"
/>
</div>
</div>

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,30 +1,25 @@
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

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,30 @@
{
"name": "@mintel/mail",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./templates/*": "./src/templates/*"
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --watch",
"lint": "eslint src",
"test": "vitest run"
},
"dependencies": {
"@react-email/components": "^0.0.33",
"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

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

726
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff