-
Notifications
You must be signed in to change notification settings - Fork 0
Test vera #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Test vera #7
Changes from all commits
c6b1370
26bfbbd
dbd2d35
4e9898c
04b8e18
5a2c58d
610fa72
ed54926
d78571c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||||||
| subject: 'Booking from ' + name, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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 }) | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,13 +24,15 @@ export default function ParallaxImage({ | |
| const frame = layer.parentElement; | ||
| if (!frame) return; | ||
|
|
||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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; | ||
|
|
||
| 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] | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| for (let i = 1; i < projects.length; i++) { | ||||||||
| if (projects[i].date > latest.date) latest = projects[i] | ||||||||
| } | ||||||||
| return latest.title | ||||||||
| } | ||||||||
| 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) | ||
| } |
| 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 | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For hidden files like
Suggested change
|
||||||||
| } | ||||||||
| 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, '&') // must run first, before the entities we introduce below | ||||||||||
| .replace(/</g, '<') | ||||||||||
| .replace(/>/g, '>') | ||||||||||
| .replace(/"/g, '"') | ||||||||||
| .replace(/'/g, ''') | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Build the mailto link for the "email me" button. | ||||||||||
| export function mailtoLink(email: string, subject: string) { | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| return 'mailto:' + encodeURIComponent(email) + '?subject=' + encodeURIComponent(subject) | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
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.