Compare commits
25
Commits
e9e47b10a6
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b05250635 | ||
|
|
b050375486 | ||
|
|
80741aa0f7 | ||
|
|
9ec94a305b | ||
|
|
eefe924683 | ||
|
|
5365bcdf82 | ||
|
|
bab237834b | ||
|
|
f2c0c2716b | ||
|
|
da7999c52b | ||
|
|
c5ff89f4e5 | ||
|
|
c6b6c31df6 | ||
|
|
14a9e9511e | ||
|
|
a01bd975e2 | ||
|
|
0927b9d665 | ||
|
|
3bb0a5e4a9 | ||
|
|
5c40a64c42 | ||
|
|
0d59363cdc | ||
|
|
148dbad7e8 | ||
|
|
fa297b1ef3 | ||
|
|
41c1b68a3b | ||
|
|
caa4c1202f | ||
|
|
c460ea27d4 | ||
|
|
5c22b9b844 | ||
|
|
b6265c4f0f | ||
|
|
cca249fa9a |
@@ -6,3 +6,5 @@ README.md
|
|||||||
.env*
|
.env*
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
|
.github
|
||||||
|
k8s
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -32,3 +32,5 @@ yarn-error.log*
|
|||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
.claude
|
||||||
|
.vscode
|
||||||
|
|||||||
+9
-5
@@ -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"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { profile } from '../data/profile';
|
||||||
|
|
||||||
|
const names = profile.handles;
|
||||||
|
|
||||||
|
export default function AnimatedName() {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
|
||||||
|
let timeout;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setCurrentIndex((prevIndex) => (prevIndex + 1) % names.length);
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 350);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="animated-name" aria-label={names[0]}>
|
||||||
|
<span aria-hidden="true" className={isAnimating ? 'is-leaving' : 'is-entering'}>
|
||||||
|
{names[currentIndex]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import SocialLinks from './SocialLinks';
|
||||||
|
import { profile } from '../data/profile';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="shell footer-content">
|
||||||
|
<p className="footer-text">
|
||||||
|
© {new Date().getFullYear()} {profile.name}. Built with Next.js, shipped on Kubernetes.
|
||||||
|
</p>
|
||||||
|
<SocialLinks variant="icons" />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={`header ${scrolled ? 'is-scrolled' : ''} ${open ? 'is-open' : ''}`}>
|
||||||
|
<nav className="navbar shell">
|
||||||
|
<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">
|
||||||
|
{links.map((link) => (
|
||||||
|
<Link key={link.href} href={link.href} className="nav-link" onClick={() => setOpen(false)}>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nav-actions">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Head from 'next/head';
|
||||||
|
import Header from './Header';
|
||||||
|
import Footer from './Footer';
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="page">
|
||||||
|
<Head>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content={pageDescription} />
|
||||||
|
<meta property="og:title" content={pageTitle} />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="main-content">{children}</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Icon from './Icon';
|
||||||
|
|
||||||
|
export default function RateCard({ provider, rate, href, isBest, index, formatCurrency, logo }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`rate-card ${isBest ? 'is-best' : ''}`}
|
||||||
|
style={{ animationDelay: `${index * 60}ms` }}
|
||||||
|
>
|
||||||
|
<div className="rate-card-header">
|
||||||
|
<div className="provider-info">
|
||||||
|
{logo && <img src={logo} alt="" className="provider-logo" />}
|
||||||
|
<h3 className="provider-name">{provider}</h3>
|
||||||
|
</div>
|
||||||
|
{isBest && <span className="pill pill-accent">Best rate</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rate-value">{formatCurrency(rate)}</div>
|
||||||
|
|
||||||
|
<div className="rate-card-footer">
|
||||||
|
<span>Visit provider</span>
|
||||||
|
<Icon name="arrowUpRight" size={16} className="rate-arrow" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Icon from './Icon';
|
||||||
|
import { socials } from '../data/profile';
|
||||||
|
|
||||||
|
export default function SocialLinks({ variant = 'icons' }) {
|
||||||
|
return (
|
||||||
|
<div className={`social-links social-links-${variant}`}>
|
||||||
|
{socials.map((social) => (
|
||||||
|
<a
|
||||||
|
key={social.name}
|
||||||
|
href={social.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={variant === 'icons' ? 'icon-button' : 'social-link'}
|
||||||
|
aria-label={social.name}
|
||||||
|
>
|
||||||
|
<Icon name={social.icon} size={variant === 'icons' ? 16 : 18} />
|
||||||
|
{variant !== 'icons' && <span>{social.name}</span>}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Kubernetes deployment
|
||||||
|
|
||||||
|
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`).
|
||||||
|
|
||||||
|
## How deploys work
|
||||||
|
|
||||||
|
`.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: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.
|
||||||
|
|
||||||
|
### Required secrets (repo or `production` environment in Gitea)
|
||||||
|
|
||||||
|
| 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
|
||||||
|
kubectl --context personal-cluster -n personal-site get pods,ingress,certificate
|
||||||
|
kubectl --context personal-cluster -n personal-site logs -l app=jesseinit-portfolio -f
|
||||||
|
# Deploy by hand (uses :latest)
|
||||||
|
kubectl --context personal-cluster apply -k k8s/
|
||||||
|
kubectl --context personal-cluster -n personal-site rollout restart deployment/jesseinit-portfolio
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: jesseinit-portfolio-config
|
||||||
|
labels:
|
||||||
|
app: jesseinit-portfolio
|
||||||
|
data:
|
||||||
|
# Add any environment variables your application needs
|
||||||
|
# Example:
|
||||||
|
# API_URL: "https://api.example.com"
|
||||||
|
# NEXT_PUBLIC_SITE_URL: "https://jesseinit.com"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: jesseinit-portfolio
|
||||||
|
labels:
|
||||||
|
app: jesseinit-portfolio
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
revisionHistoryLimit: 5
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxUnavailable: 0
|
||||||
|
maxSurge: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: jesseinit-portfolio
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: jesseinit-portfolio
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: portfolio
|
||||||
|
image: jesseinit-portfolio:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: "production"
|
||||||
|
- name: HOSTNAME
|
||||||
|
value: "0.0.0.0"
|
||||||
|
- name: PORT
|
||||||
|
value: "3000"
|
||||||
|
- name: NEXT_TELEMETRY_DISABLED
|
||||||
|
value: "1"
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: jesseinit-portfolio-config
|
||||||
|
optional: true
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
restartPolicy: Always
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: jesseinit-portfolio
|
||||||
|
labels:
|
||||||
|
app: jesseinit-portfolio
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
# Redirect www.jesseinit.dev -> jesseinit.dev
|
||||||
|
nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- jesseinit.dev
|
||||||
|
- www.jesseinit.dev
|
||||||
|
secretName: personal-site-tls
|
||||||
|
rules:
|
||||||
|
- host: jesseinit.dev
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: jesseinit-portfolio
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: personal-site
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- configmap.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- ingress.yaml
|
||||||
|
|
||||||
|
labels:
|
||||||
|
- 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:
|
||||||
|
- name: jesseinit-portfolio
|
||||||
|
newName: tea.jesseinit.dev/jesseinit/personal-site
|
||||||
|
newTag: latest
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: personal-site
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: jesseinit-portfolio
|
||||||
|
labels:
|
||||||
|
app: jesseinit-portfolio
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: jesseinit-portfolio
|
||||||
+6
-1
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+827
-1430
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+35
-85
@@ -1,4 +1,4 @@
|
|||||||
const { exec } = require("child_process");
|
import { fetchLemfiRate } from "../../../utils/lemfi.js";
|
||||||
|
|
||||||
const COMMON_HEADERS = {
|
const COMMON_HEADERS = {
|
||||||
'accept': '*/*',
|
'accept': '*/*',
|
||||||
@@ -102,6 +102,7 @@ export default async (req, res) => {
|
|||||||
provider: "TapTap",
|
provider: "TapTap",
|
||||||
bestRate: false,
|
bestRate: false,
|
||||||
href: "https://www.taptapsend.com/",
|
href: "https://www.taptapsend.com/",
|
||||||
|
logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSFcB9ECgZxEULIhxf-PZWEn23UPZATQZblQQ&s"
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -124,6 +125,7 @@ export default async (req, res) => {
|
|||||||
provider: "Send",
|
provider: "Send",
|
||||||
bestRate: false,
|
bestRate: false,
|
||||||
href: "https://send.flutterwave.com/",
|
href: "https://send.flutterwave.com/",
|
||||||
|
logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRyhQXYEg8iHCMajGdJn0B0FxwsuzAfkSeBgQ&s"
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -131,54 +133,11 @@ export default async (req, res) => {
|
|||||||
return createDefaultErrorResponse("Send", "https://send.flutterwave.com/");
|
return createDefaultErrorResponse("Send", "https://send.flutterwave.com/");
|
||||||
});
|
});
|
||||||
|
|
||||||
const curlCommand = `curl -v 'https://acemoneytransfer.com/make-request' \
|
// Ace Transfer temporarily disabled due to Cloudflare protection
|
||||||
-H 'authority: acemoneytransfer.com' \
|
// Will return a default response to avoid blocking the API
|
||||||
-H 'accept: */*' \
|
const aceCall = Promise.resolve(
|
||||||
-H 'accept-language: en-GB,en-US;q=0.9,en;q=0.8' \
|
createDefaultErrorResponse("Ace Transfer", "https://acemoneytransfer.com/referral-link/3056004")
|
||||||
-H 'content-type: application/x-www-form-urlencoded; charset=UTF-8' \
|
);
|
||||||
-H 'origin: https://acemoneytransfer.com' \
|
|
||||||
-H 'referer: https://acemoneytransfer.com/Nigeria/Send-Money-to-Nigeria' \
|
|
||||||
-H 'sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"' \
|
|
||||||
-H 'sec-ch-ua-mobile: ?0' \
|
|
||||||
-H 'sec-ch-ua-platform: "macOS"' \
|
|
||||||
-H 'sec-fetch-dest: empty' \
|
|
||||||
-H 'sec-fetch-mode: cors' \
|
|
||||||
-H 'sec-fetch-site: same-origin' \
|
|
||||||
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' \
|
|
||||||
-H 'x-requested-with: XMLHttpRequest' \
|
|
||||||
--data-raw 'uri=rate%2Fcalculator&type=POST&data%5Bsrc_amount%5D=100&data%5Bdest_amount%5D=&data%5Buser_currency%5D=EUR&data%5Bcalculation_mode%5D=S&data%5Bdest_iso_numeric_code%5D=566&data%5Bsrc_iso_numeric_code%5D=528'
|
|
||||||
`;
|
|
||||||
|
|
||||||
const aceCall = new Promise((resolve, reject) => {
|
|
||||||
exec(curlCommand, (error, stdout, stderr) => {
|
|
||||||
if (error) {
|
|
||||||
reject(`Error: ${error.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsedRate = JSON.parse(stdout);
|
|
||||||
resolve({
|
|
||||||
rate: parseFloat(parsedRate.data.exchange_rate).toFixed(2),
|
|
||||||
provider: "Ace Transfer",
|
|
||||||
bestRate: false,
|
|
||||||
});
|
|
||||||
} catch (parseError) {
|
|
||||||
reject(parseError.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then((result) => {
|
|
||||||
return {
|
|
||||||
rate: parseFloat(result.rate).toFixed(2),
|
|
||||||
provider: "Ace Transfer",
|
|
||||||
bestRate: false,
|
|
||||||
href: "https://acemoneytransfer.com/referral-link/3056004",
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error calling Ace:", error);
|
|
||||||
return createDefaultErrorResponse("Ace Transfer", "https://acemoneytransfer.com/referral-link/3056004");
|
|
||||||
});
|
|
||||||
|
|
||||||
const remitlyCall = new Promise((resolve, reject) => {
|
const remitlyCall = new Promise((resolve, reject) => {
|
||||||
fetch("https://api.remitly.io/v5/pricing/estimates?amount=1%20EUR&anchor=SEND&conduit=NLD%3AEUR-NGA%3ANGN&purpose=OTHER", {
|
fetch("https://api.remitly.io/v5/pricing/estimates?amount=1%20EUR&anchor=SEND&conduit=NLD%3AEUR-NGA%3ANGN&purpose=OTHER", {
|
||||||
@@ -198,6 +157,7 @@ export default async (req, res) => {
|
|||||||
provider: "Remitly",
|
provider: "Remitly",
|
||||||
bestRate: false,
|
bestRate: false,
|
||||||
href: "https://remit.ly/94fqcne9",
|
href: "https://remit.ly/94fqcne9",
|
||||||
|
logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRNZm0irEt8u6LXo-OFXZ9bcTchNoSeaDDnsA&s"
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -225,6 +185,7 @@ export default async (req, res) => {
|
|||||||
provider: "Nala",
|
provider: "Nala",
|
||||||
bestRate: false,
|
bestRate: false,
|
||||||
href: "https://join.iwantnala.com/JESSE-715106",
|
href: "https://join.iwantnala.com/JESSE-715106",
|
||||||
|
logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRpbV0MWgCocA3dFuWoxgCmQsxGfqRuttbD3w&s"
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -233,39 +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: Math.floor(result.data.rate % 10000),
|
|
||||||
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"
|
||||||
})
|
}))
|
||||||
.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) => {
|
||||||
@@ -278,16 +219,21 @@ 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",
|
||||||
bestRate: false,
|
bestRate: false,
|
||||||
href: "https://www.afriex.com/",
|
href: "https://www.afriex.com/",
|
||||||
|
logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTvFac7oIDG6QsJlYsBIRnWhFfy0OWjN5Mspg&s"
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.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/");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,6 +269,7 @@ export default async (req, res) => {
|
|||||||
provider: "Wise",
|
provider: "Wise",
|
||||||
bestRate: false,
|
bestRate: false,
|
||||||
href: "https://wise.com/invite/dic/obinnae93",
|
href: "https://wise.com/invite/dic/obinnae93",
|
||||||
|
logo: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ3L26sFmjg2xyW4CJy_5peQt-v0JHgsYhzvA&s"
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Wise provider or quotes not found");
|
throw new Error("Wise provider or quotes not found");
|
||||||
@@ -339,7 +286,8 @@ export default async (req, res) => {
|
|||||||
nalaCall,
|
nalaCall,
|
||||||
lemfiCall,
|
lemfiCall,
|
||||||
afriexCall,
|
afriexCall,
|
||||||
wiseCall
|
wiseCall,
|
||||||
|
sendCall
|
||||||
])
|
])
|
||||||
.then(
|
.then(
|
||||||
([
|
([
|
||||||
@@ -349,7 +297,8 @@ export default async (req, res) => {
|
|||||||
nalaResponse,
|
nalaResponse,
|
||||||
lemfiResponse,
|
lemfiResponse,
|
||||||
afriexResponse,
|
afriexResponse,
|
||||||
wiseResponse
|
wiseResponse,
|
||||||
|
sendResponse
|
||||||
]) => {
|
]) => {
|
||||||
rates = [
|
rates = [
|
||||||
aceResponse,
|
aceResponse,
|
||||||
@@ -358,7 +307,8 @@ export default async (req, res) => {
|
|||||||
nalaResponse,
|
nalaResponse,
|
||||||
lemfiResponse,
|
lemfiResponse,
|
||||||
afriexResponse,
|
afriexResponse,
|
||||||
wiseResponse
|
wiseResponse,
|
||||||
|
sendResponse
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+110
-59
@@ -1,69 +1,120 @@
|
|||||||
import Head from 'next/head';
|
import Layout from '../components/Layout';
|
||||||
|
import SocialLinks from '../components/SocialLinks';
|
||||||
|
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 (
|
||||||
<div className="container">
|
<Layout>
|
||||||
<Head>
|
{/* HERO */}
|
||||||
<title>Home - Jesse Egbosionu</title>
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
<header className="header">
|
|
||||||
<nav className="header__navbar">
|
|
||||||
<p className="header__logo">
|
|
||||||
<a href="https://twitter.com/jesseinit">init</a>
|
|
||||||
</p>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<aside className="hero__aside">
|
<div className="hero-backdrop" aria-hidden="true" />
|
||||||
<h2 className="hero__author_name">Jesse Egbosionu</h2>
|
<div className="shell hero-grid">
|
||||||
<section className="hero__links">
|
<div className="hero-content">
|
||||||
<a href="https://github.com/jesseinit" rel="noopener" alt="GitHub">
|
<h1 className="hero-title">
|
||||||
GitHub
|
Hey, I’m {profile.firstName}.
|
||||||
</a>
|
<span className="hero-title-muted">{profile.tagline}</span>
|
||||||
<span> · </span>
|
</h1>
|
||||||
<a href="https://linkedin.com/in/jesse-egbosionu/" rel="noopener" alt="Linkedin">
|
|
||||||
LinkedIn
|
|
||||||
</a>
|
|
||||||
<span> · </span>
|
|
||||||
<a href="https://twitter.com/jesseinit/" rel="noopener" alt="Twitter">
|
|
||||||
Twitter
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
<p className="hero__description">
|
|
||||||
I'm a software engineer based in Amsterdam, Netherlands and currently focused in building and scaling
|
|
||||||
software backends. Creating impact, team-work and continuous learning are some of my core
|
|
||||||
tenets and I continuously adapt these in solving customers problems when I build
|
|
||||||
software.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="hero__description">
|
<p className="hero-subtitle">{profile.intro}</p>
|
||||||
In the last 5 years I've been building services and products in the finance, ecommerce
|
|
||||||
and logistics domain, enriching lives using technologies like Python(Django,
|
|
||||||
FastAPI), Javascript(React, Node.js), PostgreSQL, Docker, Redis, Kubernetes etc.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="hero__description">
|
<div className="hero-actions">
|
||||||
I'm constantly looking for challenging and impactful opportunities so can always shoot
|
<a href={`mailto:${profile.email}`} className="button button-primary">
|
||||||
me an{' '}
|
Let’s work together
|
||||||
<a className="text-underline email-cta" href="mailto:me@jesseinit.dev">
|
<Icon name="arrowRight" size={16} />
|
||||||
email
|
</a>
|
||||||
|
<a href="https://github.com/jesseinit" target="_blank" rel="noopener noreferrer" className="button button-ghost">
|
||||||
|
<Icon name="github" size={16} />
|
||||||
|
View GitHub
|
||||||
</a>
|
</a>
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
<aside className="hero__aside text-align-center">
|
|
||||||
<img
|
|
||||||
src="hero.jpg"
|
|
||||||
className="hero__image"
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<footer className="footer">
|
|
||||||
<p className="text-align-center">Made with <span style={{ "color": "red" }}>❤️</span> and Vibes</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-meta">
|
||||||
|
<span className="meta-item">
|
||||||
|
<Icon name="pin" size={16} /> {profile.location}
|
||||||
|
</span>
|
||||||
|
<span className="meta-divider" />
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-38
@@ -1,20 +1,25 @@
|
|||||||
import Head from "next/head";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Layout from "../components/Layout";
|
||||||
|
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() {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDataFromApi = async () => {
|
const fetchDataFromApi = async () => {
|
||||||
try {
|
try {
|
||||||
const apiData = await fetchData();
|
const apiData = await fetchData();
|
||||||
setData(apiData);
|
setData(apiData);
|
||||||
|
setError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching data:", error);
|
console.error("Error fetching data:", error);
|
||||||
|
setError("Failed to load exchange rates. Please try again later.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false); // Set loading to false regardless of success or error
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,48 +33,111 @@ export default function Rates() {
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Your UI rendering logic here, using the 'data' state
|
const getBestRate = () => {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
return Math.max(...data.map(item => item.rate));
|
||||||
|
};
|
||||||
|
|
||||||
|
const bestRate = getBestRate();
|
||||||
|
|
||||||
|
const sortedData = data ? [...data].sort((a, b) => {
|
||||||
|
const aIsBest = a.bestRate || a.rate === bestRate;
|
||||||
|
const bIsBest = b.bestRate || b.rate === bestRate;
|
||||||
|
if (aIsBest && !bIsBest) return -1;
|
||||||
|
if (!aIsBest && bIsBest) return 1;
|
||||||
|
return b.rate - a.rate;
|
||||||
|
}) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<Layout
|
||||||
<Head>
|
title="EUR → NGN Exchange Rates · Jesse Egbosionu"
|
||||||
<title>EUR Rates</title>
|
description="Compare live EUR to NGN exchange rates across multiple providers."
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
<header className="header">
|
|
||||||
<nav className="header__navbar">
|
|
||||||
<p className="header__logo">
|
|
||||||
<a href="https://twitter.com/jesseinit">init</a>
|
|
||||||
</p>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<h2>For €1 you'll get the following rates</h2>
|
|
||||||
<section className="hero">
|
|
||||||
{loading ? (
|
|
||||||
<div>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data && (
|
|
||||||
<div className="currency__container">
|
|
||||||
{data.map((item) => (
|
|
||||||
<p
|
|
||||||
key={item.provider}
|
|
||||||
className={
|
|
||||||
item.bestRate ? "best-rate" : ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<a href={item.href}>{item.provider}:</a>
|
<section className="section rates-section">
|
||||||
<span className="rate">
|
<div className="shell">
|
||||||
{formatCurrency(item.rate)}
|
<div className="rates-header">
|
||||||
</span>
|
<div className="section-heading">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<span className="live-dot" />
|
||||||
|
Live tool
|
||||||
</p>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="currency-badge">
|
||||||
|
<span>€1 EUR</span>
|
||||||
|
<Icon name="arrowRight" size={16} />
|
||||||
|
<span>NGN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="rates-grid">
|
||||||
|
{[...Array(6)].map((_, index) => (
|
||||||
|
<div key={index} className="rate-card skeleton">
|
||||||
|
<div className="skeleton-line skeleton-header"></div>
|
||||||
|
<div className="skeleton-line skeleton-rate"></div>
|
||||||
|
<div className="skeleton-line skeleton-badge"></div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
</main>
|
{error && (
|
||||||
|
<div className="state-card">
|
||||||
|
<h3 className="state-title">Unable to load rates</h3>
|
||||||
|
<p className="state-message">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="button button-primary"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && sortedData && sortedData.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="rates-grid">
|
||||||
|
{sortedData.map((item, index) => (
|
||||||
|
<RateCard
|
||||||
|
key={item.provider}
|
||||||
|
provider={item.provider}
|
||||||
|
rate={item.rate}
|
||||||
|
href={item.href}
|
||||||
|
isBest={item.bestRate || item.rate === bestRate}
|
||||||
|
index={index}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
logo={item.logo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rates-info">
|
||||||
|
<p>Rates are fetched live on each visit. Click a provider to visit their website.</p>
|
||||||
|
<p className="rates-timestamp">
|
||||||
|
Last updated {new Date().toLocaleString('en-GB', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (!data || data.length === 0) && (
|
||||||
|
<div className="state-card">
|
||||||
|
<h3 className="state-title">No rates available</h3>
|
||||||
|
<p className="state-message">
|
||||||
|
We couldn't find any exchange rates at the moment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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 |
@@ -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 |
+1144
-102
File diff suppressed because it is too large
Load Diff
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user