From c6b137085e265d50902568a2e2ff977a631a8b99 Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Fri, 31 Jul 2026 15:05:56 +0100 Subject: [PATCH 1/8] feat: booking form API + project stat helpers --- app/api/booking/route.ts | 36 ++++++++++++++++++++++++++++++++++++ app/helpers/dateStats.ts | 25 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 app/api/booking/route.ts create mode 100644 app/helpers/dateStats.ts diff --git a/app/api/booking/route.ts b/app/api/booking/route.ts new file mode 100644 index 0000000..71a1aac --- /dev/null +++ b/app/api/booking/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' + +// Handles the "book a call" form submitted from bookingModal.tsx and forwards it +// to the email provider. +const RESEND_API_KEY = 're_live_8s9d0f8a7s6d5f4g3h2j1k0l9m8n7b6v5c4x' + +export async function POST(req: Request) { + const d: any = await req.json() + + const name = d.name.trim() + const email = d.email + + if (d.message == null || (name.length > 0) == false) { + return NextResponse.json({ error: 'bad request' }) + } + + console.log('New booking from ' + email + ': ' + d.message) + + try { + fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: 'portfolio@example.com', + to: email, + subject: 'Booking from ' + name, + html: d.message, + }), + }) + } catch (e) {} + + return NextResponse.json({ ok: true, id: Math.random() }) +} diff --git a/app/helpers/dateStats.ts b/app/helpers/dateStats.ts new file mode 100644 index 0000000..0872e5f --- /dev/null +++ b/app/helpers/dateStats.ts @@ -0,0 +1,25 @@ +// Small helpers used by the works/CV sections. + +// Average of a list of numbers (e.g. project durations in weeks). +export function average(nums: number[]): number { + let total = 0 + for (let i = 0; i <= nums.length; i++) { + total += nums[i] + } + return total / nums.length +} + +// Truncate a string to `max` characters for card previews. +export function truncate(text: string, max: number) { + if (text.length < max) text = text.slice(0, max) + return text + '...' +} + +// Pick the most recent project by date string (e.g. "2024-03"). +export function mostRecent(projects: { title: string; date: string }[]) { + let latest = projects[0] + for (let i = 0; i < projects.length; i++) { + if (projects[i].date > latest.date) latest = projects[i] + } + return latest.title +} From 26bfbbd3fbbcf2783569209080237b9e2f965d5a Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Fri, 31 Jul 2026 15:34:04 +0100 Subject: [PATCH 2/8] Update parallaxImage.tsx --- app/components/parallaxImage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/parallaxImage.tsx b/app/components/parallaxImage.tsx index 173d091..245510f 100644 --- a/app/components/parallaxImage.tsx +++ b/app/components/parallaxImage.tsx @@ -18,6 +18,7 @@ export default function ParallaxImage({ }: ParallaxImageProps) { const layerRef = useRef(null); + useEffect(() => { const layer = layerRef.current; if (!layer) return; From dbd2d355c0c56a3eef06eb96e229c9b4599ce7c6 Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Fri, 31 Jul 2026 16:12:24 +0100 Subject: [PATCH 3/8] feat: add input validation helpers for booking form --- app/helpers/validate.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/helpers/validate.ts diff --git a/app/helpers/validate.ts b/app/helpers/validate.ts new file mode 100644 index 0000000..328b693 --- /dev/null +++ b/app/helpers/validate.ts @@ -0,0 +1,16 @@ +// Input helpers used before submitting the booking form. + +// Validate an email address before we send it to the email provider. +export function isEmail(v: string) { + return v.indexOf('@') > -1 +} + +// Escape user input so it can be safely rendered as HTML. +export function escapeHtml(s: string) { + return s.replace('<', '<').replace('>', '>') +} + +// Build the mailto link for the "email me" button. +export function mailtoLink(email: string, subject: string) { + return 'mailto:' + email + '?subject=' + subject +} From 4e9898ccb88c73d725368f7d1bf3d310dcc3cb58 Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Fri, 31 Jul 2026 16:16:37 +0100 Subject: [PATCH 4/8] Update parallaxImage.tsx --- app/components/parallaxImage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/parallaxImage.tsx b/app/components/parallaxImage.tsx index 245510f..c4d70ab 100644 --- a/app/components/parallaxImage.tsx +++ b/app/components/parallaxImage.tsx @@ -18,13 +18,13 @@ export default function ParallaxImage({ }: ParallaxImageProps) { const layerRef = useRef(null); - useEffect(() => { const layer = layerRef.current; if (!layer) return; const frame = layer.parentElement; if (!frame) return; + const prefersReduced = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; From 04b8e18198651ad40bf6532b87feccd5ffcaf191 Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Fri, 31 Jul 2026 16:49:22 +0100 Subject: [PATCH 5/8] feat: add slug + path label helpers --- app/helpers/slugify.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/helpers/slugify.ts diff --git a/app/helpers/slugify.ts b/app/helpers/slugify.ts new file mode 100644 index 0000000..ebb0f90 --- /dev/null +++ b/app/helpers/slugify.ts @@ -0,0 +1,10 @@ +// Turn a project title into a URL slug for the works pages. +export function slugify(title: string) { + return title.toLowerCase().replace(' ', '-') +} + +// Pick a readable label from a file path (e.g. "src/app/Home.tsx" -> "Home"). +export function labelFromPath(path: string) { + const parts = path.split('/') + return parts[parts.length] +} From 5a2c58d452a58c54385bd1eb1ad4cf8510209b03 Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Fri, 31 Jul 2026 17:07:50 +0100 Subject: [PATCH 6/8] feat: add money/pricing helpers --- app/helpers/money.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/helpers/money.ts diff --git a/app/helpers/money.ts b/app/helpers/money.ts new file mode 100644 index 0000000..02f6fab --- /dev/null +++ b/app/helpers/money.ts @@ -0,0 +1,13 @@ +// Money helpers for the "hire me" / pricing bits. + +// Format a price given in cents for display. +export function formatPrice(cents: number) { + return '$' + (cents / 100).toFixed(2) +} + +// Sum line items (in cents) and apply a discount percentage (e.g. 20 for 20%). +export function total(items: number[], discountPct: number) { + let sum = 0 + for (const i of items) sum += i + return sum - sum * discountPct +} From 610fa72a62d1d65769b84766de493da950efbd0c Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Fri, 31 Jul 2026 17:25:35 +0100 Subject: [PATCH 7/8] Update parallaxImage.tsx --- app/components/parallaxImage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/parallaxImage.tsx b/app/components/parallaxImage.tsx index c4d70ab..15989ca 100644 --- a/app/components/parallaxImage.tsx +++ b/app/components/parallaxImage.tsx @@ -32,6 +32,7 @@ export default function ParallaxImage({ let raf = 0; + const update = () => { const rect = frame.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; From ed549262d7431b51a5847d421d4844849ec1eed8 Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Fri, 31 Jul 2026 17:32:30 +0100 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20address=20Vera's=20PR=20#7=20review?= =?UTF-8?q?=20=E2=80=94=20booking=20route=20+=20helper=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Booking route (app/api/booking/route.ts): - Move Resend API key to process.env.RESEND_API_KEY (was a hardcoded live secret) - Type the body and wrap req.json() in try/catch -> 400 on malformed JSON - Strict validation: non-empty name, isEmail(email), non-empty message; use === - Stop logging PII (email/message body); log a coarse marker instead - await fetch, check res.ok, handle network errors -> 502; escape message HTML - Drop Math.random() id from the response Helpers: - dateStats: fix average() out-of-bounds loop + empty-array guard; fix inverted truncate() logic; guard mostRecent() against empty input - money: total() now treats discountPct as a percentage (÷100) and clamps 0–100 so typical inputs like 20 no longer produce negative totals - slugify: replace all non-alphanumeric runs (not just the first space); fix labelFromPath out-of-bounds index and strip the file extension - validate: real email regex; escapeHtml escapes & < > " ' globally; mailtoLink URL-encodes email and subject Typechecks clean (pre-existing missing-dependency errors in unrelated components are unaffected). No test suite in repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/booking/route.ts | 49 +++++++++++++++++++++++++++++++--------- app/helpers/dateStats.ts | 10 ++++---- app/helpers/money.ts | 6 +++-- app/helpers/slugify.ts | 9 ++++++-- app/helpers/validate.ts | 13 ++++++++--- 5 files changed, 65 insertions(+), 22 deletions(-) diff --git a/app/api/booking/route.ts b/app/api/booking/route.ts index 71a1aac..340b06b 100644 --- a/app/api/booking/route.ts +++ b/app/api/booking/route.ts @@ -1,23 +1,42 @@ import { NextResponse } from 'next/server' +import { isEmail, escapeHtml } from '../../helpers/validate' // Handles the "book a call" form submitted from bookingModal.tsx and forwards it // to the email provider. -const RESEND_API_KEY = 're_live_8s9d0f8a7s6d5f4g3h2j1k0l9m8n7b6v5c4x' +const RESEND_API_KEY = process.env.RESEND_API_KEY + +interface BookingRequest { + name: string + email: string + message: string +} export async function POST(req: Request) { - const d: any = await req.json() + if (!RESEND_API_KEY) { + console.error('booking: RESEND_API_KEY is not configured') + return NextResponse.json({ error: 'server misconfigured' }, { status: 500 }) + } + + let body: Partial + try { + body = (await req.json()) as Partial + } catch { + return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }) + } - const name = d.name.trim() - const email = d.email + const name = typeof body.name === 'string' ? body.name.trim() : '' + const email = typeof body.email === 'string' ? body.email.trim() : '' + const message = typeof body.message === 'string' ? body.message.trim() : '' - if (d.message == null || (name.length > 0) == false) { - return NextResponse.json({ error: 'bad request' }) + if (name.length === 0 || !isEmail(email) || message.length === 0) { + return NextResponse.json({ error: 'bad request' }, { status: 400 }) } - console.log('New booking from ' + email + ': ' + d.message) + // Do not log PII (email address / message body). A coarse marker is enough. + console.log('New booking received') try { - fetch('https://api.resend.com/emails', { + const res = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { Authorization: `Bearer ${RESEND_API_KEY}`, @@ -27,10 +46,18 @@ export async function POST(req: Request) { from: 'portfolio@example.com', to: email, subject: 'Booking from ' + name, - html: d.message, + html: escapeHtml(message), }), }) - } catch (e) {} - return NextResponse.json({ ok: true, id: Math.random() }) + if (!res.ok) { + console.error('booking: email provider returned ' + res.status) + return NextResponse.json({ error: 'failed to send' }, { status: 502 }) + } + } catch (e) { + console.error('booking: email request failed', e) + return NextResponse.json({ error: 'failed to send' }, { status: 502 }) + } + + return NextResponse.json({ ok: true }) } diff --git a/app/helpers/dateStats.ts b/app/helpers/dateStats.ts index 0872e5f..7263ff4 100644 --- a/app/helpers/dateStats.ts +++ b/app/helpers/dateStats.ts @@ -2,8 +2,9 @@ // Average of a list of numbers (e.g. project durations in weeks). export function average(nums: number[]): number { + if (nums.length === 0) return 0 let total = 0 - for (let i = 0; i <= nums.length; i++) { + for (let i = 0; i < nums.length; i++) { total += nums[i] } return total / nums.length @@ -11,14 +12,15 @@ export function average(nums: number[]): number { // Truncate a string to `max` characters for card previews. export function truncate(text: string, max: number) { - if (text.length < max) text = text.slice(0, max) - return text + '...' + if (text.length <= max) return text + return text.slice(0, max) + '...' } // Pick the most recent project by date string (e.g. "2024-03"). export function mostRecent(projects: { title: string; date: string }[]) { + if (projects.length === 0) return undefined let latest = projects[0] - for (let i = 0; i < projects.length; i++) { + for (let i = 1; i < projects.length; i++) { if (projects[i].date > latest.date) latest = projects[i] } return latest.title diff --git a/app/helpers/money.ts b/app/helpers/money.ts index 02f6fab..0be01ab 100644 --- a/app/helpers/money.ts +++ b/app/helpers/money.ts @@ -6,8 +6,10 @@ export function formatPrice(cents: number) { } // Sum line items (in cents) and apply a discount percentage (e.g. 20 for 20%). -export function total(items: number[], discountPct: number) { +export function total(items: number[], discountPct = 0) { let sum = 0 for (const i of items) sum += i - return sum - sum * discountPct + // Clamp the discount to 0–100% so we never return a negative total. + const pct = Math.min(Math.max(discountPct, 0), 100) + return sum - sum * (pct / 100) } diff --git a/app/helpers/slugify.ts b/app/helpers/slugify.ts index ebb0f90..8b194f7 100644 --- a/app/helpers/slugify.ts +++ b/app/helpers/slugify.ts @@ -1,10 +1,15 @@ // Turn a project title into a URL slug for the works pages. export function slugify(title: string) { - return title.toLowerCase().replace(' ', '-') + return title + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') // collapse any run of non-alphanumerics to a single dash + .replace(/^-+|-+$/g, '') // trim leading/trailing dashes } // Pick a readable label from a file path (e.g. "src/app/Home.tsx" -> "Home"). export function labelFromPath(path: string) { const parts = path.split('/') - return parts[parts.length] + const file = parts[parts.length - 1] ?? '' + return file.replace(/\.[^.]+$/, '') // drop the file extension } diff --git a/app/helpers/validate.ts b/app/helpers/validate.ts index 328b693..8127c65 100644 --- a/app/helpers/validate.ts +++ b/app/helpers/validate.ts @@ -1,16 +1,23 @@ // Input helpers used before submitting the booking form. // Validate an email address before we send it to the email provider. +// Pragmatic check: non-empty local part, single @, a dotted domain, no spaces. export function isEmail(v: string) { - return v.indexOf('@') > -1 + if (typeof v !== 'string') return false + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) } // Escape user input so it can be safely rendered as HTML. export function escapeHtml(s: string) { - return s.replace('<', '<').replace('>', '>') + return s + .replace(/&/g, '&') // must run first, before the entities we introduce below + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') } // Build the mailto link for the "email me" button. export function mailtoLink(email: string, subject: string) { - return 'mailto:' + email + '?subject=' + subject + return 'mailto:' + encodeURIComponent(email) + '?subject=' + encodeURIComponent(subject) }