Compare commits
13
Commits
0927b9d665
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b05250635 | ||
|
|
b050375486 | ||
|
|
80741aa0f7 | ||
|
|
9ec94a305b | ||
|
|
eefe924683 | ||
|
|
5365bcdf82 | ||
|
|
bab237834b | ||
|
|
f2c0c2716b | ||
|
|
da7999c52b | ||
|
|
c5ff89f4e5 | ||
|
|
c6b6c31df6 | ||
|
|
14a9e9511e | ||
|
|
a01bd975e2 |
@@ -34,7 +34,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci --no-audit --no-fund --no-update-notifier
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -74,7 +74,6 @@ jobs:
|
|||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=sha,format=long
|
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
@@ -119,22 +118,22 @@ jobs:
|
|||||||
|
|
||||||
KUBECONFIG="${KUBECONFIG_PATH}" kubectl cluster-info --request-timeout=15s
|
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
|
- name: Apply manifests
|
||||||
run: kubectl apply -k k8s/
|
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
|
- name: Wait for rollout
|
||||||
run: |
|
run: |
|
||||||
if ! kubectl rollout status "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" --timeout=5m; then
|
if ! kubectl rollout status "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" --timeout=5m; then
|
||||||
kubectl get pods -n "${NAMESPACE}" -o wide
|
kubectl get pods -n "${NAMESPACE}" -o wide
|
||||||
kubectl describe pods -n "${NAMESPACE}" | tail -40
|
kubectl describe pods -n "${NAMESPACE}" | tail -40
|
||||||
kubectl rollout undo "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" || true
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Verify deployment
|
- name: Verify deployment
|
||||||
run: |
|
run: |
|
||||||
kubectl get deployments,pods,services,ingress -n "${NAMESPACE}"
|
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}"
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
.claude
|
.claude
|
||||||
|
.vscode
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Icon from './Icon';
|
|||||||
const links = [
|
const links = [
|
||||||
{ href: '/#about', label: 'About' },
|
{ href: '/#about', label: 'About' },
|
||||||
{ href: '/#stack', label: 'Stack' },
|
{ href: '/#stack', label: 'Stack' },
|
||||||
{ href: '/#projects', label: 'Projects' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
@@ -23,11 +22,15 @@ export default function Header() {
|
|||||||
return (
|
return (
|
||||||
<header className={`header ${scrolled ? 'is-scrolled' : ''} ${open ? 'is-open' : ''}`}>
|
<header className={`header ${scrolled ? 'is-scrolled' : ''} ${open ? 'is-open' : ''}`}>
|
||||||
<nav className="navbar shell">
|
<nav className="navbar shell">
|
||||||
<Link href="/" className="logo" onClick={() => setOpen(false)}>
|
<Link href="/" className="logo" aria-label="Home" onClick={() => setOpen(false)}>
|
||||||
<span className="logo-mark">je</span>
|
<svg className="logo-mark" viewBox="0 0 32 32" aria-hidden="true">
|
||||||
<span className="logo-text">
|
<rect width="32" height="32" rx="7" fill="#0c0a09" />
|
||||||
jesseinit<span className="logo-dot">.dev</span>
|
<g fill="none" stroke="#fafaf9" strokeWidth="3" strokeLinecap="round">
|
||||||
</span>
|
<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 className="nav-links">
|
<div className="nav-links">
|
||||||
@@ -40,9 +43,6 @@ export default function Header() {
|
|||||||
|
|
||||||
<div className="nav-actions">
|
<div className="nav-actions">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<a href="/#contact" className="button button-primary button-small nav-cta">
|
|
||||||
Contact
|
|
||||||
</a>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button nav-menu"
|
className="icon-button nav-menu"
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export default function Layout({ children, title, description }) {
|
|||||||
<meta property="og:title" content={pageTitle} />
|
<meta property="og:title" content={pageTitle} />
|
||||||
<meta property="og:description" content={pageDescription} />
|
<meta property="og:description" content={pageDescription} />
|
||||||
<meta property="og:type" content="website" />
|
<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>
|
</Head>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ export const profile = {
|
|||||||
role: 'Software Engineer',
|
role: 'Software Engineer',
|
||||||
location: 'Amsterdam, NL',
|
location: 'Amsterdam, NL',
|
||||||
email: 'me@jesseinit.dev',
|
email: 'me@jesseinit.dev',
|
||||||
available: true,
|
|
||||||
tagline: 'I build backend systems that scale, and the products on top of them.',
|
tagline: 'I build backend systems that scale, and the products on top of them.',
|
||||||
intro:
|
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.',
|
'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 = [
|
export const focusAreas = [
|
||||||
{
|
{
|
||||||
title: 'Backend architecture',
|
title: 'Backend architecture',
|
||||||
@@ -54,33 +47,6 @@ export const stack = [
|
|||||||
{ group: 'Infrastructure', items: ['Docker', 'Kubernetes', 'CI/CD'] },
|
{ 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 = [
|
export const socials = [
|
||||||
{ name: 'GitHub', url: 'https://github.com/jesseinit', icon: 'github' },
|
{ name: 'GitHub', url: 'https://github.com/jesseinit', icon: 'github' },
|
||||||
{ name: 'LinkedIn', url: 'https://linkedin.com/in/jesse-egbosionu/', icon: 'linkedin' },
|
{ name: 'LinkedIn', url: 'https://linkedin.com/in/jesse-egbosionu/', icon: 'linkedin' },
|
||||||
|
|||||||
+5
-6
@@ -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`:
|
`.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.
|
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`.
|
2. **Build and push image**: `tea.jesseinit.dev/jesseinit/personal-site:latest`.
|
||||||
3. **Deploy**: pins `newTag` in `kustomization.yaml` to `sha-<commit>`, runs `kubectl apply -k k8s/`,
|
3. **Deploy**: runs `kubectl apply -k k8s/`, then `kubectl rollout restart` so the pod pulls the new
|
||||||
waits for the rollout, and rolls back automatically if it fails.
|
`: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.
|
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
|
## Files
|
||||||
|
|
||||||
- `namespace.yaml`: `personal-site` namespace
|
- `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
|
- `service.yaml`: ClusterIP on port 80 → container port 3000
|
||||||
- `ingress.yaml`: nginx ingress + TLS for `jesseinit.dev` / `www.jesseinit.dev`
|
- `ingress.yaml`: nginx ingress + TLS for `jesseinit.dev` / `www.jesseinit.dev`
|
||||||
- `configmap.yaml`: optional environment variables
|
- `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
|
```bash
|
||||||
kubectl --context personal-cluster -n personal-site get pods,ingress,certificate
|
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 logs -l app=jesseinit-portfolio -f
|
||||||
kubectl --context personal-cluster -n personal-site rollout undo deployment/jesseinit-portfolio
|
|
||||||
|
|
||||||
# Deploy by hand (uses :latest)
|
# Deploy by hand (uses :latest)
|
||||||
kubectl --context personal-cluster apply -k k8s/
|
kubectl --context personal-cluster apply -k k8s/
|
||||||
|
kubectl --context personal-cluster -n personal-site rollout restart deployment/jesseinit-portfolio
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
app: jesseinit-portfolio
|
app: jesseinit-portfolio
|
||||||
spec:
|
spec:
|
||||||
replicas: 2
|
replicas: 1
|
||||||
revisionHistoryLimit: 5
|
revisionHistoryLimit: 5
|
||||||
strategy:
|
strategy:
|
||||||
type: RollingUpdate
|
type: RollingUpdate
|
||||||
@@ -23,7 +23,7 @@ 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
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ labels:
|
|||||||
app.kubernetes.io/name: personal-site
|
app.kubernetes.io/name: personal-site
|
||||||
app.kubernetes.io/managed-by: kustomize
|
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:
|
images:
|
||||||
- name: jesseinit-portfolio
|
- name: jesseinit-portfolio
|
||||||
newName: tea.jesseinit.dev/jesseinit/personal-site
|
newName: tea.jesseinit.dev/jesseinit/personal-site
|
||||||
|
|||||||
+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
+814
-1391
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-32
@@ -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) => {
|
||||||
|
|||||||
+4
-65
@@ -2,10 +2,9 @@ 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 SectionHeading from '../components/SectionHeading';
|
||||||
import ProjectCard from '../components/ProjectCard';
|
|
||||||
import Reveal from '../components/Reveal';
|
import Reveal from '../components/Reveal';
|
||||||
import Icon from '../components/Icon';
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
@@ -15,13 +14,6 @@ export default function Home() {
|
|||||||
<div className="hero-backdrop" aria-hidden="true" />
|
<div className="hero-backdrop" aria-hidden="true" />
|
||||||
<div className="shell hero-grid">
|
<div className="shell hero-grid">
|
||||||
<div className="hero-content">
|
<div className="hero-content">
|
||||||
{profile.available && (
|
|
||||||
<p className="status-pill">
|
|
||||||
<span className="live-dot" />
|
|
||||||
Open to new opportunities
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h1 className="hero-title">
|
<h1 className="hero-title">
|
||||||
Hey, I’m {profile.firstName}.
|
Hey, I’m {profile.firstName}.
|
||||||
<span className="hero-title-muted">{profile.tagline}</span>
|
<span className="hero-title-muted">{profile.tagline}</span>
|
||||||
@@ -34,8 +26,9 @@ export default function Home() {
|
|||||||
Let’s work together
|
Let’s work together
|
||||||
<Icon name="arrowRight" size={16} />
|
<Icon name="arrowRight" size={16} />
|
||||||
</a>
|
</a>
|
||||||
<a href="#projects" className="button button-ghost">
|
<a href="https://github.com/jesseinit" target="_blank" rel="noopener noreferrer" className="button button-ghost">
|
||||||
View projects
|
<Icon name="github" size={16} />
|
||||||
|
View GitHub
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -73,17 +66,6 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
{/* ABOUT */}
|
{/* ABOUT */}
|
||||||
@@ -133,49 +115,6 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</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 |
+13
-249
@@ -329,18 +329,17 @@ button {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same artwork as public/icon.svg; the ring keeps the dark tile visible in dark mode.
|
||||||
.logo-mark {
|
.logo-mark {
|
||||||
display: grid;
|
display: block;
|
||||||
place-items: center;
|
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 9px;
|
flex-shrink: 0;
|
||||||
background: var(--text);
|
border-radius: 7px;
|
||||||
color: var(--bg);
|
}
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.8125rem;
|
[data-theme='dark'] .logo-mark {
|
||||||
font-weight: 700;
|
box-shadow: 0 0 0 1px var(--border-strong);
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.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 {
|
.hero-title {
|
||||||
font-size: clamp(2.5rem, 6vw, 4.25rem);
|
font-size: clamp(2.5rem, 6vw, 4.25rem);
|
||||||
font-weight: 700;
|
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
|
// SECTIONS
|
||||||
.section {
|
.section {
|
||||||
padding: 96px 0;
|
padding: 96px 0;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading {
|
.section-heading {
|
||||||
@@ -845,166 +798,6 @@ button {
|
|||||||
margin-bottom: 16px;
|
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
|
||||||
.footer {
|
.footer {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -1308,10 +1101,6 @@ button {
|
|||||||
.stack-grid {
|
.stack-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
@@ -1346,10 +1135,6 @@ button {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-cta {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-menu {
|
.nav-menu {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
@@ -1362,36 +1147,15 @@ button {
|
|||||||
left: -16px;
|
left: -16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
|
||||||
margin-top: 72px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
padding: 72px 0;
|
padding: 72px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.focus-grid,
|
.focus-grid,
|
||||||
.stack-grid,
|
.stack-grid {
|
||||||
.project-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-grid .span-2 {
|
|
||||||
grid-column: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-card {
|
|
||||||
padding: 36px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-content,
|
.footer-content,
|
||||||
.rates-header {
|
.rates-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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