Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6501eac38a | |||
| 7f9206ae77 | |||
| 6229f8e886 | |||
| 8ac090aff3 | |||
| 696f9d361d | |||
| 31840da9e7 | |||
| 96ec2c7d8d | |||
| 9029375247 | |||
| 95d0a1622f | |||
| 646d615e76 | |||
| 51409099fc | |||
| 22cd20e639 | |||
| e7cc1c8ca5 | |||
| 0ccb15a929 | |||
| a94ddcfbb2 | |||
| d3a9af140c | |||
| 0dc3ba0da4 |
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.
|
||||
@@ -104,7 +104,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
@@ -117,7 +118,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.runtime
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
@@ -130,7 +132,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
@@ -143,7 +146,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -4,10 +4,30 @@ The Mintel CLI is the primary automation tool for managing the monorepo and ensu
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
The CLI is intended to be used within the monorepo:
|
||||
### Using npx (Recommended)
|
||||
|
||||
Run the CLI without installing it globally. This always uses the latest version from the registry:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
npx @mintel/cli init apps/my-new-website.com
|
||||
```
|
||||
|
||||
### Global Installation
|
||||
|
||||
Install the CLI globally from the Mintel registry:
|
||||
|
||||
```bash
|
||||
npm install -g @mintel/cli
|
||||
```
|
||||
|
||||
### Development (Local Link)
|
||||
|
||||
If you are contributing to the CLI, you can link it locally:
|
||||
|
||||
```bash
|
||||
cd packages/cli
|
||||
pnpm build
|
||||
npm link
|
||||
```
|
||||
|
||||
## 🛠 Commands
|
||||
@@ -17,10 +37,11 @@ pnpm install
|
||||
Scaffolds a new, production-ready client website in the specified path.
|
||||
|
||||
```bash
|
||||
pnpm --filter @mintel/cli start init apps/my-new-website.com
|
||||
mintel init apps/my-new-website.com
|
||||
```
|
||||
|
||||
#### What it does:
|
||||
|
||||
1. **Project Structure**: Creates a modern Next.js directory layout.
|
||||
2. **Shared Configs**: Generates `package.json`, `tsconfig.json`, and `eslint.config.mjs` that extend the `@mintel` shared packages.
|
||||
3. **Localization**: Sets up a localized routing structure (`src/app/[locale]`) with `next-intl` pre-configured.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
@@ -87,7 +88,7 @@ program
|
||||
.action(async (projectPath) => {
|
||||
const fullPath = path.isAbsolute(projectPath)
|
||||
? projectPath
|
||||
: path.resolve(process.cwd(), "../../", projectPath);
|
||||
: path.resolve(process.cwd(), projectPath);
|
||||
const projectName = path.basename(fullPath);
|
||||
|
||||
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
||||
|
||||
15
packages/gatekeeper/dev-output.log
Normal file
15
packages/gatekeeper/dev-output.log
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
> @mintel/gatekeeper@1.0.0 dev
|
||||
> next dev
|
||||
|
||||
⚠ Port 3000 is in use, trying 3001 instead.
|
||||
▲ Next.js 15.1.6
|
||||
- Local: http://localhost:3001
|
||||
- Network: http://192.168.1.126:3001
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
✓ Starting...
|
||||
warn - It seems like you don't have a global error handler set up. It is recommended that you add a global-error.js file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
✓ Ready in 2.7s
|
||||
[?25h
|
||||
@@ -24,6 +24,7 @@
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/next-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
6
packages/gatekeeper/postcss.config.cjs
Normal file
6
packages/gatekeeper/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
12
packages/gatekeeper/public/icon-white.svg
Normal file
12
packages/gatekeeper/public/icon-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
12
packages/gatekeeper/public/logo-black.svg
Normal file
12
packages/gatekeeper/public/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/gatekeeper/public/logo-white.svg
Normal file
12
packages/gatekeeper/public/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -19,7 +19,11 @@ export async function GET(req: NextRequest) {
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||
|
||||
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
|
||||
const gatekeeperUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
|
||||
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
|
||||
|
||||
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Lock, ShieldCheck, ArrowRight } from "lucide-react";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const redirectUrl = (params.redirect as string) || "/";
|
||||
const error = params.error === "1";
|
||||
|
||||
const projectName = process.env.PROJECT_NAME || "Mintel";
|
||||
const projectColor = process.env.PROJECT_COLOR || "#82ed20";
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const password = formData.get("password");
|
||||
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
|
||||
if (password === expectedPassword) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, expectedPassword, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
redirect(
|
||||
`/gatekeeper/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-mintel-dark">
|
||||
{/* Background Decor */}
|
||||
<div className="absolute inset-0 bg-grid pointer-events-none opacity-20" />
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, ${projectColor}11 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md px-6 animate-in fade-in zoom-in duration-700">
|
||||
{/* Logo / Icon */}
|
||||
<div className="flex justify-center mb-12">
|
||||
<div
|
||||
className="w-20 h-20 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl"
|
||||
style={{ borderBottom: `2px solid ${projectColor}44` }}
|
||||
>
|
||||
<ShieldCheck
|
||||
className="w-10 h-10"
|
||||
style={{ color: projectColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.03] backdrop-blur-3xl border border-white/10 p-10 rounded-[48px] shadow-2xl relative overflow-hidden group">
|
||||
{/* Subtle accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-1 opacity-50"
|
||||
style={{
|
||||
background: `linear-gradient(to right, transparent, ${projectColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-3xl font-black mb-3 tracking-tighter uppercase italic flex items-center justify-center gap-2">
|
||||
{projectName.split(" ")[0]}
|
||||
<span style={{ color: projectColor }}>GATEKEEPER</span>
|
||||
</h1>
|
||||
<p className="text-white/40 text-sm font-medium">
|
||||
Restricted Infrastructure Access
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-200 p-4 rounded-2xl mb-8 text-sm flex items-center gap-3 animate-pulse">
|
||||
<Lock className="w-5 h-5 flex-shrink-0" />
|
||||
<span>Invalid access password. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-8">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black uppercase tracking-[0.3em] text-white/20 ml-5">
|
||||
Authentication Code
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full bg-white/5 border border-white/10 rounded-3xl px-8 py-6 focus:outline-none focus:ring-2 transition-all text-xl tracking-[0.5em] text-center placeholder:tracking-normal placeholder:text-white/10"
|
||||
style={{ "--tw-ring-color": `${projectColor}33` } as any}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full font-black uppercase tracking-[0.2em] py-6 rounded-3xl transition-all active:scale-[0.98] flex items-center justify-center gap-3 shadow-lg hover:shadow-mintel-green/10"
|
||||
style={{ backgroundColor: projectColor, color: "#000" }}
|
||||
>
|
||||
Verify Identity
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-[10px] font-bold text-white/10 uppercase tracking-[0.5em]">
|
||||
© 2026 {projectName} Infrastructure
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,83 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #000c1f;
|
||||
--foreground: #ffffff;
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-sans font-bold text-slate-900 tracking-tighter;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-4 text-base leading-relaxed text-slate-700;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
@layer components {
|
||||
.narrow-container {
|
||||
@apply max-w-4xl mx-auto px-6 py-10;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.2s ease-in-out 0s 2;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Newsreader } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
style: "italic",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
@@ -12,7 +21,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
133
packages/gatekeeper/src/app/login/page.tsx
Normal file
133
packages/gatekeeper/src/app/login/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const redirectUrl = (params.redirect as string) || "/";
|
||||
const error = params.error === "1";
|
||||
|
||||
const projectName = process.env.PROJECT_NAME || "Mintel";
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const password = formData.get("password");
|
||||
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
|
||||
if (password === expectedPassword) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, expectedPassword, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
|
||||
{/* Background Decor - Signature mintel.me style */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
|
||||
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
|
||||
<div className="space-y-12 sm:space-y-16 animate-fade-in">
|
||||
{/* Top Icon Box - Signature mintel.me Black Square */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
|
||||
<Image
|
||||
src="/icon-white.svg"
|
||||
alt="Mintel"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12 animate-slide-up">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
|
||||
{projectName} <span className="text-slate-300">Gatekeeper</span>
|
||||
</h1>
|
||||
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
Infrastructure Protection
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Access Denied. Try Again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-6">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="GATEKEEPER CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100"
|
||||
>
|
||||
Unlock Access
|
||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Bottom Section - Full Branding Parity */}
|
||||
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
|
||||
<div className="h-px w-8 bg-slate-100" />
|
||||
<div className="opacity-80 transition-opacity hover:opacity-100">
|
||||
<Image
|
||||
src="/logo-black.svg"
|
||||
alt={projectName}
|
||||
width={140}
|
||||
height={40}
|
||||
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
|
||||
© 2026 MINTEL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
packages/gatekeeper/src/app/page.tsx
Normal file
5
packages/gatekeeper/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
59
packages/gatekeeper/tailwind.config.cjs
Normal file
59
packages/gatekeeper/tailwind.config.cjs
Normal file
@@ -0,0 +1,59 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
xl: "1rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "2rem",
|
||||
full: "9999px",
|
||||
},
|
||||
colors: {
|
||||
slate: {
|
||||
850: "#1e293b",
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "Inter", "system-ui", "sans-serif"],
|
||||
serif: ["var(--font-newsreader)", "Georgia", "serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fadeIn 0.5s ease-in-out",
|
||||
"slide-up": "slideUp 0.6s ease-out",
|
||||
"slide-down": "slideDown 0.6s ease-out",
|
||||
shake: "shake 0.2s ease-in-out 0s 2",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { transform: "translateY(20px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
slideDown: {
|
||||
"0%": { transform: "translateY(-20px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
shake: {
|
||||
"0%, 100%": { transform: "translateX(0)" },
|
||||
"25%": { transform: "translateX(-4px)" },
|
||||
"75%": { transform: "translateX(4px)" },
|
||||
},
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
industrial: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
mintel: {
|
||||
green: "#82ed20",
|
||||
blue: "#001a4d",
|
||||
dark: "#000c1f",
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,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],
|
||||
},
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
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
|
||||
|
||||
# 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 source (honoring .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# Use a secret for NPM_TOKEN to authenticate with private registry
|
||||
@@ -17,22 +13,24 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
# Build Gatekeeper and its dependencies
|
||||
RUN pnpm --filter @mintel/gatekeeper... build
|
||||
RUN mkdir -p packages/gatekeeper/public
|
||||
|
||||
# Build Gatekeeper
|
||||
RUN pnpm --filter @mintel/gatekeeper build
|
||||
|
||||
# Runner
|
||||
FROM base AS runner
|
||||
# Step 2: Runner stage
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
||||
COPY --from=builder /app/packages/gatekeeper/.next/standalone ./
|
||||
COPY --from=builder /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Step 1: Base image
|
||||
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
|
||||
RUN corepack enable pnpm
|
||||
@@ -16,4 +16,4 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Step 3: Build shared packages
|
||||
RUN pnpm -r build --filter="./packages/*"
|
||||
RUN pnpm --filter "./packages/*" -r build
|
||||
|
||||
@@ -194,7 +194,9 @@ jobs:
|
||||
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 }}
|
||||
|
||||
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 /
|
||||
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"]
|
||||
}
|
||||
662
pnpm-lock.yaml
generated
662
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user