Compare commits

...
13 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
21 changed files with 975 additions and 1853 deletions
+6 -7
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
run: npm ci --no-audit --no-fund --no-update-notifier
- name: Build
run: npm run build
@@ -74,7 +74,6 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=sha,format=long
- name: Build and push image
uses: docker/build-push-action@v7
@@ -119,22 +118,22 @@ jobs:
KUBECONFIG="${KUBECONFIG_PATH}" kubectl cluster-info --request-timeout=15s
- name: Pin image to this commit
run: sed -i "s/newTag: latest/newTag: sha-${GITHUB_SHA}/" k8s/kustomization.yaml
- 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
kubectl rollout undo "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" || true
exit 1
fi
- name: Verify deployment
run: |
kubectl get deployments,pods,services,ingress -n "${NAMESPACE}"
echo "### Deployed sha-${GITHUB_SHA} to ${NAMESPACE}" >> "${GITHUB_STEP_SUMMARY}"
echo "### Deployed ${GITHUB_SHA} to ${NAMESPACE}" >> "${GITHUB_STEP_SUMMARY}"
+1
View File
@@ -33,3 +33,4 @@ yarn-error.log*
# vercel
.vercel
.claude
.vscode
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /app
# Dependencies
FROM base AS dependencies
COPY package.json package-lock.json ./
RUN npm ci
RUN npm ci --no-audit --no-fund --no-update-notifier
# Build
FROM base AS build
+9 -9
View File
@@ -6,7 +6,6 @@ import Icon from './Icon';
const links = [
{ href: '/#about', label: 'About' },
{ href: '/#stack', label: 'Stack' },
{ href: '/#projects', label: 'Projects' },
];
export default function Header() {
@@ -23,11 +22,15 @@ export default function Header() {
return (
<header className={`header ${scrolled ? 'is-scrolled' : ''} ${open ? 'is-open' : ''}`}>
<nav className="navbar shell">
<Link href="/" className="logo" onClick={() => setOpen(false)}>
<span className="logo-mark">je</span>
<span className="logo-text">
jesseinit<span className="logo-dot">.dev</span>
</span>
<Link href="/" className="logo" aria-label="Home" onClick={() => setOpen(false)}>
<svg className="logo-mark" viewBox="0 0 32 32" aria-hidden="true">
<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>
<div className="nav-links">
@@ -40,9 +43,6 @@ export default function Header() {
<div className="nav-actions">
<ThemeToggle />
<a href="/#contact" className="button button-primary button-small nav-cta">
Contact
</a>
<button
type="button"
className="icon-button nav-menu"
+4 -1
View File
@@ -16,7 +16,10 @@ export default function Layout({ children, title, description }) {
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<link rel="icon" href="/favicon.ico" />
<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>
<Header />
-48
View File
@@ -1,48 +0,0 @@
import Link from 'next/link';
import Icon from './Icon';
export default function ProjectCard({ project }) {
const { title, description, tags, href, repo, featured, internal } = project;
const primaryHref = href || repo;
return (
<article className={`project-card ${featured ? 'is-featured' : ''}`}>
<div className="project-top">
{featured ? (
<span className="pill pill-accent">
<span className="live-dot" /> Live
</span>
) : (
<Icon name="github" size={20} className="project-glyph" />
)}
{repo && href && (
<a href={repo} target="_blank" rel="noopener noreferrer" className="project-repo" aria-label={`${title} source code`}>
<Icon name="github" size={16} />
</a>
)}
</div>
<h3 className="project-title">
{internal ? (
<Link href={primaryHref} className="project-link">
{title}
</Link>
) : (
<a href={primaryHref} target="_blank" rel="noopener noreferrer" className="project-link">
{title}
</a>
)}
<Icon name="arrowUpRight" size={18} className="project-arrow" />
</h3>
<p className="project-description">{description}</p>
<ul className="tag-list">
{tags.map((tag) => (
<li key={tag} className="tag">
{tag}
</li>
))}
</ul>
</article>
);
}
-34
View File
@@ -7,7 +7,6 @@ export const profile = {
role: 'Software Engineer',
location: 'Amsterdam, NL',
email: 'me@jesseinit.dev',
available: true,
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.',
@@ -18,12 +17,6 @@ export const profile = {
],
};
export const stats = [
{ value: '6+', label: 'Years shipping' },
{ value: '3', label: 'Industries' },
{ value: 'M+', label: 'Requests served' },
];
export const focusAreas = [
{
title: 'Backend architecture',
@@ -54,33 +47,6 @@ export const stack = [
{ group: 'Infrastructure', items: ['Docker', 'Kubernetes', 'CI/CD'] },
];
export const projects = [
{
title: 'LibreOffice Conversion API',
description: 'A stateless REST API that exposes document conversion through the LibreOffice CLI.',
tags: ['Python', 'Flask', 'Docker'],
repo: 'https://github.com/jesseinit/flask_libreoffice_api',
},
{
title: 'Store Manager',
description: 'A web app that helps store owners manage sales and product inventory records.',
tags: ['React', 'Node.js', 'PostgreSQL'],
repo: 'https://github.com/jesseinit/store-manager-react',
},
{
title: 'Feather Insure',
description: 'A small insurance recommendation API.',
tags: ['Python', 'API'],
repo: 'https://github.com/jesseinit/feather-insure',
},
{
title: 'RSS Reader API',
description: 'A REST API that lets users read and follow their favourite RSS feeds.',
tags: ['Python', 'REST'],
repo: 'https://github.com/jesseinit/rss_reader_api',
},
];
export const socials = [
{ name: 'GitHub', url: 'https://github.com/jesseinit', icon: 'github' },
{ name: 'LinkedIn', url: 'https://linkedin.com/in/jesse-egbosionu/', icon: 'linkedin' },
+5 -6
View File
@@ -8,9 +8,9 @@ https://jesseinit.dev (`www.` redirects to the apex). TLS comes from cert-manage
`.github/workflows/deploy.yml` (run by Gitea Actions) on every push to `master`:
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:sha-<commit>` and `:latest`.
3. **Deploy**: pins `newTag` in `kustomization.yaml` to `sha-<commit>`, runs `kubectl apply -k k8s/`,
waits for the rollout, and rolls back automatically if it fails.
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.
Pull requests run step 1 only. Old images are cleaned up by the nightly `gitea-package-prune` cronjob.
@@ -24,7 +24,7 @@ Pull requests run step 1 only. Old images are cleaned up by the nightly `gitea-p
## Files
- `namespace.yaml`: `personal-site` namespace
- `deployment.yaml`: 2 replicas, zero-downtime rolling updates, non-root, health checks
- `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
@@ -35,8 +35,7 @@ Pull requests run step 1 only. Old images are cleaned up by the nightly `gitea-p
```bash
kubectl --context personal-cluster -n personal-site get pods,ingress,certificate
kubectl --context personal-cluster -n personal-site logs -l app=jesseinit-portfolio -f
kubectl --context personal-cluster -n personal-site rollout undo deployment/jesseinit-portfolio
# Deploy by hand (uses :latest)
kubectl --context personal-cluster apply -k k8s/
kubectl --context personal-cluster -n personal-site rollout restart deployment/jesseinit-portfolio
```
+2 -2
View File
@@ -5,7 +5,7 @@ metadata:
labels:
app: jesseinit-portfolio
spec:
replicas: 2
replicas: 1
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
@@ -23,7 +23,7 @@ spec:
containers:
- name: portfolio
image: jesseinit-portfolio:latest
imagePullPolicy: IfNotPresent
imagePullPolicy: Always
ports:
- containerPort: 3000
name: http
+1 -1
View File
@@ -15,7 +15,7 @@ labels:
app.kubernetes.io/name: personal-site
app.kubernetes.io/managed-by: kustomize
# CI rewrites newTag to sha-<commit> before applying.
# CI deploys :latest and runs `kubectl rollout restart` to pick up the new image.
images:
- name: jesseinit-portfolio
newName: tea.jesseinit.dev/jesseinit/personal-site
+6 -1
View File
@@ -1,6 +1,11 @@
module.exports = {
images: {
domains: ['res.cloudinary.com'],
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
],
},
output: 'standalone',
};
+815 -1392
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -5,17 +5,18 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "node --test"
},
"dependencies": {
"next": "^14.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"next": "^16.4.3",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"sass": "^1.32.12"
},
"devDependencies": {
"autoprefixer": "^10.2.5",
"postcss": "^8.2.13",
"tailwindcss": "^2.1.2"
"tailwindcss": "^4.3.3"
}
}
+11 -32
View File
@@ -1,4 +1,4 @@
import { log } from "console";
import { fetchLemfiRate } from "../../../utils/lemfi.js";
const COMMON_HEADERS = {
'accept': '*/*',
@@ -194,40 +194,19 @@ export default async (req, res) => {
});
const lemfiCall = new Promise((resolve, reject) => {
fetch("https://lemfi.com/api/lemonade/v2/exchange", {
headers: {
accept: "application/json",
"accept-language": "en-GB,en;q=0.9,nl-NL;q=0.8,nl;q=0.7,en-US;q=0.6",
"content-type": "application/json",
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)",
const lemfiHref = "https://lemfi.com/en-gb/international-money-transfer";
const lemfiProvider = "LemFi (referral code - OBIN6518)";
const lemfiCall = fetchLemfiRate()
.then((rate) => ({
rate: rate.toFixed(2),
provider: lemfiProvider,
bestRate: false,
href: "https://lemfi.com",
href: lemfiHref,
logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSMYTBiPB_joupdSSoBL1fA0wySDPcMpY92XQ&s"
};
})
}))
.catch((error) => {
console.error("Error calling Lemfi:", error);
return createDefaultErrorResponse("Lemfi(referal code - OBIN6518)", "https://lemfi.com");
console.error("Error calling LemFi:", error);
return createDefaultErrorResponse(lemfiProvider, lemfiHref);
});
const afriexCall = new Promise((resolve, reject) => {
+4 -65
View File
@@ -2,10 +2,9 @@ import Layout from '../components/Layout';
import SocialLinks from '../components/SocialLinks';
import AnimatedName from '../components/AnimatedName';
import SectionHeading from '../components/SectionHeading';
import ProjectCard from '../components/ProjectCard';
import Reveal from '../components/Reveal';
import Icon from '../components/Icon';
import { profile, stats, focusAreas, stack, projects } from '../data/profile';
import { profile, focusAreas, stack } from '../data/profile';
export default function Home() {
return (
@@ -15,13 +14,6 @@ export default function Home() {
<div className="hero-backdrop" aria-hidden="true" />
<div className="shell hero-grid">
<div className="hero-content">
{profile.available && (
<p className="status-pill">
<span className="live-dot" />
Open to new opportunities
</p>
)}
<h1 className="hero-title">
Hey, I’m {profile.firstName}.
<span className="hero-title-muted">{profile.tagline}</span>
@@ -34,8 +26,9 @@ export default function Home() {
Let’s work together
<Icon name="arrowRight" size={16} />
</a>
<a href="#projects" className="button button-ghost">
View projects
<a href="https://github.com/jesseinit" target="_blank" rel="noopener noreferrer" className="button button-ghost">
<Icon name="github" size={16} />
View GitHub
</a>
</div>
@@ -73,17 +66,6 @@ export default function Home() {
</div>
</div>
</div>
<div className="shell">
<dl className="stats">
{stats.map((stat, i) => (
<Reveal key={stat.label} className="stat" delay={i * 80}>
<dt className="stat-label">{stat.label}</dt>
<dd className="stat-value">{stat.value}</dd>
</Reveal>
))}
</dl>
</div>
</section>
{/* ABOUT */}
@@ -133,49 +115,6 @@ export default function Home() {
</div>
</div>
</section>
{/* PROJECTS */}
<section id="projects" className="section">
<div className="shell">
<SectionHeading index="03" eyebrow="Projects" title="Things I’ve built.">
A mix of production tools and side projects. More on{' '}
<a href="https://github.com/jesseinit" target="_blank" rel="noopener noreferrer" className="text-link">
GitHub
</a>
.
</SectionHeading>
<div className="project-grid">
{projects.map((project, i) => (
<Reveal key={project.title} delay={(i % 3) * 80} className={project.featured ? 'span-2' : ''}>
<ProjectCard project={project} />
</Reveal>
))}
</div>
</div>
</section>
{/* CONTACT */}
<section id="contact" className="section">
<div className="shell">
<Reveal className="contact-card">
<p className="eyebrow">
<span className="eyebrow-index">04</span>
Contact
</p>
<h2 className="contact-title">Have a system that needs to scale?</h2>
<p className="contact-lead">
I’m always happy to talk about backend architecture, interesting problems or new roles. My inbox is open.
</p>
<div className="hero-actions">
<a href={`mailto:${profile.email}`} className="button button-primary">
<Icon name="mail" size={16} />
{profile.email}
</a>
<SocialLinks variant="pills" />
</div>
</Reveal>
</div>
</section>
</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

+13 -249
View File
@@ -329,18 +329,17 @@ button {
font-weight: 600;
}
// Same artwork as public/icon.svg; the ring keeps the dark tile visible in dark mode.
.logo-mark {
display: grid;
place-items: center;
display: block;
width: 32px;
height: 32px;
border-radius: 9px;
background: var(--text);
color: var(--bg);
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: -0.02em;
flex-shrink: 0;
border-radius: 7px;
}
[data-theme='dark'] .logo-mark {
box-shadow: 0 0 0 1px var(--border-strong);
}
.logo-text {
@@ -449,19 +448,6 @@ button {
}
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 14px 6px 12px;
border-radius: var(--radius-full);
border: 1px solid var(--border);
background: var(--bg-elevated);
font-size: 0.8125rem;
color: var(--text-muted);
box-shadow: var(--shadow-card);
}
.hero-title {
font-size: clamp(2.5rem, 6vw, 4.25rem);
font-weight: 700;
@@ -665,46 +651,13 @@ button {
}
}
// STATS
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-top: 96px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
overflow: hidden;
}
.stat {
display: flex;
flex-direction: column-reverse;
gap: 4px;
padding: 24px 28px;
& + & {
border-left: 1px solid var(--border);
}
}
.stat-value {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.1;
}
.stat-label {
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
// SECTIONS
.section {
padding: 96px 0;
& + & {
padding-top: 0;
}
}
.section-heading {
@@ -845,166 +798,6 @@ button {
margin-bottom: 16px;
}
// PROJECTS
.project-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
> * {
display: flex;
}
.span-2 {
grid-column: span 2;
}
}
.project-card {
@extend .card-base;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
padding: 28px;
transition: border-color 0.25s ease, transform 0.35s var(--ease), box-shadow 0.35s var(--ease);
&:hover {
transform: translateY(-3px);
border-color: var(--border-strong);
box-shadow: var(--shadow-lift);
.project-arrow {
opacity: 1;
transform: translate(0, 0);
}
}
&.is-featured {
background:
radial-gradient(120% 120% at 100% 0%, var(--accent-soft), transparent 55%),
var(--bg-elevated);
.project-title {
font-size: 1.5rem;
}
.project-description {
font-size: 1rem;
max-width: 48ch;
}
}
}
.project-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
min-height: 28px;
}
.project-glyph {
color: var(--text-subtle);
}
.project-repo {
position: relative;
z-index: 1;
display: inline-flex;
padding: 6px;
border-radius: 8px;
color: var(--text-muted);
transition: color 0.2s ease, background-color 0.2s ease;
&:hover {
color: var(--text);
background: var(--surface);
}
}
.project-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
// Stretch the title link over the whole card.
.project-link::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
}
.project-arrow {
color: var(--text-muted);
opacity: 0;
transform: translate(-4px, 4px);
transition: opacity 0.25s ease, transform 0.35s var(--ease);
}
.project-description {
font-size: 0.9375rem;
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 24px;
}
.project-card .tag-list {
margin-top: auto;
}
// CONTACT
.contact-card {
@extend .card-base;
position: relative;
overflow: hidden;
padding: 64px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
background:
radial-gradient(80% 140% at 100% 100%, var(--accent-glow), transparent 60%),
var(--bg-elevated);
&::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
background-image: radial-gradient(var(--grid-dot) 1px, transparent 1px);
background-size: 20px 20px;
mask-image: linear-gradient(to left, #000, transparent 60%);
-webkit-mask-image: linear-gradient(to left, #000, transparent 60%);
}
> * {
position: relative;
z-index: 1;
}
}
.contact-title {
font-size: clamp(2rem, 4.5vw, 3.25rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.04em;
max-width: 16ch;
}
.contact-lead {
font-size: 1.0625rem;
color: var(--text-muted);
max-width: 52ch;
margin-bottom: 8px;
}
// FOOTER
.footer {
border-top: 1px solid var(--border);
@@ -1308,10 +1101,6 @@ button {
.stack-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.project-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
@@ -1346,10 +1135,6 @@ button {
border-radius: var(--radius-sm);
}
.nav-cta {
display: none;
}
.nav-menu {
display: inline-flex;
}
@@ -1362,36 +1147,15 @@ button {
left: -16px;
}
.stats {
margin-top: 72px;
}
.stat {
padding: 20px 16px;
}
.stat-value {
font-size: 1.5rem;
}
.section {
padding: 72px 0;
}
.focus-grid,
.stack-grid,
.project-grid {
.stack-grid {
grid-template-columns: 1fr;
}
.project-grid .span-2 {
grid-column: auto;
}
.contact-card {
padding: 36px 24px;
}
.footer-content,
.rates-header {
flex-direction: column;
+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());
}