Replace static Hello World with KenJim Technologies React app

- Vite + React frontend: hero, services grid, contact form
- Express + nodemailer backend for contact form → kenji@kenjim.com
- Multi-stage Docker builds; nginx inside frontend proxies /api to backend
- SMTP config via .env (see .env.example)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 15:18:16 +00:00
parent bcb354d074
commit e802f03f02
25 changed files with 809 additions and 40 deletions

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="KenJim Technologies — Enterprise Network Design, Security Solutions, AI Applications and Infrastructure Automation." />
<title>KenJim Technologies</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

18
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA routing — return index.html for all non-file routes
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to the backend container
location /api/ {
proxy_pass http://api:3001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "kenjim-technologies",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}

19
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,19 @@
import Nav from './components/Nav'
import Hero from './components/Hero'
import Services from './components/Services'
import Contact from './components/Contact'
import Footer from './components/Footer'
export default function App() {
return (
<>
<Nav />
<main>
<Hero />
<Services />
<Contact />
</main>
<Footer />
</>
)
}

View File

@@ -0,0 +1,130 @@
import { useState } from 'react'
import styles from './Contact.module.css'
const SERVICES = [
'Enterprise Network Design',
'Security Solutions',
'AI Applications',
'Infrastructure Automation',
'General Inquiry',
]
const INITIAL = { name: '', email: '', service: '', message: '' }
export default function Contact() {
const [form, setForm] = useState(INITIAL)
const [status, setStatus] = useState(null) // null | 'sending' | 'ok' | 'error'
const [errorMsg, setErrorMsg] = useState('')
function handleChange(e) {
setForm((f) => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e) {
e.preventDefault()
setStatus('sending')
setErrorMsg('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Unknown error')
setStatus('ok')
setForm(INITIAL)
} catch (err) {
setStatus('error')
setErrorMsg(err.message)
}
}
return (
<section id="contact">
<div className="container">
<div className={styles.layout}>
<div className={styles.info}>
<p className="section-label">Get in Touch</p>
<h2 className="section-title">Start a Conversation</h2>
<p className={styles.infoText}>
Whether you need a comprehensive network overhaul, a security audit,
or want to explore how AI can transform your operations we'd love
to hear from you.
</p>
<div className={styles.contact}>
<p className={styles.contactLabel}>Email us directly</p>
<a href="mailto:kenji@kenjim.com">kenji@kenjim.com</a>
</div>
</div>
<form className={styles.form} onSubmit={handleSubmit} noValidate>
<div className={styles.row}>
<label className={styles.field}>
<span>Name</span>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
placeholder="Your name"
required
/>
</label>
<label className={styles.field}>
<span>Email</span>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
placeholder="you@company.com"
required
/>
</label>
</div>
<label className={styles.field}>
<span>Service</span>
<select name="service" value={form.service} onChange={handleChange}>
<option value="">Select a service…</option>
{SERVICES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</label>
<label className={styles.field}>
<span>Message</span>
<textarea
name="message"
value={form.message}
onChange={handleChange}
placeholder="Tell us about your project or requirements…"
rows={5}
required
/>
</label>
{status === 'ok' && (
<p className={styles.success}>
✓ Message sent — we'll be in touch shortly.
</p>
)}
{status === 'error' && (
<p className={styles.error}>{errorMsg}</p>
)}
<button
type="submit"
className={styles.submit}
disabled={status === 'sending'}
>
{status === 'sending' ? 'Sending…' : 'Send Message'}
</button>
</form>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,110 @@
.layout {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 4rem;
align-items: start;
}
@media (max-width: 768px) {
.layout { grid-template-columns: 1fr; gap: 2.5rem; }
}
.infoText {
color: var(--text-muted);
line-height: 1.7;
margin-top: 1rem;
margin-bottom: 2rem;
}
.contactLabel {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.contact a {
color: var(--accent);
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 1.25rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 2rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 500px) {
.row { grid-template-columns: 1fr; }
}
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.field input,
.field select,
.field textarea {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 0.375rem;
color: var(--text);
font-size: 0.95rem;
padding: 0.65rem 0.85rem;
width: 100%;
transition: border-color 0.2s;
font-family: inherit;
resize: vertical;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
outline: none;
border-color: var(--accent);
}
.field select option { background: var(--bg-card); }
.submit {
background: var(--accent);
color: #080d1a;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 700;
padding: 0.8rem;
cursor: pointer;
transition: opacity 0.2s;
margin-top: 0.25rem;
}
.submit:hover:not(:disabled) { opacity: 0.88; }
.submit:disabled { opacity: 0.5; cursor: not-allowed; }
.success {
color: var(--success);
font-size: 0.9rem;
font-weight: 600;
}
.error {
color: var(--error);
font-size: 0.9rem;
}

View File

@@ -0,0 +1,19 @@
import styles from './Footer.module.css'
export default function Footer() {
return (
<footer className={styles.footer}>
<div className={styles.inner}>
<span className={styles.logo}>
<span className={styles.accent}>KenJim</span> Technologies
</span>
<span className={styles.copy}>
© {new Date().getFullYear()} KenJim Technologies. All rights reserved.
</span>
<a href="mailto:kenji@kenjim.com" className={styles.email}>
kenji@kenjim.com
</a>
</div>
</footer>
)
}

View File

@@ -0,0 +1,20 @@
.footer {
border-top: 1px solid var(--border);
padding: 2rem 1.5rem;
}
.inner {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.logo { font-weight: 700; font-size: 1rem; }
.accent { color: var(--accent); }
.copy { color: var(--text-muted); font-size: 0.875rem; }
.email { color: var(--text-muted); font-size: 0.875rem; }
.email:hover { color: var(--accent); }

View File

@@ -0,0 +1,24 @@
import styles from './Hero.module.css'
export default function Hero() {
return (
<section className={styles.hero}>
<div className={`container ${styles.inner}`}>
<p className="section-label">Enterprise Technology Consulting</p>
<h1 className={styles.headline}>
Engineering the Infrastructure<br />
<span className={styles.accent}>that Powers the Future</span>
</h1>
<p className={styles.sub}>
KenJim Technologies delivers expert consulting in enterprise network design,
cybersecurity, AI-driven applications, and infrastructure automation
built for organizations that demand reliability at scale.
</p>
<div className={styles.actions}>
<a href="#contact" className={styles.btnPrimary}>Request a Consultation</a>
<a href="#services" className={styles.btnSecondary}>Our Services</a>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,58 @@
.hero {
min-height: 90vh;
display: flex;
align-items: center;
padding: 6rem 1.5rem;
background:
radial-gradient(ellipse 80% 60% at 50% -10%, rgba(56,189,248,0.12) 0%, transparent 70%),
var(--bg);
}
.inner { max-width: 720px; }
.headline {
font-size: clamp(2.2rem, 5vw, 3.5rem);
font-weight: 800;
line-height: 1.15;
margin-bottom: 1.5rem;
}
.accent { color: var(--accent); }
.sub {
color: var(--text-muted);
font-size: 1.15rem;
line-height: 1.7;
margin-bottom: 2.5rem;
max-width: 600px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.btnPrimary, .btnSecondary {
padding: 0.75rem 1.75rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 1rem;
text-decoration: none;
transition: opacity 0.2s;
}
.btnPrimary {
background: var(--accent);
color: #080d1a;
}
.btnPrimary:hover { opacity: 0.88; text-decoration: none; }
.btnSecondary {
border: 1px solid var(--border);
color: var(--text);
background: transparent;
}
.btnSecondary:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }

View File

@@ -0,0 +1,17 @@
import styles from './Nav.module.css'
export default function Nav() {
return (
<header className={styles.nav}>
<div className={styles.inner}>
<a href="#" className={styles.logo}>
<span className={styles.logoAccent}>KenJim</span> Technologies
</a>
<nav className={styles.links}>
<a href="#services">Services</a>
<a href="#contact" className={styles.cta}>Contact Us</a>
</nav>
</div>
</header>
)
}

View File

@@ -0,0 +1,51 @@
.nav {
position: sticky;
top: 0;
z-index: 100;
background: rgba(8, 13, 26, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.inner {
max-width: 1100px;
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 1.2rem;
font-weight: 700;
color: var(--text);
text-decoration: none;
}
.logoAccent { color: var(--accent); }
.links {
display: flex;
align-items: center;
gap: 2rem;
}
.links a {
color: var(--text-muted);
font-size: 0.95rem;
text-decoration: none;
transition: color 0.2s;
}
.links a:hover { color: var(--text); }
.cta {
background: var(--accent);
color: #080d1a !important;
padding: 0.45rem 1.1rem;
border-radius: 0.375rem;
font-weight: 600;
}
.cta:hover { background: var(--accent-dim); }

View File

@@ -0,0 +1,74 @@
import styles from './Services.module.css'
const services = [
{
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round"
d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zm0 0v-9m0 0H3m9 0h9M12 3v9" />
</svg>
),
title: 'Enterprise Network Design',
description:
'Architecting resilient, high-performance networks for complex enterprise environments. From campus LAN to multi-site WAN, SD-WAN, and hybrid cloud connectivity.',
},
{
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
),
title: 'Security Solutions',
description:
'End-to-end cybersecurity strategy, assessment, and implementation. Zero-trust architecture, firewall design, intrusion detection, and compliance frameworks.',
},
{
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round"
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 002.25-2.25V6.75a2.25 2.25 0 00-2.25-2.25H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25zm.75-12h9v9h-9v-9z" />
</svg>
),
title: 'AI Applications',
description:
'Custom AI and machine learning solutions tailored to your business workflows. LLM integration, intelligent automation, data pipelines, and AI-powered decision systems.',
},
{
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round"
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
</svg>
),
title: 'Infrastructure Automation',
description:
'Eliminate manual operations with Infrastructure as Code, CI/CD pipelines, configuration management, and container orchestration built for scale and reliability.',
},
]
export default function Services() {
return (
<section id="services" className={styles.section}>
<div className="container">
<p className="section-label">What We Do</p>
<h2 className="section-title">Consulting Services</h2>
<p className="section-sub">
We bring deep technical expertise across the full stack of enterprise technology.
</p>
<div className={styles.grid}>
{services.map((s) => (
<div key={s.title} className={styles.card}>
<div className={styles.icon}>{s.icon}</div>
<h3 className={styles.cardTitle}>{s.title}</h3>
<p className={styles.cardDesc}>{s.description}</p>
<a href="#contact" className={styles.cardLink}>
Request a quote
</a>
</div>
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,55 @@
.section {
background: var(--bg-card);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-top: 3rem;
}
.card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border-color 0.2s;
}
.card:hover { border-color: var(--accent); }
.icon {
width: 2.5rem;
height: 2.5rem;
color: var(--accent);
}
.icon svg { width: 100%; height: 100%; }
.cardTitle {
font-size: 1.1rem;
font-weight: 700;
}
.cardDesc {
color: var(--text-muted);
font-size: 0.95rem;
line-height: 1.65;
flex: 1;
}
.cardLink {
font-size: 0.875rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
margin-top: 0.5rem;
}
.cardLink:hover { text-decoration: underline; }

56
frontend/src/index.css Normal file
View File

@@ -0,0 +1,56 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #080d1a;
--bg-card: #0f1829;
--bg-input: #0a1020;
--border: #1e2d45;
--accent: #38bdf8;
--accent-dim:#0ea5e9;
--text: #e2e8f0;
--text-muted:#64748b;
--success: #22c55e;
--error: #ef4444;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 1rem;
line-height: 1.6;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
section { padding: 6rem 1.5rem; }
.container {
max-width: 1100px;
margin: 0 auto;
}
.section-label {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 1rem;
}
.section-title {
font-size: clamp(1.75rem, 4vw, 2.5rem);
font-weight: 700;
line-height: 1.2;
margin-bottom: 1rem;
}
.section-sub {
color: var(--text-muted);
max-width: 560px;
font-size: 1.05rem;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
)

11
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001'
}
}
})