Compare commits

...
15 Commits
Author SHA1 Message Date
Jesse Egbosionu 5b05250635 refactor: remove contact section and related stats from profile data
CI and Deploy / Build site (push) Successful in 21s
CI and Deploy / Build and push image (push) Successful in 39s
CI and Deploy / Deploy to Kubernetes (push) Successful in 12s
2026-10-27 23:32:02 +01:00
Jesse Egbosionu b050375486 fix: add aria-label to logo link for accessibility; remove logo text
CI and Deploy / Build site (push) Successful in 20s
CI and Deploy / Build and push image (push) Successful in 38s
CI and Deploy / Deploy to Kubernetes (push) Successful in 11s
2026-10-24 23:41:37 +02:00
Jesse Egbosionu 80741aa0f7 Fix LemFi EUR to NGN rate integration
CI and Deploy / Build site (push) Successful in 21s
CI and Deploy / Build and push image (push) Successful in 40s
CI and Deploy / Deploy to Kubernetes (push) Successful in 12s
2026-10-24 23:36:39 +02:00
Jesse EgbosionuandClaude Opus 5.5 9ec94a305b feat: use favicon artwork for header logo mark; ignore .vscode
CI and Deploy / Build site (push) Successful in 23s
CI and Deploy / Build and push image (push) Successful in 40s
CI and Deploy / Deploy to Kubernetes (push) Successful in 11s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 22:12:25 +02:00
Jesse EgbosionuandClaude Opus 5.5 eefe924683 feat: add custom je favicon
CI and Deploy / Build site (push) Successful in 20s
CI and Deploy / Build and push image (push) Successful in 42s
CI and Deploy / Deploy to Kubernetes (push) Successful in 12s
SVG icon matching the header logo mark (dark tile, "je", emerald dot),
with a multi-size favicon.ico fallback and an apple-touch-icon.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 22:08:34 +02:00
Jesse EgbosionuandClaude Opus 5.5 5365bcdf82 ci: deploy :latest with rollout restart, single replica
CI and Deploy / Build site (push) Successful in 20s
CI and Deploy / Build and push image (push) Successful in 16s
CI and Deploy / Deploy to Kubernetes (push) Successful in 12s
Drop the sha-pinned overlay and automatic rollback. The deployment now
runs 1 replica with imagePullPolicy Always, and CI restarts it after
apply so the new :latest image is pulled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 22:02:08 +02:00
Jesse EgbosionuandClaude Opus 5.5 bab237834b ci: pin image via generated overlay instead of downloading kustomize
CI and Deploy / Build site (push) Successful in 19s
CI and Deploy / Build and push image (push) Successful in 21s
CI and Deploy / Deploy to Kubernetes (push) Successful in 20s
kubectl's built-in kustomize applies a temporary overlay that overrides
the image tag, removing the slow kustomize download from GitHub.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 21:47:08 +02:00
Jesse EgbosionuandClaude Opus 5.5 f2c0c2716b ci: silence npm audit, funding and update notices in npm ci
CI and Deploy / Build site (push) Successful in 20s
CI and Deploy / Build and push image (push) Successful in 30s
CI and Deploy / Deploy to Kubernetes (push) Failing after 2m33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 21:45:13 +02:00
Jesse EgbosionuandClaude Opus 5.5 da7999c52b feat: remove projects section and availability badge
CI and Deploy / Build and push image (push) Successful in 39s
CI and Deploy / Deploy to Kubernetes (push) Failing after 2m33s
CI and Deploy / Build site (push) Successful in 20s
- Drop projects section, nav link and data (ProjectCard removed in c5ff89f)
- Swap hero "View projects" button for a GitHub link
- Remove "Open to new opportunities" badge

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 21:42:49 +02:00
Jesse EgbosionuandClaude Opus 5.5 c5ff89f4e5 chore: upgrade to React 19 and use images.remotePatterns
CI and Deploy / Build site (push) Failing after 19s
CI and Deploy / Build and push image (push) Skipped
CI and Deploy / Deploy to Kubernetes (push) Skipped
Fixes Next.js 16 deprecation warnings for React 18 and images.domains.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 21:42:40 +02:00
Jesse Egbosionu c6b6c31df6 chore: update dependencies for Next.js and Tailwind CSS
CI and Deploy / Build site (push) Successful in 22s
CI and Deploy / Build and push image (push) Successful in 40s
CI and Deploy / Deploy to Kubernetes (push) Successful in 20s
- upgraded Next.js from 14.0.4 to 16.4.3
- upgraded Tailwind CSS from 2.1.2 to 4.3.3
2026-10-24 21:23:37 +02:00
Jesse Egbosionu 14a9e9511e feat: add kustomize setup and pin image version in deployment
CI and Deploy / Build site (push) Successful in 46s
CI and Deploy / Build and push image (push) Successful in 1m5s
CI and Deploy / Deploy to Kubernetes (push) Successful in 15s
2026-10-24 21:22:02 +02:00
Jesse Egbosionu a01bd975e2 chore: update caniuse-lite version and add license information 2026-10-24 21:19:51 +02:00
Jesse EgbosionuandClaude Opus 5.5 0927b9d665 ci: build, push and deploy to personal cluster on push to master
- Gitea Actions workflow: build, push sha-tagged image, apply kustomize, wait for rollout with auto-rollback
- Deploy to personal-site namespace, served at jesseinit.dev with TLS
- Run container as non-root and bind to 0.0.0.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 21:19:22 +02:00
Jesse EgbosionuandClaude Opus 5.5 3bb0a5e4a9 feat: redesign site as a modern developer portfolio
- New home page: hero, stats, about, focus areas, stack, projects, contact
- Light/dark theme with system default and persisted toggle
- Content centralised in data/profile.js
- Restyle /rates to match; remove it from nav and projects
- Afriex: log a clear error now that its API requires a device id

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 21:19:22 +02:00
33 changed files with 2659 additions and 2550 deletions
+2
View File
@@ -6,3 +6,5 @@ README.md
.env* .env*
.next .next
.git .git
.github
k8s
+139
View File
@@ -0,0 +1,139 @@
name: CI and Deploy
on:
workflow_dispatch:
pull_request:
branches:
- master
push:
branches:
- master
env:
REGISTRY: tea.jesseinit.dev
IMAGE_NAME: ${{ github.repository_owner }}/personal-site
NAMESPACE: personal-site
DEPLOYMENT: jesseinit-portfolio
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
ci:
name: Build site
runs-on: ubuntu-latest
timeout-minutes: 15
# The Gitea runner image only caches Node 24; match the Dockerfile's Node 22.
container: node:22-bookworm
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci --no-audit --no-fund --no-update-notifier
- name: Build
run: npm run build
- name: Validate Kubernetes manifests
run: |
curl -fsSL "https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
kubectl kustomize k8s/ > /dev/null
build-image:
name: Build and push image
needs: ci
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
- name: Build and push image
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
deploy:
name: Deploy to Kubernetes
needs: build-image
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up kubectl
uses: azure/setup-kubectl@v5.1.0
with:
version: v1.34.3
- name: Configure kubectl
env:
KUBE_CONFIG_B64: ${{ secrets.KUBE_CONFIG }}
run: |
set -euo pipefail
if [ -z "${KUBE_CONFIG_B64}" ]; then
echo "::error::KUBE_CONFIG is not configured in the production environment or repository."
exit 1
fi
KUBECONFIG_PATH="${RUNNER_TEMP}/personal-site-kubeconfig"
umask 077
printf '%s' "${KUBE_CONFIG_B64}" | base64 --decode > "${KUBECONFIG_PATH}"
echo "KUBECONFIG=${KUBECONFIG_PATH}" >> "${GITHUB_ENV}"
KUBECONFIG="${KUBECONFIG_PATH}" kubectl cluster-info --request-timeout=15s
- name: Apply manifests
run: kubectl apply -k k8s/
# The spec still says :latest, so force new pods to pull the image just pushed.
- name: Restart deployment
run: kubectl rollout restart "deployment/${DEPLOYMENT}" -n "${NAMESPACE}"
- name: Wait for rollout
run: |
if ! kubectl rollout status "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" --timeout=5m; then
kubectl get pods -n "${NAMESPACE}" -o wide
kubectl describe pods -n "${NAMESPACE}" | tail -40
exit 1
fi
- name: Verify deployment
run: |
kubectl get deployments,pods,services,ingress -n "${NAMESPACE}"
echo "### Deployed ${GITHUB_SHA} to ${NAMESPACE}" >> "${GITHUB_STEP_SUMMARY}"
+1
View File
@@ -33,3 +33,4 @@ yarn-error.log*
# vercel # vercel
.vercel .vercel
.claude .claude
.vscode
+9 -5
View File
@@ -5,7 +5,7 @@ WORKDIR /app
# Dependencies # Dependencies
FROM base AS dependencies FROM base AS dependencies
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci --no-audit --no-fund --no-update-notifier
# Build # Build
FROM base AS build FROM base AS build
@@ -16,8 +16,12 @@ RUN npm run build
# Run # Run
FROM base AS run FROM base AS run
ENV NODE_ENV=production ENV NODE_ENV=production
RUN mkdir .next ENV HOSTNAME=0.0.0.0
COPY --from=build /app/public ./public ENV PORT=3000
COPY --from=build /app/.next/standalone ./ RUN mkdir .next && chown node:node .next
COPY --from=build /app/.next/static ./.next/static COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"] CMD ["node", "server.js"]
+14 -8
View File
@@ -1,27 +1,33 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { profile } from '../data/profile';
const names = ['👨🏿‍💼 Jesse Egbosionu', '👨🏿‍💻 jesseinit', '😎 jessewithit']; const names = profile.handles;
export default function AnimatedName() { export default function AnimatedName() {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => { useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let timeout;
const interval = setInterval(() => { const interval = setInterval(() => {
setIsAnimating(true); setIsAnimating(true);
timeout = setTimeout(() => {
setTimeout(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % names.length); setCurrentIndex((prevIndex) => (prevIndex + 1) % names.length);
setIsAnimating(false); setIsAnimating(false);
}, 500); // Half of animation duration for smooth transition }, 350);
}, 3000); // Change name every 3 seconds }, 3000);
return () => clearInterval(interval); return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, []); }, []);
return ( return (
<span className="animated-name-wrapper"> <span className="animated-name" aria-label={names[0]}>
<span className={`highlight animated-name ${isAnimating ? 'fade-out' : 'fade-in'}`}> <span aria-hidden="true" className={isAnimating ? 'is-leaving' : 'is-entering'}>
{names[currentIndex]} {names[currentIndex]}
</span> </span>
</span> </span>
+7 -4
View File
@@ -1,11 +1,14 @@
import SocialLinks from './SocialLinks';
import { profile } from '../data/profile';
export default function Footer() { export default function Footer() {
return ( return (
<footer className="footer"> <footer className="footer">
<div className="footer-content"> <div className="shell footer-content">
<p> <p className="footer-text">
Built with <span className="heart">❤️</span> © {new Date().getFullYear()} {profile.name}. Built with Next.js, shipped on Kubernetes.
</p> </p>
<p className="footer-year">© {new Date().getFullYear()}</p> <SocialLinks variant="icons" />
</div> </div>
</footer> </footer>
); );
+47 -20
View File
@@ -1,31 +1,58 @@
import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import ThemeToggle from './ThemeToggle';
import Icon from './Icon';
const links = [
{ href: '/#about', label: 'About' },
{ href: '/#stack', label: 'Stack' },
];
export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
export default function Header({ showNavLinks = true }) {
return ( return (
<header className="header"> <header className={`header ${scrolled ? 'is-scrolled' : ''} ${open ? 'is-open' : ''}`}>
<nav className="navbar"> <nav className="navbar shell">
<div className="logo"> <Link href="/" className="logo" aria-label="Home" onClick={() => setOpen(false)}>
<Link href="/"> <svg className="logo-mark" viewBox="0 0 32 32" aria-hidden="true">
<span className="logo-text">JE</span> <rect width="32" height="32" rx="7" fill="#0c0a09" />
<g fill="none" stroke="#fafaf9" strokeWidth="3" strokeLinecap="round">
<path d="M11.5 13v9a3.5 3.5 0 0 1-3.5 3.5h-1" />
<path d="M15.5 18h10a5 5 0 1 0-1.46 3.54" />
</g>
<circle cx="11.5" cy="8" r="2" fill="#34d399" />
</svg>
</Link> </Link>
</div>
{showNavLinks && (
<div className="nav-links"> <div className="nav-links">
<Link href="/" className="nav-link"> {links.map((link) => (
Home <Link key={link.href} href={link.href} className="nav-link" onClick={() => setOpen(false)}>
</Link> {link.label}
<Link href="/rates" className="nav-link cta">
Rates
</Link> </Link>
))}
</div> </div>
)}
{!showNavLinks && ( <div className="nav-actions">
<div className="nav-links"> <ThemeToggle />
<a href="mailto:me@jesseinit.dev" className="nav-link cta"> <button
Get in touch type="button"
</a> className="icon-button nav-menu"
aria-label={open ? 'Close menu' : 'Open menu'}
aria-expanded={open}
onClick={() => setOpen(!open)}
>
<Icon name={open ? 'close' : 'menu'} />
</button>
</div> </div>
)}
</nav> </nav>
</header> </header>
); );
+86
View File
@@ -0,0 +1,86 @@
const paths = {
github: (
<path
fill="currentColor"
stroke="none"
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
),
linkedin: (
<path
fill="currentColor"
stroke="none"
d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"
/>
),
x: (
<path
fill="currentColor"
stroke="none"
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
),
arrowRight: <path d="M5 12h14M13 6l6 6-6 6" />,
arrowUpRight: <path d="M7 17 17 7M8 7h9v9" />,
mail: (
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</>
),
sun: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</>
),
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
server: (
<>
<rect x="3" y="4" width="18" height="7" rx="2" />
<rect x="3" y="13" width="18" height="7" rx="2" />
<path d="M7 7.5h.01M7 16.5h.01" />
</>
),
card: (
<>
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M2 10h20M6 15h4" />
</>
),
route: (
<>
<circle cx="6" cy="19" r="2" />
<circle cx="18" cy="5" r="2" />
<path d="M8 19h8.5a3.5 3.5 0 0 0 0-7h-9a3.5 3.5 0 0 1 0-7H16" />
</>
),
cloud: <path d="M17.5 19a4.5 4.5 0 1 0-1.41-8.78A6 6 0 0 0 4.5 12.5 3.5 3.5 0 0 0 6 19z" />,
pin: (
<>
<path d="M12 21s-7-6.2-7-11a7 7 0 1 1 14 0c0 4.8-7 11-7 11z" />
<circle cx="12" cy="10" r="2.5" />
</>
),
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
close: <path d="M6 6l12 12M18 6 6 18" />,
};
export default function Icon({ name, size = 18, className = '' }) {
return (
<svg
className={`icon ${className}`}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{paths[name]}
</svg>
);
}
+17 -9
View File
@@ -2,19 +2,27 @@ import Head from 'next/head';
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
export default function Layout({ children, title, description, showNavLinks = true }) { export default function Layout({ children, title, description }) {
const pageTitle = title || 'Jesse Egbosionu · Software Engineer';
const pageDescription =
description || 'Software engineer based in Amsterdam, focused on building scalable backend systems.';
return ( return (
<div className="container"> <div className="page">
<Head> <Head>
<title>{title || 'Jesse Egbosionu - Software Engineer'}</title> <title>{pageTitle}</title>
<link rel="icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta <meta name="description" content={pageDescription} />
name="description" <meta property="og:title" content={pageTitle} />
content={description || 'Software engineer based in Amsterdam, focused on building scalable backend systems.'} <meta property="og:description" content={pageDescription} />
/> <meta property="og:type" content="website" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#0c0a09" />
</Head> </Head>
<Header showNavLinks={showNavLinks} /> <Header />
<main className="main-content">{children}</main> <main className="main-content">{children}</main>
+8 -11
View File
@@ -1,30 +1,27 @@
import Icon from './Icon';
export default function RateCard({ provider, rate, href, isBest, index, formatCurrency, logo }) { export default function RateCard({ provider, rate, href, isBest, index, formatCurrency, logo }) {
return ( return (
<a <a
href={href} href={href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`rate-card ${isBest ? 'best' : ''}`} className={`rate-card ${isBest ? 'is-best' : ''}`}
style={{ animationDelay: `${index * 0.1}s` }} style={{ animationDelay: `${index * 60}ms` }}
> >
<div className="rate-card-header"> <div className="rate-card-header">
<div className="provider-info"> <div className="provider-info">
{logo && ( {logo && <img src={logo} alt="" className="provider-logo" />}
<img
src={logo}
alt={`${provider} logo`}
className="provider-logo"
/>
)}
<h3 className="provider-name">{provider}</h3> <h3 className="provider-name">{provider}</h3>
</div> </div>
{isBest && <span className="best-badge">Best Rate</span>} {isBest && <span className="pill pill-accent">Best rate</span>}
</div> </div>
<div className="rate-value">{formatCurrency(rate)}</div> <div className="rate-value">{formatCurrency(rate)}</div>
<div className="rate-card-footer"> <div className="rate-card-footer">
<span className="visit-link">Visit provider →</span> <span>Visit provider</span>
<Icon name="arrowUpRight" size={16} className="rate-arrow" />
</div> </div>
</a> </a>
); );
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from 'react';
// Fades children in when they scroll into view.
export default function Reveal({ as: Tag = 'div', delay = 0, className = '', children, ...rest }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node || typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin: '0px 0px -10% 0px' }
);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<Tag
ref={ref}
className={`reveal ${visible ? 'is-visible' : ''} ${className}`}
style={{ transitionDelay: `${delay}ms` }}
{...rest}
>
{children}
</Tag>
);
}
+14
View File
@@ -0,0 +1,14 @@
import Reveal from './Reveal';
export default function SectionHeading({ index, eyebrow, title, children }) {
return (
<Reveal className="section-heading">
<p className="eyebrow">
<span className="eyebrow-index">{index}</span>
{eyebrow}
</p>
<h2 className="section-title">{title}</h2>
{children && <p className="section-lead">{children}</p>}
</Reveal>
);
}
+9 -35
View File
@@ -1,46 +1,20 @@
const socialData = [ import Icon from './Icon';
{ import { socials } from '../data/profile';
name: 'GitHub',
url: 'https://github.com/jesseinit',
icon: (
<svg className="social-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
),
},
{
name: 'LinkedIn',
url: 'https://linkedin.com/in/jesse-egbosionu/',
icon: (
<svg className="social-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
</svg>
),
},
{
name: 'Twitter',
url: 'https://twitter.com/jesseinit/',
icon: (
<svg className="social-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
),
},
];
export default function SocialLinks() { export default function SocialLinks({ variant = 'icons' }) {
return ( return (
<div className="social-links"> <div className={`social-links social-links-${variant}`}>
{socialData.map((social) => ( {socials.map((social) => (
<a <a
key={social.name} key={social.name}
href={social.url} href={social.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="social-link" className={variant === 'icons' ? 'icon-button' : 'social-link'}
aria-label={social.name}
> >
{social.icon} <Icon name={social.icon} size={variant === 'icons' ? 16 : 18} />
{social.name} {variant !== 'icons' && <span>{social.name}</span>}
</a> </a>
))} ))}
</div> </div>
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import Icon from './Icon';
export default function ThemeToggle() {
const [theme, setTheme] = useState(null);
useEffect(() => {
setTheme(document.documentElement.dataset.theme || 'light');
}, []);
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
try {
localStorage.setItem('theme', next);
} catch (e) {}
setTheme(next);
};
return (
<button
type="button"
className="icon-button"
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} />
</button>
);
}
+54
View File
@@ -0,0 +1,54 @@
// Single source of truth for site content. Edit here, the UI follows.
export const profile = {
name: 'Jesse Egbosionu',
firstName: 'Jesse',
handles: ['Jesse Egbosionu', 'jesseinit', 'jessewithit'],
role: 'Software Engineer',
location: 'Amsterdam, NL',
email: 'me@jesseinit.dev',
tagline: 'I build backend systems that scale, and the products on top of them.',
intro:
'I architect and scale backend systems that handle millions of requests. From payment processing to logistics orchestration, I turn complex technical challenges into reliable production infrastructure.',
about: [
'I’m a software engineer based in Amsterdam with 6+ years of experience shipping products across finance, e-commerce, and logistics.',
'Most of my work lives on the backend: designing APIs, modelling data, and running services in production. I care about systems that are boring in the best way: observable, well-tested, and easy for the next engineer to pick up.',
'When I’m not at work, I build small tools that scratch my own itch.',
],
};
export const focusAreas = [
{
title: 'Backend architecture',
description: 'Service design, APIs and data models that hold up under real traffic.',
icon: 'server',
},
{
title: 'Payments & fintech',
description: 'Transaction flows, reconciliation and integrations with payment providers.',
icon: 'card',
},
{
title: 'Logistics orchestration',
description: 'Event-driven workflows that move orders, parcels and people on time.',
icon: 'route',
},
{
title: 'Infra & delivery',
description: 'Containerised deployments on Kubernetes, CI/CD and production observability.',
icon: 'cloud',
},
];
export const stack = [
{ group: 'Languages', items: ['Python', 'JavaScript', 'Go'] },
{ group: 'Frameworks', items: ['Django', 'FastAPI', 'Flask', 'Node.js', 'React', 'Next.js'] },
{ group: 'Data', items: ['PostgreSQL', 'MongoDB', 'Redis', 'Kafka', 'Pub/Sub'] },
{ group: 'Infrastructure', items: ['Docker', 'Kubernetes', 'CI/CD'] },
];
export const socials = [
{ name: 'GitHub', url: 'https://github.com/jesseinit', icon: 'github' },
{ name: 'LinkedIn', url: 'https://linkedin.com/in/jesse-egbosionu/', icon: 'linkedin' },
{ name: 'X', url: 'https://twitter.com/jesseinit/', icon: 'x' },
];
+31 -215
View File
@@ -1,225 +1,41 @@
# Kubernetes Deployment Guide # Kubernetes deployment
This directory contains all the necessary Kubernetes manifests to deploy the jesseinit portfolio application. The site runs on the personal k3s cluster in the `personal-site` namespace and is served at
https://jesseinit.dev (`www.` redirects to the apex). TLS comes from cert-manager (`letsencrypt-prod`).
## Prerequisites ## How deploys work
- Kubernetes cluster (local or cloud) `.github/workflows/deploy.yml` (run by Gitea Actions) on every push to `master`:
- kubectl CLI installed and configured
- Docker for building the image
- (Optional) kustomize for managing configurations
## Files Overview 1. **Build site**: `npm ci && npm run build`, and renders the kustomization to catch manifest errors.
2. **Build and push image**: `tea.jesseinit.dev/jesseinit/personal-site:latest`.
3. **Deploy**: runs `kubectl apply -k k8s/`, then `kubectl rollout restart` so the pod pulls the new
`:latest` (`imagePullPolicy: Always`), and waits for the rollout. There is no automatic rollback.
- `deployment.yaml` - Kubernetes Deployment with 2 replicas, health checks, and resource limits Pull requests run step 1 only. Old images are cleaned up by the nightly `gitea-package-prune` cronjob.
- `service.yaml` - ClusterIP Service exposing the application on port 80
- `ingress.yaml` - Ingress resource for external access (requires ingress controller)
- `configmap.yaml` - ConfigMap for environment variables
- `kustomization.yaml` - Kustomize configuration for easy deployment management
## Quick Start ### Required secrets (repo or `production` environment in Gitea)
### 1. Build the Docker Image | Secret | Value |
| ---------------- | ----------------------------------------------------- |
| `REGISTRY_TOKEN` | Gitea token with `write:package` for `jesseinit` |
| `KUBE_CONFIG` | base64-encoded kubeconfig for the personal cluster |
## Files
- `namespace.yaml`: `personal-site` namespace
- `deployment.yaml`: 1 replica, zero-downtime rolling updates, non-root, health checks
- `service.yaml`: ClusterIP on port 80 → container port 3000
- `ingress.yaml`: nginx ingress + TLS for `jesseinit.dev` / `www.jesseinit.dev`
- `configmap.yaml`: optional environment variables
- `kustomization.yaml`: ties it together and sets the image
## Manual operations
```bash ```bash
# Build the image kubectl --context personal-cluster -n personal-site get pods,ingress,certificate
docker build -t jesseinit-portfolio:latest . kubectl --context personal-cluster -n personal-site logs -l app=jesseinit-portfolio -f
# Deploy by hand (uses :latest)
# If using a registry, tag and push kubectl --context personal-cluster apply -k k8s/
docker tag jesseinit-portfolio:latest <your-registry>/jesseinit-portfolio:latest kubectl --context personal-cluster -n personal-site rollout restart deployment/jesseinit-portfolio
docker push <your-registry>/jesseinit-portfolio:latest
``` ```
### 2. Update Image Reference (if using a registry)
Edit `k8s/kustomization.yaml` and update the image reference:
```yaml
images:
- name: jesseinit-portfolio
newName: <your-registry>/jesseinit-portfolio
newTag: latest
```
### 3. Deploy to Kubernetes
#### Option A: Using kubectl
```bash
# Apply all manifests
kubectl apply -f k8s/
# Verify deployment
kubectl get pods -l app=jesseinit-portfolio
kubectl get svc jesseinit-portfolio
kubectl get ingress jesseinit-portfolio
```
#### Option B: Using Kustomize
```bash
# Deploy using kustomize
kubectl apply -k k8s/
# Or build and preview first
kubectl kustomize k8s/ | kubectl apply -f -
```
### 4. Configure Ingress (Optional)
Edit `k8s/ingress.yaml` to match your setup:
1. Update the `host` field with your domain
2. Set the correct `ingressClassName` (nginx, traefik, etc.)
3. Uncomment TLS section if using HTTPS
4. Add appropriate annotations for your ingress controller
Example for NGINX with cert-manager:
```yaml
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
```
### 5. Add Environment Variables (Optional)
Edit `k8s/configmap.yaml` to add any required environment variables:
```yaml
data:
API_URL: "https://api.example.com"
NEXT_PUBLIC_SITE_URL: "https://jesseinit.com"
```
## Local Development with Minikube
```bash
# Start minikube
minikube start
# Build image in minikube's Docker daemon
eval $(minikube docker-env)
docker build -t jesseinit-portfolio:latest .
# Deploy
kubectl apply -k k8s/
# Access the application
minikube service jesseinit-portfolio
```
## Monitoring
### Check Pod Status
```bash
kubectl get pods -l app=jesseinit-portfolio
kubectl logs -l app=jesseinit-portfolio -f
kubectl describe pod <pod-name>
```
### Check Service
```bash
kubectl get svc jesseinit-portfolio
kubectl describe svc jesseinit-portfolio
```
### Port Forward for Local Testing
```bash
kubectl port-forward svc/jesseinit-portfolio 8080:80
# Access at http://localhost:8080
```
## Scaling
```bash
# Scale to 3 replicas
kubectl scale deployment jesseinit-portfolio --replicas=3
# Or edit deployment.yaml and reapply
```
## Updating the Application
```bash
# Build new image with new tag
docker build -t jesseinit-portfolio:v2 .
docker push <your-registry>/jesseinit-portfolio:v2
# Update the image
kubectl set image deployment/jesseinit-portfolio portfolio=<your-registry>/jesseinit-portfolio:v2
# Or update kustomization.yaml and reapply
kubectl apply -k k8s/
```
## Troubleshooting
### Pods not starting
```bash
kubectl describe pod <pod-name>
kubectl logs <pod-name>
```
### Service not accessible
```bash
kubectl get endpoints jesseinit-portfolio
kubectl describe svc jesseinit-portfolio
```
### Image pull errors
If using a private registry, create an image pull secret:
```bash
kubectl create secret docker-registry regcred \
--docker-server=<your-registry> \
--docker-username=<username> \
--docker-password=<password> \
--docker-email=<email>
```
Then add to `deployment.yaml`:
```yaml
spec:
imagePullSecrets:
- name: regcred
```
## Resource Management
The deployment includes default resource requests and limits:
- Requests: 128Mi memory, 100m CPU
- Limits: 512Mi memory, 500m CPU
Adjust these in `deployment.yaml` based on your application's needs.
## Cleanup
```bash
# Delete all resources
kubectl delete -k k8s/
# Or delete individually
kubectl delete -f k8s/
```
## Production Considerations
1. **Use a specific image tag** instead of `latest` for better version control
2. **Set up HorizontalPodAutoscaler** for automatic scaling
3. **Configure persistent storage** if needed (for logs, cache, etc.)
4. **Add network policies** for security
5. **Set up monitoring** with Prometheus/Grafana
6. **Configure TLS/SSL** certificates for HTTPS
7. **Use secrets** for sensitive data instead of ConfigMaps
8. **Implement resource quotas** and limit ranges
9. **Set up backup and disaster recovery** procedures
10. **Use namespace isolation** for multi-environment deployments
+16 -2
View File
@@ -5,7 +5,13 @@ metadata:
labels: labels:
app: jesseinit-portfolio app: jesseinit-portfolio
spec: spec:
replicas: 2 replicas: 1
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector: selector:
matchLabels: matchLabels:
app: jesseinit-portfolio app: jesseinit-portfolio
@@ -17,19 +23,27 @@ spec:
containers: containers:
- name: portfolio - name: portfolio
image: jesseinit-portfolio:latest image: jesseinit-portfolio:latest
imagePullPolicy: IfNotPresent imagePullPolicy: Always
ports: ports:
- containerPort: 3000 - containerPort: 3000
name: http name: http
env: env:
- name: NODE_ENV - name: NODE_ENV
value: "production" value: "production"
- name: HOSTNAME
value: "0.0.0.0"
- name: PORT
value: "3000"
- name: NEXT_TELEMETRY_DISABLED - name: NEXT_TELEMETRY_DISABLED
value: "1" value: "1"
envFrom: envFrom:
- configMapRef: - configMapRef:
name: jesseinit-portfolio-config name: jesseinit-portfolio-config
optional: true optional: true
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
resources: resources:
requests: requests:
memory: "128Mi" memory: "128Mi"
+11 -10
View File
@@ -5,13 +5,19 @@ metadata:
labels: labels:
app: jesseinit-portfolio app: jesseinit-portfolio
annotations: annotations:
# Uncomment and configure based on your ingress controller cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
# cert-manager.io/cluster-issuer: "letsencrypt-prod" # Redirect www.jesseinit.dev -> jesseinit.dev
nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
spec: spec:
ingressClassName: nginx # Change to your ingress class (nginx, traefik, etc.) ingressClassName: nginx
tls:
- hosts:
- jesseinit.dev
- www.jesseinit.dev
secretName: personal-site-tls
rules: rules:
- host: jesseinit.com # Replace with your domain - host: jesseinit.dev
http: http:
paths: paths:
- path: / - path: /
@@ -21,8 +27,3 @@ spec:
name: jesseinit-portfolio name: jesseinit-portfolio
port: port:
number: 80 number: 80
# Uncomment for TLS/HTTPS support
# tls:
# - hosts:
# - jesseinit.com
# secretName: jesseinit-portfolio-tls
+8 -4
View File
@@ -1,18 +1,22 @@
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
namespace: default namespace: personal-site
resources: resources:
- namespace.yaml
- configmap.yaml - configmap.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- ingress.yaml - ingress.yaml
commonLabels: labels:
app: jesseinit-portfolio - pairs:
app.kubernetes.io/name: personal-site
app.kubernetes.io/managed-by: kustomize
# CI deploys :latest and runs `kubectl rollout restart` to pick up the new image.
images: images:
- name: jesseinit-portfolio - name: jesseinit-portfolio
newName: jesseinit-portfolio newName: tea.jesseinit.dev/jesseinit/personal-site
newTag: latest newTag: latest
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: personal-site
+6 -1
View File
@@ -1,6 +1,11 @@
module.exports = { module.exports = {
images: { images: {
domains: ['res.cloudinary.com'], remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
],
}, },
output: 'standalone', output: 'standalone',
}; };
+814 -1391
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -5,17 +5,18 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start" "start": "next start",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"next": "^14.0.4", "next": "^16.4.3",
"react": "^18.2.0", "react": "^19.3.1",
"react-dom": "^18.2.0", "react-dom": "^19.3.1",
"sass": "^1.32.12" "sass": "^1.32.12"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.2.5", "autoprefixer": "^10.2.5",
"postcss": "^8.2.13", "postcss": "^8.2.13",
"tailwindcss": "^2.1.2" "tailwindcss": "^4.3.3"
} }
} }
+19
View File
@@ -0,0 +1,19 @@
import { Html, Head, Main, NextScript } from 'next/document';
// Set the theme before first paint to avoid a light/dark flash.
const themeScript = `(function(){try{var t=localStorage.getItem('theme');if(!t){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.dataset.theme=t}catch(e){}})();`;
export default function Document() {
return (
<Html lang="en">
<Head>
<link rel="preload" href="/fonts/inter_font.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
+17 -34
View File
@@ -1,4 +1,4 @@
import { log } from "console"; import { fetchLemfiRate } from "../../../utils/lemfi.js";
const COMMON_HEADERS = { const COMMON_HEADERS = {
'accept': '*/*', 'accept': '*/*',
@@ -194,40 +194,19 @@ export default async (req, res) => {
}); });
const lemfiCall = new Promise((resolve, reject) => { const lemfiHref = "https://lemfi.com/en-gb/international-money-transfer";
fetch("https://lemfi.com/api/lemonade/v2/exchange", { const lemfiProvider = "LemFi (referral code - OBIN6518)";
headers: { const lemfiCall = fetchLemfiRate()
accept: "application/json", .then((rate) => ({
"accept-language": "en-GB,en;q=0.9,nl-NL;q=0.8,nl;q=0.7,en-US;q=0.6", rate: rate.toFixed(2),
"content-type": "application/json", provider: lemfiProvider,
priority: "u=1, i",
"sec-ch-ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin"
},
referrer: "https://lemfi.com/",
referrerPolicy: "origin",
body: '{"from":"EUR","to":"NGN"}',
method: "POST",
mode: "cors",
credentials: "include"
}).then((response) => resolve(response.json()))
.catch((error) => reject(error));
}).then((result) => {
return {
rate: 0,
provider: "Lemfi(referal code - OBIN6518)",
bestRate: false, bestRate: false,
href: "https://lemfi.com", href: lemfiHref,
logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSMYTBiPB_joupdSSoBL1fA0wySDPcMpY92XQ&s" logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSMYTBiPB_joupdSSoBL1fA0wySDPcMpY92XQ&s"
}; }))
})
.catch((error) => { .catch((error) => {
console.error("Error calling Lemfi:", error); console.error("Error calling LemFi:", error);
return createDefaultErrorResponse("Lemfi(referal code - OBIN6518)", "https://lemfi.com"); return createDefaultErrorResponse(lemfiProvider, lemfiHref);
}); });
const afriexCall = new Promise((resolve, reject) => { const afriexCall = new Promise((resolve, reject) => {
@@ -240,7 +219,11 @@ export default async (req, res) => {
.catch((error) => reject(error)); .catch((error) => reject(error));
}) })
.then((result) => { .then((result) => {
const ngnRate = result.rates.EUR.NGN; // Afriex now rejects unauthenticated requests with {"message":"Device id missing"}
const ngnRate = result?.rates?.EUR?.NGN;
if (!ngnRate) {
throw new Error(`Unexpected response: ${result?.message || JSON.stringify(result).slice(0, 200)}`);
}
return { return {
rate: parseFloat(ngnRate).toFixed(2), rate: parseFloat(ngnRate).toFixed(2),
provider: "Afriex", provider: "Afriex",
@@ -250,7 +233,7 @@ export default async (req, res) => {
}; };
}) })
.catch((error) => { .catch((error) => {
console.error("Error calling Afriex:", error); console.error("Error calling Afriex:", error.message);
return createDefaultErrorResponse("Afriex", "https://www.afriex.com/"); return createDefaultErrorResponse("Afriex", "https://www.afriex.com/");
}); });
+101 -35
View File
@@ -1,51 +1,117 @@
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import SocialLinks from '../components/SocialLinks'; import SocialLinks from '../components/SocialLinks';
import AnimatedName from '../components/AnimatedName'; import AnimatedName from '../components/AnimatedName';
import SectionHeading from '../components/SectionHeading';
import Reveal from '../components/Reveal';
import Icon from '../components/Icon';
import { profile, focusAreas, stack } from '../data/profile';
export default function Home() { export default function Home() {
return ( return (
<Layout showNavLinks={false}> <Layout>
{/* HERO */}
<section className="hero"> <section className="hero">
<h1 className="hero-title"> <div className="hero-backdrop" aria-hidden="true" />
Hey, I'm <AnimatedName /> <div className="shell hero-grid">
</h1>
<div className="hero-main">
<div className="hero-content"> <div className="hero-content">
<h1 className="hero-title">
Hey, I’m {profile.firstName}.
<span className="hero-title-muted">{profile.tagline}</span>
</h1>
<p className="hero-subtitle"> <p className="hero-subtitle">{profile.intro}</p>
A software engineer building systems that scale in Amsterdam
</p>
<div className="hero-description"> <div className="hero-actions">
<p> <a href={`mailto:${profile.email}`} className="button button-primary">
I architect and scale backend systems that handle millions of requests. Let’s work together
From payment processing to logistics orchestration, I turn complex <Icon name="arrowRight" size={16} />
technical challenges into reliable production infrastructure. </a>
</p> <a href="https://github.com/jesseinit" target="_blank" rel="noopener noreferrer" className="button button-ghost">
<Icon name="github" size={16} />
<p> View GitHub
6 years shipping products across <strong>finance</strong>, <strong>ecommerce</strong>, and <strong>logistics</strong>.
I work with Python (Django, FastAPI), JavaScript (React, Node.js), PostgreSQL,
Docker, Redis, and Kubernetes to build systems that just work.
</p>
</div>
<SocialLinks />
<a href="mailto:me@jesseinit.dev" className="primary-cta">
<span>Let's work together</span>
<span className="arrow">→</span>
</a> </a>
</div> </div>
<div className="hero-image-wrapper"> <div className="hero-meta">
<div className="image-backdrop"></div> <span className="meta-item">
<img <Icon name="pin" size={16} /> {profile.location}
src="hero.jpg" </span>
alt="Jesse Egbosionu" <span className="meta-divider" />
className="hero-image" <SocialLinks variant="icons" />
/> </div>
</div>
<div className="hero-visual">
<div className="portrait">
<img src="/hero.jpg" alt={profile.name} className="portrait-image" />
</div>
<div className="terminal-card" aria-hidden="true">
<div className="terminal-bar">
<span />
<span />
<span />
<em>~/whoami</em>
</div>
<pre className="terminal-body">
<code>
<span className="t-prompt">$</span> whoami{'\n'}
<span className="t-accent">
<AnimatedName />
</span>
{'\n'}
<span className="t-prompt">$</span> cat role.txt{'\n'}
<span className="t-muted">{profile.role.toLowerCase()} @ {profile.location.split(',')[0].toLowerCase()}</span>
</code>
</pre>
</div>
</div>
</div>
</section>
{/* ABOUT */}
<section id="about" className="section">
<div className="shell">
<SectionHeading index="01" eyebrow="About" title="Engineering for the long run." />
<div className="about-grid">
<Reveal className="about-copy">
{profile.about.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</Reveal>
<div className="focus-grid">
{focusAreas.map((area, i) => (
<Reveal key={area.title} className="focus-card" delay={i * 80}>
<span className="focus-icon">
<Icon name={area.icon} size={20} />
</span>
<h3 className="focus-title">{area.title}</h3>
<p className="focus-description">{area.description}</p>
</Reveal>
))}
</div>
</div>
</div>
</section>
{/* STACK */}
<section id="stack" className="section">
<div className="shell">
<SectionHeading index="02" eyebrow="Stack" title="Tools I reach for.">
The technologies I use day to day to design, build and run production systems.
</SectionHeading>
<div className="stack-grid">
{stack.map((group, i) => (
<Reveal key={group.group} className="stack-group" delay={i * 80}>
<h3 className="stack-title">{group.group}</h3>
<ul className="tag-list">
{group.items.map((item) => (
<li key={item} className="tag tag-large">
{item}
</li>
))}
</ul>
</Reveal>
))}
</div> </div>
</div> </div>
</section> </section>
+29 -29
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Layout from "../components/Layout"; import Layout from "../components/Layout";
import RateCard from "../components/RateCard"; import RateCard from "../components/RateCard";
import Icon from "../components/Icon";
import { fetchData } from "../utils/api"; import { fetchData } from "../utils/api";
export default function Rates() { export default function Rates() {
@@ -49,25 +50,27 @@ export default function Rates() {
return ( return (
<Layout <Layout
title="EUR to NGN Exchange Rates - Jesse Egbosionu" title="EUR → NGN Exchange Rates · Jesse Egbosionu"
description="Compare live EUR to NGN exchange rates across multiple providers." description="Compare live EUR to NGN exchange rates across multiple providers."
showNavLinks={false}
> >
<section className="rates-section"> <section className="section rates-section">
<div className="shell">
<div className="rates-header"> <div className="rates-header">
<div className="rates-title-group"> <div className="section-heading">
<h1 className="rates-title">Exchange Rates</h1> <p className="eyebrow">
<p className="rates-subtitle"> <span className="live-dot" />
Compare live EUR to NGN rates across providers Live tool
</p>
<h1 className="section-title">Exchange rates</h1>
<p className="section-lead">
Compare live EUR → NGN rates across remittance providers, fetched in real time.
</p> </p>
</div> </div>
<div className="currency-display">
<div className="currency-badge"> <div className="currency-badge">
<span className="currency-from">€1 EUR</span> <span>€1 EUR</span>
<span className="currency-arrow">→</span> <Icon name="arrowRight" size={16} />
<span className="currency-to">NGN</span> <span>NGN</span>
</div>
</div> </div>
</div> </div>
@@ -75,22 +78,21 @@ export default function Rates() {
<div className="rates-grid"> <div className="rates-grid">
{[...Array(6)].map((_, index) => ( {[...Array(6)].map((_, index) => (
<div key={index} className="rate-card skeleton"> <div key={index} className="rate-card skeleton">
<div className="skeleton-header"></div> <div className="skeleton-line skeleton-header"></div>
<div className="skeleton-rate"></div> <div className="skeleton-line skeleton-rate"></div>
<div className="skeleton-badge"></div> <div className="skeleton-line skeleton-badge"></div>
</div> </div>
))} ))}
</div> </div>
)} )}
{error && ( {error && (
<div className="error-state"> <div className="state-card">
<div className="error-icon">⚠️</div> <h3 className="state-title">Unable to load rates</h3>
<h3 className="error-title">Unable to load rates</h3> <p className="state-message">{error}</p>
<p className="error-message">{error}</p>
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="retry-button" className="button button-primary"
> >
Try again Try again
</button> </button>
@@ -115,11 +117,9 @@ export default function Rates() {
</div> </div>
<div className="rates-info"> <div className="rates-info">
<p className="info-text"> <p>Rates are fetched live on each visit. Click a provider to visit their website.</p>
Rates are updated regularly. Click on a provider to visit their website. <p className="rates-timestamp">
</p> Last updated {new Date().toLocaleString('en-GB', {
<p className="info-timestamp">
Last updated: {new Date().toLocaleString('en-GB', {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short' timeStyle: 'short'
})} })}
@@ -129,14 +129,14 @@ export default function Rates() {
)} )}
{!loading && !error && (!data || data.length === 0) && ( {!loading && !error && (!data || data.length === 0) && (
<div className="empty-state"> <div className="state-card">
<div className="empty-icon">📊</div> <h3 className="state-title">No rates available</h3>
<h3 className="empty-title">No rates available</h3> <p className="state-message">
<p className="empty-message">
We couldn't find any exchange rates at the moment. We couldn't find any exchange rates at the moment.
</p> </p>
</div> </div>
)} )}
</div>
</section> </section>
</Layout> </Layout>
); );
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0c0a09"/>
<g fill="none" stroke="#fafaf9" stroke-width="3" stroke-linecap="round">
<path d="M11.5 13v9a3.5 3.5 0 0 1-3.5 3.5h-1"/>
<path d="M15.5 18h10a5 5 0 1 0-1.46 3.54"/>
</g>
<circle cx="11.5" cy="8" r="2" fill="#34d399"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

+963 -663
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { fetchLemfiRate, parseLemfiQuote } from "../utils/lemfi.js";
// Captured from LemFi's Netherlands EUR → NGN calculator endpoint.
const quote = {
exchange_from: "EUR",
exchange_to: "NGN",
rate: "9.8057976951626334827662874e+25",
ID: "64eeb42d-7f05-4b50-a172-5e581358517a",
};
test("decodes the live quote to the rate displayed by LemFi", () => {
assert.equal(parseLemfiQuote({ data: quote }), 1522);
});
test("keeps promotional rewards out of the base rate comparison", () => {
assert.equal(parseLemfiQuote({ data: { ...quote, promotion_rate: { rate: "1e30" } } }), 1522);
});
test("rejects malformed rates and currency pairs", () => {
for (const change of [
{ exchange_from: "GBP" }, { exchange_to: "USD" },
{ rate: undefined }, { rate: "invalid" }, { rate: true },
{ rate: "Infinity" }, { rate: "0" }, { rate: "-1" },
{ ID: undefined }, { ID: "abc" }, { ID: "000" },
]) {
assert.throws(() => parseLemfiQuote({ data: { ...quote, ...change } }));
}
assert.throws(() => parseLemfiQuote({}));
});
test("requests the EUR to NGN Netherlands quote with a timeout", async (t) => {
t.mock.method(globalThis, "fetch", async (url, options) => {
assert.equal(url.origin + url.pathname, "https://lemfi.com/api/exchange-quote");
assert.deepEqual(Object.fromEntries(url.searchParams), {
from: "EUR", to: "NGN", country: "Netherlands", lang: "en",
});
assert.ok(options.signal instanceof AbortSignal);
return { ok: true, json: async () => ({ data: quote }) };
});
assert.equal(await fetchLemfiRate(), 1522);
});
test("rejects upstream HTTP and network errors", async (t) => {
const fetchMock = t.mock.method(globalThis, "fetch", async () => ({ ok: false, status: 503 }));
await assert.rejects(fetchLemfiRate(), /503/);
fetchMock.mock.mockImplementation(async () => { throw new Error("Network unavailable"); });
await assert.rejects(fetchLemfiRate(), /Network unavailable/);
});
+33
View File
@@ -0,0 +1,33 @@
export function parseLemfiQuote(result) {
const quote = result?.data;
if (quote?.exchange_from !== "EUR" || quote?.exchange_to !== "NGN") {
throw new Error("LemFi did not return a EUR to NGN quote");
}
// Match LemFi's calculator: divide the encoded rate by the digits in its ID.
// Use the base rate so referral rewards and LemFi Cash do not inflate comparisons.
const divisor = typeof quote.ID === "string" ? Number(quote.ID.replace(/\D/g, "")) : 0;
const encodedRate = typeof quote.rate === "string" || typeof quote.rate === "number"
? Number(quote.rate)
: NaN;
const rate = encodedRate / divisor;
if (!Number.isFinite(divisor) || divisor <= 0 || !Number.isFinite(rate) || rate <= 0) {
throw new Error("LemFi returned an invalid exchange rate");
}
return rate;
}
export async function fetchLemfiRate() {
const url = new URL("https://lemfi.com/api/exchange-quote");
url.search = new URLSearchParams({ from: "EUR", to: "NGN", country: "Netherlands", lang: "en" });
const response = await fetch(url, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(8000),
});
if (!response.ok) {
throw new Error(`LemFi quote request failed (${response.status})`);
}
return parseLemfiQuote(await response.json());
}