diff --git a/app/api/booking/route.ts b/app/api/booking/route.ts new file mode 100644 index 0000000..340b06b --- /dev/null +++ b/app/api/booking/route.ts @@ -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 + try { + body = (await req.json()) as Partial + } 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, + subject: 'Booking from ' + name, + 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 }) +} diff --git a/app/components/parallaxImage.tsx b/app/components/parallaxImage.tsx index 173d091..15989ca 100644 --- a/app/components/parallaxImage.tsx +++ b/app/components/parallaxImage.tsx @@ -24,6 +24,7 @@ export default function ParallaxImage({ const frame = layer.parentElement; if (!frame) return; + const prefersReduced = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -31,6 +32,7 @@ export default function ParallaxImage({ let raf = 0; + const update = () => { const rect = frame.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; diff --git a/app/helpers/dateStats.ts b/app/helpers/dateStats.ts new file mode 100644 index 0000000..7263ff4 --- /dev/null +++ b/app/helpers/dateStats.ts @@ -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] + 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 new file mode 100644 index 0000000..0be01ab --- /dev/null +++ b/app/helpers/money.ts @@ -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) +} diff --git a/app/helpers/slugify.ts b/app/helpers/slugify.ts new file mode 100644 index 0000000..8b194f7 --- /dev/null +++ b/app/helpers/slugify.ts @@ -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 +} diff --git a/app/helpers/validate.ts b/app/helpers/validate.ts new file mode 100644 index 0000000..8127c65 --- /dev/null +++ b/app/helpers/validate.ts @@ -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, '&') // 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:' + encodeURIComponent(email) + '?subject=' + encodeURIComponent(subject) +}