Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 696f9d361d | |||
| 31840da9e7 | |||
| 96ec2c7d8d | |||
| 9029375247 | |||
| 95d0a1622f | |||
| 646d615e76 | |||
| 51409099fc | |||
| 22cd20e639 | |||
| e7cc1c8ca5 | |||
| 0ccb15a929 | |||
| a94ddcfbb2 | |||
| d3a9af140c | |||
| 0dc3ba0da4 | |||
| 1a94465dba | |||
| 7e256025ea | |||
| e843de42da | |||
| 4d1b2231e3 | |||
| 71f47f9037 | |||
| 79d41b6a73 | |||
| 6b7236ba97 | |||
| 40a95b5353 | |||
| 7329e00125 | |||
| 94be60ba4e | |||
| a8bc039c02 | |||
| 653deb7995 | |||
| 61f65107f2 |
5
.changeset/init-mail-package.md
Normal file
5
.changeset/init-mail-package.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@mintel/mail": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Initial release of the branded email system package.
|
||||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.npmrc
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
out
|
||||||
|
coverage
|
||||||
|
.vercel
|
||||||
|
.turbo
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -2,13 +2,8 @@ name: Monorepo Pipeline
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -18,6 +13,8 @@ jobs:
|
|||||||
qa:
|
qa:
|
||||||
name: 🧪 Quality Assurance
|
name: 🧪 Quality Assurance
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -31,7 +28,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -52,6 +48,8 @@ jobs:
|
|||||||
needs: qa
|
needs: qa
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -70,7 +68,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -80,3 +77,80 @@ jobs:
|
|||||||
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
||||||
pnpm sync-versions
|
pnpm sync-versions
|
||||||
pnpm release:tag
|
pnpm release:tag
|
||||||
|
|
||||||
|
build-images:
|
||||||
|
name: 🐳 Build & Push Images
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: 🐳 Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: 🔐 Registry Login
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.infra.mintel.me
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
|
- name: 🏗️ Build & Push Nextjs Build-Base
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|||||||
31
apps/sample-website/.env.example
Normal file
31
apps/sample-website/.env.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Project
|
||||||
|
PROJECT_NAME=sample-website
|
||||||
|
PROJECT_COLOR=#82ed20
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
GATEKEEPER_PASSWORD=mintel
|
||||||
|
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||||
|
|
||||||
|
# Host Config (Local)
|
||||||
|
TRAEFIK_HOST=sample-website.localhost
|
||||||
|
DIRECTUS_HOST=cms.sample-website.localhost
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://sample-website.localhost
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
DIRECTUS_URL=http://localhost:8055
|
||||||
|
DIRECTUS_KEY=sample-key-123
|
||||||
|
DIRECTUS_SECRET=sample-secret-123
|
||||||
|
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
|
||||||
|
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
|
||||||
|
DIRECTUS_DB_NAME=directus
|
||||||
|
DIRECTUS_DB_USER=directus
|
||||||
|
DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||||
|
|
||||||
|
# Sentry / Glitchtip
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Analytics (Umami)
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
34
apps/sample-website/.gitignore
vendored
Normal file
34
apps/sample-website/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnpm-debug.log*
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# directus
|
||||||
|
/directus/uploads
|
||||||
|
/directus/extensions
|
||||||
|
/.env
|
||||||
38
apps/sample-website/Dockerfile
Normal file
38
apps/sample-website/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Start from the pre-built Nextjs Base image
|
||||||
|
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build-time environment variables for Next.js
|
||||||
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||||
|
ARG NEXT_PUBLIC_TARGET
|
||||||
|
ARG DIRECTUS_URL
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||||
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
|
||||||
|
# Build the specific application
|
||||||
|
RUN pnpm --filter sample-website build
|
||||||
|
|
||||||
|
# Production runner image
|
||||||
|
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir -p apps/sample-website/.next && chown nextjs:nodejs apps/sample-website/.next
|
||||||
|
|
||||||
|
# Copy standalone output and static files from the monorepo path
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/sample-website/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/sample-website/.next/static ./apps/sample-website/.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
# server.js in monorepo standalone is created for each app
|
||||||
|
CMD ["node", "apps/sample-website/server.js"]
|
||||||
0
apps/sample-website/directus/extensions/.gitkeep
Normal file
0
apps/sample-website/directus/extensions/.gitkeep
Normal file
0
apps/sample-website/directus/uploads/.gitkeep
Normal file
0
apps/sample-website/directus/uploads/.gitkeep
Normal file
71
apps/sample-website/docker-compose.yml
Normal file
71
apps/sample-website/docker-compose.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
||||||
|
NEXT_PUBLIC_TARGET: ${TARGET:-development}
|
||||||
|
DIRECTUS_URL: ${DIRECTUS_URL:-http://directus:8055}
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
|
||||||
|
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
directus:
|
||||||
|
image: registry.infra.mintel.me/mintel/directus:latest
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
KEY: ${DIRECTUS_KEY:-mintel-key}
|
||||||
|
SECRET: ${DIRECTUS_SECRET:-mintel-secret}
|
||||||
|
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
||||||
|
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
||||||
|
DB_CLIENT: 'pg'
|
||||||
|
DB_HOST: 'directus-db'
|
||||||
|
DB_PORT: '5432'
|
||||||
|
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||||
|
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
|
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
|
||||||
|
WEBSOCKETS_ENABLED: 'true'
|
||||||
|
PUBLIC_URL: ${DIRECTUS_URL:-http://localhost:8055}
|
||||||
|
ports:
|
||||||
|
- "8055:8055"
|
||||||
|
volumes:
|
||||||
|
- ./directus/uploads:/directus/uploads
|
||||||
|
- ./directus/extensions:/directus/extensions
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||||
|
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
||||||
|
|
||||||
|
directus-db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||||
|
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
|
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
|
||||||
|
volumes:
|
||||||
|
- directus-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
networks:
|
||||||
|
infra:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
directus-db-data:
|
||||||
@@ -4,11 +4,21 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "mintel dev",
|
||||||
|
"dev:local": "mintel dev --local",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "vitest run"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"cms:bootstrap": "mintel directus bootstrap",
|
||||||
|
"cms:push:testing": "mintel directus sync push testing",
|
||||||
|
"cms:pull:testing": "mintel directus sync pull testing",
|
||||||
|
"cms:push:staging": "mintel directus sync push staging",
|
||||||
|
"cms:pull:staging": "mintel directus sync pull staging",
|
||||||
|
"cms:push:prod": "mintel directus sync push production",
|
||||||
|
"cms:pull:prod": "mintel directus sync pull production",
|
||||||
|
"pagespeed:test": "mintel pagespeed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
|
|||||||
@@ -4,10 +4,30 @@ The Mintel CLI is the primary automation tool for managing the monorepo and ensu
|
|||||||
|
|
||||||
## 🚀 Installation
|
## 🚀 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
|
```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
|
## 🛠 Commands
|
||||||
@@ -17,10 +37,11 @@ pnpm install
|
|||||||
Scaffolds a new, production-ready client website in the specified path.
|
Scaffolds a new, production-ready client website in the specified path.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter @mintel/cli start init apps/my-new-website.com
|
mintel init apps/my-new-website.com
|
||||||
```
|
```
|
||||||
|
|
||||||
#### What it does:
|
#### What it does:
|
||||||
|
|
||||||
1. **Project Structure**: Creates a modern Next.js directory layout.
|
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.
|
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.
|
3. **Localization**: Sets up a localized routing structure (`src/app/[locale]`) with `next-intl` pre-configured.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import fs from "fs-extra";
|
import fs from "fs-extra";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -10,7 +11,76 @@ const program = new Command();
|
|||||||
program
|
program
|
||||||
.name("mintel")
|
.name("mintel")
|
||||||
.description("CLI for Mintel monorepo management")
|
.description("CLI for Mintel monorepo management")
|
||||||
.version("1.0.0");
|
.version("1.0.1");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("dev")
|
||||||
|
.description("Start the development environment (Docker stack)")
|
||||||
|
.option("-l, --local", "Run Next.js locally instead of in Docker")
|
||||||
|
.action(async (options) => {
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
console.log(chalk.blue("🚀 Starting Development Environment..."));
|
||||||
|
|
||||||
|
if (options.local) {
|
||||||
|
console.log(chalk.cyan("Running Next.js locally..."));
|
||||||
|
execSync("next dev", { stdio: "inherit" });
|
||||||
|
} else {
|
||||||
|
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||||
|
// Ensure network exists
|
||||||
|
try {
|
||||||
|
execSync("docker network create infra", { stdio: "ignore" });
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(`
|
||||||
|
📱 App: http://localhost:3000
|
||||||
|
🗄️ CMS: http://localhost:8055/admin
|
||||||
|
🚦 Traefik: http://localhost:8080
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
execSync(
|
||||||
|
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const directus = program
|
||||||
|
.command("directus")
|
||||||
|
.description("Directus management commands");
|
||||||
|
|
||||||
|
directus
|
||||||
|
.command("bootstrap")
|
||||||
|
.description("Setup Directus branding and settings")
|
||||||
|
.action(async () => {
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
console.log(chalk.blue("🎨 Bootstrapping Directus..."));
|
||||||
|
execSync("npx tsx --env-file=.env scripts/setup-directus.ts", {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
directus
|
||||||
|
.command("sync <action> <env>")
|
||||||
|
.description("Sync Directus data (push/pull) for a specific environment")
|
||||||
|
.action(async (action, env) => {
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
console.log(
|
||||||
|
chalk.blue(`📥 Executing Directus sync: ${action} -> ${env}...`),
|
||||||
|
);
|
||||||
|
execSync(`./scripts/sync-directus.sh ${action} ${env}`, {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("pagespeed")
|
||||||
|
.description("Run PageSpeed (Lighthouse) tests")
|
||||||
|
.action(async () => {
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
console.log(chalk.blue("⚡ Running PageSpeed tests..."));
|
||||||
|
execSync("npx tsx ./scripts/pagespeed-sitemap.ts", { stdio: "inherit" });
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("init <path>")
|
.command("init <path>")
|
||||||
@@ -18,7 +88,7 @@ program
|
|||||||
.action(async (projectPath) => {
|
.action(async (projectPath) => {
|
||||||
const fullPath = path.isAbsolute(projectPath)
|
const fullPath = path.isAbsolute(projectPath)
|
||||||
? projectPath
|
? projectPath
|
||||||
: path.resolve(process.cwd(), "../../", projectPath);
|
: path.resolve(process.cwd(), projectPath);
|
||||||
const projectName = path.basename(fullPath);
|
const projectName = path.basename(fullPath);
|
||||||
|
|
||||||
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
||||||
@@ -34,10 +104,21 @@ program
|
|||||||
private: true,
|
private: true,
|
||||||
type: "module",
|
type: "module",
|
||||||
scripts: {
|
scripts: {
|
||||||
dev: "next dev",
|
dev: "mintel dev",
|
||||||
|
"dev:local": "mintel dev --local",
|
||||||
build: "next build",
|
build: "next build",
|
||||||
start: "next start",
|
start: "next start",
|
||||||
lint: "next lint",
|
lint: "next lint",
|
||||||
|
typecheck: "tsc --noEmit",
|
||||||
|
test: "vitest run --passWithNoTests",
|
||||||
|
"directus:bootstrap": "mintel directus bootstrap",
|
||||||
|
"directus:push:testing": "mintel directus sync push testing",
|
||||||
|
"directus:pull:testing": "mintel directus sync pull testing",
|
||||||
|
"directus:push:staging": "mintel directus sync push staging",
|
||||||
|
"directus:pull:staging": "mintel directus sync pull staging",
|
||||||
|
"directus:push:prod": "mintel directus sync push production",
|
||||||
|
"directus:pull:prod": "mintel directus sync pull production",
|
||||||
|
"pagespeed:test": "mintel pagespeed",
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
next: "15.1.6",
|
next: "15.1.6",
|
||||||
@@ -238,34 +319,56 @@ export default function Home() {
|
|||||||
// Copy infra templates
|
// Copy infra templates
|
||||||
const infraPath = path.resolve(__dirname, "../../infra");
|
const infraPath = path.resolve(__dirname, "../../infra");
|
||||||
if (await fs.pathExists(infraPath)) {
|
if (await fs.pathExists(infraPath)) {
|
||||||
await fs.copy(
|
// Setup Dockerfile from template
|
||||||
path.join(infraPath, "docker/Dockerfile.nextjs"),
|
const templatePath = path.join(
|
||||||
path.join(fullPath, "Dockerfile"),
|
infraPath,
|
||||||
|
"docker/Dockerfile.app-template",
|
||||||
);
|
);
|
||||||
await fs.copy(
|
if (await fs.pathExists(templatePath)) {
|
||||||
path.join(infraPath, "docker/docker-compose.template.yml"),
|
let dockerfile = await fs.readFile(templatePath, "utf8");
|
||||||
path.join(fullPath, "docker-compose.yml"),
|
dockerfile = dockerfile.replace(/\$\{APP_NAME:-app\}/g, projectName);
|
||||||
|
await fs.writeFile(path.join(fullPath, "Dockerfile"), dockerfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup docker-compose from template
|
||||||
|
const composeTemplatePath = path.join(
|
||||||
|
infraPath,
|
||||||
|
"docker/docker-compose.template.yml",
|
||||||
);
|
);
|
||||||
|
if (await fs.pathExists(composeTemplatePath)) {
|
||||||
|
let compose = await fs.readFile(composeTemplatePath, "utf8");
|
||||||
|
compose = compose.replace(/\$\{APP_NAME:-app\}/g, projectName);
|
||||||
|
compose = compose.replace(/\$\{PROJECT_NAME:-app\}/g, projectName);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "docker-compose.yml"),
|
||||||
|
compose,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
|
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
|
||||||
await fs.copy(
|
const deployActionPath = path.join(
|
||||||
path.join(infraPath, "gitea/deploy-action.yml"),
|
infraPath,
|
||||||
path.join(fullPath, ".gitea/workflows/deploy.yml"),
|
"gitea/deploy-action.yml",
|
||||||
);
|
);
|
||||||
|
if (await fs.pathExists(deployActionPath)) {
|
||||||
|
await fs.copy(
|
||||||
|
deployActionPath,
|
||||||
|
path.join(fullPath, ".gitea/workflows/deploy.yml"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create Directus structure
|
// Create Directus structure
|
||||||
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
|
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
|
||||||
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
|
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
|
||||||
await fs.writeFile(
|
await fs.writeFile(path.join(fullPath, "directus/uploads/.gitkeep"), "");
|
||||||
path.join(fullPath, "directus/uploads/.gitkeep"),
|
await fs.writeFile(
|
||||||
"",
|
path.join(fullPath, "directus/extensions/.gitkeep"),
|
||||||
);
|
"",
|
||||||
await fs.writeFile(
|
);
|
||||||
path.join(fullPath, "directus/extensions/.gitkeep"),
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create .env.example
|
// Create .env.example
|
||||||
const envExample = `# Project
|
const envExample = `# Project
|
||||||
PROJECT_NAME=${projectName}
|
PROJECT_NAME=${projectName}
|
||||||
PROJECT_COLOR=#82ed20
|
PROJECT_COLOR=#82ed20
|
||||||
|
|
||||||
@@ -297,14 +400,13 @@ SENTRY_DSN=
|
|||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
`;
|
`;
|
||||||
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||||
|
|
||||||
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
|
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
|
||||||
const templatePath = path.join(infraPath, "templates/website");
|
const templatePath = path.join(infraPath, "templates/website");
|
||||||
if (await fs.pathExists(templatePath)) {
|
if (await fs.pathExists(templatePath)) {
|
||||||
console.log(chalk.blue("Applying premium templates..."));
|
console.log(chalk.blue("Applying premium templates..."));
|
||||||
await fs.copy(templatePath, fullPath, { overwrite: true });
|
await fs.copy(templatePath, fullPath, { overwrite: true });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ export const nextConfig = [
|
|||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
|
"**/build/**",
|
||||||
|
"**/out/**",
|
||||||
|
"**/coverage/**",
|
||||||
"**/.next/**",
|
"**/.next/**",
|
||||||
"**/node_modules/**",
|
"**/node_modules/**",
|
||||||
"**/.gitea/**",
|
"**/.gitea/**",
|
||||||
"**/.changeset/**",
|
"**/.changeset/**",
|
||||||
|
"**/.vercel/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"next": "15.1.6",
|
"next": "15.1.6",
|
||||||
"next-intl": "^3.26.5",
|
"next-intl": "^4.8.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^2.6.0"
|
"tailwind-merge": "^2.6.0"
|
||||||
|
|||||||
12
packages/gatekeeper/public/logo-black.svg
Normal file
12
packages/gatekeeper/public/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/gatekeeper/public/logo-white.svg
Normal file
12
packages/gatekeeper/public/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -55,12 +55,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
{/* Logo / Icon */}
|
{/* Logo / Icon */}
|
||||||
<div className="flex justify-center mb-12">
|
<div className="flex justify-center mb-12">
|
||||||
<div
|
<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` }}
|
style={{ borderBottom: `2px solid ${projectColor}44` }}
|
||||||
>
|
>
|
||||||
<ShieldCheck
|
<img
|
||||||
className="w-10 h-10"
|
src="/logo-white.svg"
|
||||||
style={{ color: projectColor }}
|
alt={projectName}
|
||||||
|
className="w-full h-auto opacity-90"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const config = {
|
const config = {
|
||||||
extends: ["@commitlint/config-conventional"],
|
extends: ["@commitlint/config-conventional"],
|
||||||
rules: {
|
rules: {
|
||||||
"header-max-length": [2, "always", 150],
|
"header-max-length": [2, "always", 250],
|
||||||
"subject-case": [0],
|
"subject-case": [0],
|
||||||
"subject-full-stop": [0],
|
"subject-full-stop": [0],
|
||||||
},
|
},
|
||||||
|
|||||||
33
packages/infra/docker/Dockerfile.app-template
Normal file
33
packages/infra/docker/Dockerfile.app-template
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Start from the pre-built Nextjs Base image
|
||||||
|
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build-time environment variables for Next.js
|
||||||
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||||
|
ARG NEXT_PUBLIC_TARGET
|
||||||
|
ARG DIRECTUS_URL
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||||
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
|
||||||
|
# Build the specific application
|
||||||
|
RUN pnpm --filter ${APP_NAME:-app} build
|
||||||
|
|
||||||
|
# Production runner image
|
||||||
|
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy standalone output and static files
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/public ./apps/${APP_NAME:-app}/public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/.next/static ./apps/${APP_NAME:-app}/.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
CMD ["node", "apps/${APP_NAME:-app}/server.js"]
|
||||||
12
packages/infra/docker/Dockerfile.directus
Normal file
12
packages/infra/docker/Dockerfile.directus
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM directus/directus:11
|
||||||
|
|
||||||
|
# Add any custom extensions or configurations here if needed
|
||||||
|
# COPY ./extensions /directus/extensions
|
||||||
|
|
||||||
|
# Default environment for optimized production use
|
||||||
|
ENV LOGGER_LEVEL="info"
|
||||||
|
ENV WEBSOCKETS_ENABLED="true"
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8055/health || exit 1
|
||||||
@@ -1,47 +1,37 @@
|
|||||||
FROM node:20-alpine AS base
|
# Step 1: Builder stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
# Install dependencies only when needed
|
RUN apk add --no-cache libc6-compat curl
|
||||||
FROM base AS deps
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
# Install dependencies
|
# Copy source (honoring .dockerignore)
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
|
||||||
RUN corepack enable pnpm && pnpm i --frozen-lockfile
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
# 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
|
||||||
|
|
||||||
# Build the application
|
# Build Gatekeeper and its dependencies
|
||||||
RUN corepack enable pnpm && pnpm run build
|
RUN pnpm --filter @mintel/gatekeeper... build
|
||||||
|
RUN mkdir -p packages/gatekeeper/public
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Step 2: Runner stage
|
||||||
FROM base AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
RUN apk add --no-cache libc6-compat curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
COPY --from=builder /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
COPY --from=builder /app/packages/gatekeeper/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "packages/gatekeeper/server.js"]
|
||||||
|
|||||||
@@ -1,66 +1,19 @@
|
|||||||
FROM node:20-alpine AS base
|
# Step 1: Builder image
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
# Install dependencies only when needed
|
|
||||||
FROM base AS deps
|
|
||||||
RUN apk add --no-cache libc6-compat curl
|
RUN apk add --no-cache libc6-compat curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Step 2: Install dependencies
|
||||||
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
# We copy everything first because we have a .dockerignore
|
||||||
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
# and we need the workspace structure for pnpm to work correctly
|
||||||
elif [ -f package-lock.json ]; then npm ci; \
|
|
||||||
else npm i; fi
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Use a secret for NPM_TOKEN to authenticate with private registry
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
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
|
||||||
|
|
||||||
# Build-time environment variables for Next.js
|
# Step 3: Build shared packages
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
RUN pnpm --filter "./packages/*" -r build
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
|
||||||
else npm run build; fi
|
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install curl for health checks
|
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
|
|
||||||
# Set the correct permission for prerender cache
|
|
||||||
RUN mkdir .next
|
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
|
|||||||
19
packages/infra/docker/Dockerfile.runtime
Normal file
19
packages/infra/docker/Dockerfile.runtime
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install essential production utilities
|
||||||
|
RUN apk add --no-cache curl libc6-compat
|
||||||
|
|
||||||
|
# Set standard production environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Expose the default Next.js port
|
||||||
|
EXPOSE 3000
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: registry.infra.mintel.me/mintel/${APP_NAME:-app}:${IMAGE_TAG:-latest}
|
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
@@ -40,7 +39,6 @@ services:
|
|||||||
|
|
||||||
gatekeeper:
|
gatekeeper:
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||||
container_name: ${PROJECT_NAME}-gatekeeper
|
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
@@ -55,7 +53,7 @@ services:
|
|||||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
directus:
|
directus:
|
||||||
image: directus/directus:11
|
image: registry.infra.mintel.me/mintel/directus:latest
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ jobs:
|
|||||||
prepare:
|
prepare:
|
||||||
name: 🔍 Prepare Environment
|
name: 🔍 Prepare Environment
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
outputs:
|
outputs:
|
||||||
target: ${{ steps.determine.outputs.target }}
|
target: ${{ steps.determine.outputs.target }}
|
||||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
@@ -136,6 +138,8 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -144,7 +148,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -171,6 +174,8 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -179,23 +184,28 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: |
|
uses: docker/login-action@v3
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
with:
|
||||||
|
registry: registry.infra.mintel.me
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- name: 🏗️ Docker Build & Push
|
- name: 🏗️ Docker Build & Push
|
||||||
env:
|
uses: docker/build-push-action@v5
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
with:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
context: .
|
||||||
run: |
|
file: packages/infra/docker/Dockerfile.nextjs
|
||||||
docker buildx build \
|
platforms: linux/arm64
|
||||||
--pull \
|
pull: true
|
||||||
--platform linux/arm64 \
|
build-args: |
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
||||||
--build-arg NEXT_PUBLIC_TARGET="${{ needs.prepare.outputs.target }}" \
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$IMAGE_TAG \
|
push: true
|
||||||
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache \
|
secrets: |
|
||||||
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache,mode=max \
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
--push .
|
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
|
# JOB 4: Deploy
|
||||||
@@ -205,6 +215,8 @@ jobs:
|
|||||||
needs: [prepare, build, qa]
|
needs: [prepare, build, qa]
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
env:
|
env:
|
||||||
TARGET: ${{ needs.prepare.outputs.target }}
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
@@ -271,6 +283,8 @@ jobs:
|
|||||||
needs: [prepare, deploy]
|
needs: [prepare, deploy]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 🔔 Gotify - Success
|
- name: 🔔 Gotify - Success
|
||||||
if: needs.deploy.result == 'success'
|
if: needs.deploy.result == 'success'
|
||||||
|
|||||||
60
packages/infra/scripts/prune-registry.sh
Normal file
60
packages/infra/scripts/prune-registry.sh
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REGISTRY_DATA="/opt/infra/registry/data/docker/registry/v2"
|
||||||
|
KEEP_TAGS=3
|
||||||
|
|
||||||
|
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
|
||||||
|
|
||||||
|
# 1. Prune Registry Tags (Filesystem level)
|
||||||
|
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||||
|
repo_name=$(basename "$repo_dir")
|
||||||
|
tags_dir="$repo_dir/_manifests/tags"
|
||||||
|
|
||||||
|
if [ -d "$tags_dir" ]; then
|
||||||
|
echo "🔍 Processing repository: mintel/$repo_name"
|
||||||
|
|
||||||
|
# Prune main-* tags
|
||||||
|
echo " 📦 Pruning main tags..."
|
||||||
|
main_tags=$(ls -dt "$tags_dir"/main-* 2>/dev/null || true)
|
||||||
|
count=0
|
||||||
|
for tag_path in $main_tags; do
|
||||||
|
((++count))
|
||||||
|
if [ $count -gt $KEEP_TAGS ]; then
|
||||||
|
echo " 🗑️ Deleting old main tag: $(basename "$tag_path")"
|
||||||
|
rm -rf "$tag_path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Prune version tags (v* and rc*)
|
||||||
|
echo " 🏷️ Pruning version tags..."
|
||||||
|
version_tags=$(ls -dt "$tags_dir"/v1* 2>/dev/null || true)
|
||||||
|
count=0
|
||||||
|
for tag_path in $version_tags; do
|
||||||
|
((++count))
|
||||||
|
if [ $count -gt $KEEP_TAGS ]; then
|
||||||
|
echo " 🗑️ Deleting old version tag: $(basename "$tag_path")"
|
||||||
|
rm -rf "$tag_path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Always prune buildcache (as it rebuilds quickly)
|
||||||
|
if [ -d "$tags_dir/buildcache" ]; then
|
||||||
|
echo " 🧹 Deleting buildcache tag"
|
||||||
|
rm -rf "$tags_dir/buildcache"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Run Garbage Collection
|
||||||
|
echo "♻️ Running Registry Garbage Collection..."
|
||||||
|
docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml
|
||||||
|
|
||||||
|
# 3. Prune Host Docker resources (Shorter window: 24h)
|
||||||
|
echo "🧹 Pruning Host Docker resources..."
|
||||||
|
docker system prune -af --filter "until=24h"
|
||||||
|
docker volume prune -f
|
||||||
|
|
||||||
|
echo "✅ Maintenance complete!"
|
||||||
|
df -h /
|
||||||
5
packages/infra/templates/website/.gitignore
vendored
5
packages/infra/templates/website/.gitignore
vendored
@@ -27,3 +27,8 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# directus
|
||||||
|
/directus/uploads
|
||||||
|
/directus/extensions
|
||||||
|
/.env
|
||||||
|
|||||||
45
packages/mail/package.json
Normal file
45
packages/mail/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/mail/src/assets/logo-black.svg
Normal file
12
packages/mail/src/assets/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/mail/src/assets/logo-white.svg
Normal file
12
packages/mail/src/assets/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
29
packages/mail/src/components/MintelLogo.tsx
Normal file
29
packages/mail/src/components/MintelLogo.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Link, Img } from "@react-email/components";
|
||||||
|
|
||||||
|
export interface MintelLogoProps {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MintelLogo = ({ size = 200 }: MintelLogoProps) => {
|
||||||
|
// Original Logo is 545x260, we scale it
|
||||||
|
const width = size;
|
||||||
|
const height = (size * 260) / 545;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="https://mintel.me"
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Img
|
||||||
|
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDU0NSAyNjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoyOyI+CiAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwxLC0xMjg2LC0xMTUwKSI+CiAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMSwtMCwtMCwxLDEyODYsMTE1MCkiPgogICAgICAgICAgICA8dXNlIHhsaW5rOmhyZWY9IiNfSW1hZ2UxIiB4PSI0MS41NjkiIHk9IjMxLjM4NSIgd2lkdGg9IjQ2MnB4IiBoZWlnaHQ9IjE5N3B4Ii8+CiAgICAgICAgPC9nPgogICAgPC9nPgogICAgPGRlZnM+CiAgICAgICAgPGltYWdlIGlkPSJfSW1hZ2UxIiB3aWR0aD0iNDYycHgiIGhlaWdodD0iMTk3cHgiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQU5TVWhFVWdBQUFjNFNBQUFERkNBWUFBQUNYQlJXMEFBQWdBRWxFUVZSNFhMU0JTQlNScGRmZWU0eE9lTXVlemV6NnoxY3VaeXpNeXpNeDU3cnI5ZXY5ZTFlNTVxNU56WXpNeFpPYmRkNWRNdG93MHcwMHcwMHcwMHcwMHV0eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4cDVxNHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4O0VYSVRDT0RFOiAwIgogICAgPC9kZWZzPgo8L3N2Zz4K"
|
||||||
|
alt="Mintel Logo"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
packages/mail/src/index.test.tsx
Normal file
25
packages/mail/src/index.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import * as React from "react";
|
||||||
|
import { render } from "./index";
|
||||||
|
import { MintelLogo } from "./components/MintelLogo";
|
||||||
|
import { ContactFormNotification } from "./templates/ContactFormNotification";
|
||||||
|
|
||||||
|
describe("@mintel/mail rendering", () => {
|
||||||
|
it("should render the MintelLogo to HTML", async () => {
|
||||||
|
const html = await render(React.createElement(MintelLogo));
|
||||||
|
expect(html).toContain("Mintel Logo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render a ContactFormNotification to HTML", async () => {
|
||||||
|
const html = await render(
|
||||||
|
React.createElement(ContactFormNotification, {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
message: "Hello World",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(html).toContain("New Submission");
|
||||||
|
expect(html).toContain("Test User");
|
||||||
|
expect(html).toContain("test@example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
packages/mail/src/index.ts
Normal file
24
packages/mail/src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { render as reactEmailRender } from "@react-email/components";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a React email template to HTML.
|
||||||
|
*/
|
||||||
|
export async function render(
|
||||||
|
template: ReactElement,
|
||||||
|
options?: any,
|
||||||
|
): Promise<string> {
|
||||||
|
return reactEmailRender(template, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export Components
|
||||||
|
export * from "./components/MintelLogo";
|
||||||
|
|
||||||
|
// Export Layouts
|
||||||
|
export * from "./layouts/BaseLayout";
|
||||||
|
export * from "./layouts/MintelLayout";
|
||||||
|
export * from "./layouts/ClientLayout";
|
||||||
|
|
||||||
|
// Export Templates
|
||||||
|
export * from "./templates/ContactFormNotification";
|
||||||
|
export * from "./templates/ConfirmationMessage";
|
||||||
53
packages/mail/src/layouts/BaseLayout.tsx
Normal file
53
packages/mail/src/layouts/BaseLayout.tsx
Normal 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",
|
||||||
|
};
|
||||||
80
packages/mail/src/layouts/ClientLayout.tsx
Normal file
80
packages/mail/src/layouts/ClientLayout.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Hr, Section, Text, Img } from "@react-email/components";
|
||||||
|
import { BaseLayout } from "./BaseLayout";
|
||||||
|
|
||||||
|
export interface ClientLayoutProps {
|
||||||
|
preview: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
clientLogo?: string;
|
||||||
|
clientName: string;
|
||||||
|
brandColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClientLayout = ({
|
||||||
|
preview,
|
||||||
|
children,
|
||||||
|
clientLogo,
|
||||||
|
clientName,
|
||||||
|
brandColor = "#82ed20",
|
||||||
|
}: ClientLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<BaseLayout preview={preview} brandColor={brandColor}>
|
||||||
|
<Section style={header}>
|
||||||
|
{clientLogo ? (
|
||||||
|
<Img src={clientLogo} alt={clientName} height="40" style={logo} />
|
||||||
|
) : (
|
||||||
|
<Text style={logoText(brandColor)}>{clientName}</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
<Hr style={hr} />
|
||||||
|
<Section style={mainContent}>{children}</Section>
|
||||||
|
<Hr style={hr} />
|
||||||
|
<Section style={footer}>
|
||||||
|
<Text style={footerText}>
|
||||||
|
© 2026 {clientName}. All rights reserved.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
marginBottom: "32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const logo = {
|
||||||
|
margin: "0 auto",
|
||||||
|
display: "block",
|
||||||
|
};
|
||||||
|
|
||||||
|
const logoText = (color: string) => ({
|
||||||
|
margin: "0 auto",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "#ffffff",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
borderLeft: `4px solid ${color}`,
|
||||||
|
paddingLeft: "12px",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainContent = {
|
||||||
|
marginBottom: "32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const hr = {
|
||||||
|
borderColor: "#222222",
|
||||||
|
margin: "20px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = {
|
||||||
|
marginTop: "32px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerText = {
|
||||||
|
fontSize: "10px",
|
||||||
|
color: "#333333",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
};
|
||||||
53
packages/mail/src/layouts/MintelLayout.tsx
Normal file
53
packages/mail/src/layouts/MintelLayout.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Hr, Section, Text } from "@react-email/components";
|
||||||
|
import { BaseLayout } from "./BaseLayout";
|
||||||
|
import { MintelLogo } from "../components/MintelLogo";
|
||||||
|
|
||||||
|
export interface MintelLayoutProps {
|
||||||
|
preview: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<BaseLayout preview={preview} brandColor="#82ed20">
|
||||||
|
<Section style={header}>
|
||||||
|
<MintelLogo />
|
||||||
|
</Section>
|
||||||
|
<Hr style={hr} />
|
||||||
|
<Section style={mainContent}>{children}</Section>
|
||||||
|
<Hr style={hr} />
|
||||||
|
<Section style={footer}>
|
||||||
|
<Text style={footerText}>
|
||||||
|
© 2026 Mintel Infrastructure. Secure Communication Channel.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
marginBottom: "32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainContent = {
|
||||||
|
marginBottom: "32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const hr = {
|
||||||
|
borderColor: "#222222",
|
||||||
|
margin: "20px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = {
|
||||||
|
marginTop: "32px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerText = {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#444444",
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
};
|
||||||
57
packages/mail/src/templates/ConfirmationMessage.tsx
Normal file
57
packages/mail/src/templates/ConfirmationMessage.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Heading, Text } from "@react-email/components";
|
||||||
|
import { ClientLayout } from "../layouts/ClientLayout";
|
||||||
|
|
||||||
|
export interface ConfirmationMessageProps {
|
||||||
|
name: string;
|
||||||
|
clientName: string;
|
||||||
|
clientLogo?: string;
|
||||||
|
brandColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmationMessage = ({
|
||||||
|
name,
|
||||||
|
clientName,
|
||||||
|
clientLogo,
|
||||||
|
brandColor,
|
||||||
|
}: ConfirmationMessageProps) => {
|
||||||
|
const preview = `Thank you for your message, ${name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClientLayout
|
||||||
|
preview={preview}
|
||||||
|
clientName={clientName}
|
||||||
|
clientLogo={clientLogo}
|
||||||
|
brandColor={brandColor}
|
||||||
|
>
|
||||||
|
<Heading style={h1}>Thank You</Heading>
|
||||||
|
<Text style={text}>Hello {name},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Thank you for contacting us. We have received your message and will get
|
||||||
|
back to you as soon as possible.
|
||||||
|
</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
The {clientName} Team
|
||||||
|
</Text>
|
||||||
|
</ClientLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmationMessage;
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
fontSize: "28px",
|
||||||
|
fontWeight: "900",
|
||||||
|
margin: "0 0 16px",
|
||||||
|
color: "#ffffff",
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
color: "#cccccc",
|
||||||
|
margin: "16px 0",
|
||||||
|
};
|
||||||
118
packages/mail/src/templates/ContactFormNotification.tsx
Normal file
118
packages/mail/src/templates/ContactFormNotification.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Heading, Section, Text, Row, Column } from "@react-email/components";
|
||||||
|
import { MintelLayout } from "../layouts/MintelLayout";
|
||||||
|
|
||||||
|
export interface ContactFormNotificationProps {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
productName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContactFormNotification = ({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
productName,
|
||||||
|
}: ContactFormNotificationProps) => {
|
||||||
|
const preview = `New message from ${name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MintelLayout preview={preview}>
|
||||||
|
<Heading style={h1}>New Submission</Heading>
|
||||||
|
<Text style={intro}>
|
||||||
|
A new message has been received via the contact form.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={detailsContainer}>
|
||||||
|
<Row>
|
||||||
|
<Column style={labelCol}>
|
||||||
|
<Text style={label}>Name</Text>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<Text style={value}>{name}</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Column style={labelCol}>
|
||||||
|
<Text style={label}>Email</Text>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<Text style={value}>{email}</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
{productName && (
|
||||||
|
<Row>
|
||||||
|
<Column style={labelCol}>
|
||||||
|
<Text style={label}>Product</Text>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<Text style={value}>{productName}</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={messageSection}>
|
||||||
|
<Text style={label}>Message</Text>
|
||||||
|
<Text style={messageText}>{message}</Text>
|
||||||
|
</Section>
|
||||||
|
</MintelLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactFormNotification;
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
fontSize: "28px",
|
||||||
|
fontWeight: "900",
|
||||||
|
margin: "0 0 16px",
|
||||||
|
color: "#ffffff",
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const intro = {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#888888",
|
||||||
|
margin: "0 0 32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailsContainer = {
|
||||||
|
backgroundColor: "#151515",
|
||||||
|
padding: "24px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "24px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelCol = {
|
||||||
|
width: "100px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = {
|
||||||
|
fontSize: "10px",
|
||||||
|
fontWeight: "900",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
color: "#444444",
|
||||||
|
margin: "0 0 4px",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#ffffff",
|
||||||
|
margin: "0 0 12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageSection = {
|
||||||
|
padding: "0 24px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageText = {
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontStyle: "italic",
|
||||||
|
borderLeft: "2px solid #222222",
|
||||||
|
paddingLeft: "16px",
|
||||||
|
margin: "12px 0 0",
|
||||||
|
};
|
||||||
14
packages/mail/tsconfig.json
Normal file
14
packages/mail/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
import fs from "node:fs";
|
||||||
const withNextIntl = createNextIntlPlugin();
|
import path from "node:path";
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
export const baseNextConfig = {
|
export const baseNextConfig = {
|
||||||
@@ -34,10 +34,30 @@ export const baseNextConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const withMintelConfig = (config) => {
|
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(
|
return withSentryConfig(
|
||||||
nextIntlConfig,
|
nextConfig,
|
||||||
{
|
{
|
||||||
silent: !process.env.CI,
|
silent: !process.env.CI,
|
||||||
treeshake: { removeDebugLogging: true },
|
treeshake: { removeDebugLogging: true },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next-intl": "^3.0.0",
|
"next-intl": "^4.8.2",
|
||||||
"@sentry/nextjs": "^8.0.0"
|
"@sentry/nextjs": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^21.0.0",
|
"@directus/sdk": "^21.0.0",
|
||||||
"next": "15.1.6",
|
"next": "15.1.6",
|
||||||
"next-intl": "^3.0.0",
|
"next-intl": "^4.8.2",
|
||||||
"zod": "^3.0.0"
|
"zod": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { isValidLang } from "../src/index";
|
import { isValidLang } from "./lang";
|
||||||
|
|
||||||
describe("next-utils", () => {
|
describe("next-utils", () => {
|
||||||
it("should validate languages correctly", () => {
|
it("should validate languages correctly", () => {
|
||||||
|
|||||||
@@ -30,12 +30,7 @@ export async function rateLimit(
|
|||||||
submissions[identifier] = now;
|
submissions[identifier] = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const languages = ["en", "de"] as const;
|
export * from "./lang";
|
||||||
export type Lang = (typeof languages)[number];
|
|
||||||
|
|
||||||
export function isValidLang(lang: string): lang is Lang {
|
|
||||||
return (languages as readonly string[]).includes(lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
export * from "./i18n";
|
export * from "./i18n";
|
||||||
export * from "./env";
|
export * from "./env";
|
||||||
|
|||||||
6
packages/next-utils/src/lang.ts
Normal file
6
packages/next-utils/src/lang.ts
Normal 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
726
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user