Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/api/booking/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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 = process.env.RESEND_API_KEY

interface BookingRequest {
name: string
email: string
message: string
}

export async function POST(req: Request) {
if (!RESEND_API_KEY) {
console.error('booking: RESEND_API_KEY is not configured')
return NextResponse.json({ error: 'server misconfigured' }, { status: 500 })
}

let body: Partial<BookingRequest>
try {
body = (await req.json()) as Partial<BookingRequest>
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
}

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 (name.length === 0 || !isEmail(email) || message.length === 0) {
return NextResponse.json({ error: 'bad request' }, { status: 400 })
}

// Do not log PII (email address / message body). A coarse marker is enough.
console.log('New booking received')

try {
const res = await 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request is sent to to: email, i.e. the person who submitted the form. A booking form should notify the site owner; as written the owner never receives the booking. Use a configured owner address (e.g. process.env.BOOKING_TO_EMAIL) and include the submitter's email/name in the body.

Suggested change
to: email,
to: process.env.BOOKING_TO_EMAIL ?? 'owner@example.com',
replyTo: email,

subject: 'Booking from ' + name,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name is interpolated into the subject without sanitization. A name containing CR/LF could inject extra headers if the provider builds raw email from these fields. Escape/validate the name or use a fixed subject.

Suggested change
subject: 'Booking from ' + name,
subject: `Booking from ${name.replace(/[\r\n]/g, ' ')}`,

html: escapeHtml(message),
}),
})

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 })
}
2 changes: 2 additions & 0 deletions app/components/parallaxImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ export default function ParallaxImage({
const frame = layer.parentElement;
if (!frame) return;


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace on this blank line (and line 35). Remove the extra spaces to keep the diff clean and avoid lint noise.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added blank line contains trailing whitespace (four spaces). Same for line 35. It adds noise to the diff and will typically fail prettier/eslint no-trailing-spaces checks in CI. Remove the whitespace so the lines are truly empty.

const prefersReduced =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReduced) return;

let raf = 0;


const update = () => {
const rect = frame.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
Expand Down
27 changes: 27 additions & 0 deletions app/helpers/dateStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// 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 {
if (nums.length === 0) return 0
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) 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostRecent assumes at least one project. If projects is empty, latest is undefined, the loop is skipped, and latest.title on line 24 throws a TypeError and crashes the caller. Guard the empty array before indexing:

Suggested change
let latest = projects[0]
if (projects.length === 0) return ''
let latest = projects[0]

for (let i = 1; i < projects.length; i++) {
if (projects[i].date > latest.date) latest = projects[i]
}
return latest.title
}
15 changes: 15 additions & 0 deletions app/helpers/money.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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 = 0) {
let sum = 0
for (const i of items) sum += i
// 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)
}
15 changes: 15 additions & 0 deletions app/helpers/slugify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Turn a project title into a URL slug for the works pages.
export function slugify(title: string) {
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('/')
const file = parts[parts.length - 1] ?? ''
return file.replace(/\.[^.]+$/, '') // drop the file extension

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For hidden files like .gitignore or .eslintrc, the regex /\.[^.]+$/ matches the entire filename, so labelFromPath returns '' instead of the base name. This creates empty labels and can break anything that uses the label for routing/display. Fix: only strip the extension when there is a non-empty base before the dot:

Suggested change
return file.replace(/\.[^.]+$/, '') // drop the file extension
const dot = file.lastIndexOf('.')
return dot > 0 ? file.slice(0, dot) : file

}
23 changes: 23 additions & 0 deletions app/helpers/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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) {
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(/&/g, '&amp;') // must run first, before the entities we introduce below
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}

// Build the mailto link for the "email me" button.
export function mailtoLink(email: string, subject: string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mailtoLink concatenates raw inputs. If subject contains spaces, &, ?, or newlines, the generated mailto: URL is broken and can inject additional headers/parameters in some clients. Use encodeURIComponent on both email and subject.

Suggested change
export function mailtoLink(email: string, subject: string) {
export function mailtoLink(email: string, subject: string) {
return 'mailto:' + encodeURIComponent(email) + '?subject=' + encodeURIComponent(subject)
}

return 'mailto:' + encodeURIComponent(email) + '?subject=' + encodeURIComponent(subject)
}