Notes
Notes from James
What I'm building, thinking about, and working on.
Check out in .
The mobile menu renders six service colours without a context provider
The homepage has a floating hamburger button fixed to the top-left corner on mobile. Tap it and a dropdown appears with six links — Software, Accessibility, Audio, Production, Musician, Notes. Each link has an icon coloured to match the service accent. Purple for software. Fuchsia for accessibility. Cyan for audio. Emerald for production. Orange for musician. Pink for notes. The MobileNav component stores six NavItem objects in a static array. Each item carries two hex values — a dark variant and a light variant. The dark values are the same -400 shades used across the rest of the site. The light values are the -600 shades that pass WCAG contrast on white backgrounds. When the component renders each link, it reads the current theme from the useTheme hook and picks the matching hex value inline — theme equals light means item.light, otherwise item.dark. That decision happens per icon, per render, with no context provider wrapping the tree. The useTheme hook watches the data-theme attribute on the document element through a MutationObserver. When the ThemeToggle flips the attribute, the observer fires, the hook re-renders the MobileNav, and every icon colour updates simultaneously. The menu itself is two layers. A fixed backdrop with fifty percent black opacity covers the viewport and closes the menu on tap. The dropdown sits absolute below the button with a glass background at ninety-five percent opacity, a backdrop blur, and a rounded border. The button is forty-four pixels square — above the WCAG minimum touch target. The aria-expanded attribute flips with the open state. The aria-label switches between Open menu and Close menu. The icon swaps from a hamburger to an X mark. The whole component is seventy-four lines including the nav items array. Six services, two colour modes, one hook call, zero context providers.
The footer links every package for search engines but hides them from phones
The Footer component renders two elements. The first is a nav that only appears on desktop — hidden on base, block on the lg breakpoint. Inside is a five-column grid where each column represents a service. The column header is a link to the service page. Below it is an unordered list of every product in that service, each linking to its checkout page. Sound Engineering has four links. Music Production has five. Software Engineering has four. Accessibility has four. Musician has four. Twenty-one internal links in total, all generated dynamically from the same products array that powers the service pages and checkout flows. If I add a new package to the products array, the footer picks it up on the next build with zero additional configuration. The grid is hidden on mobile because twenty-one links stacked vertically would push the actual footer content off-screen. On desktop, the five-column layout fits cleanly above the copyright section. The purpose is SEO internal linking. Every checkout page is set to noindex nofollow — search engines cannot find them through crawling. But the footer links exist on every public page of the site, giving search engines a path from the indexed service pages to the individual product pages. The links also tell search engines about the site structure — five services, four to five packages each, a clear hierarchy. The second element is the visible footer with the company logos, copyright line, registered office address, company number, legal links, and a WCAG 2.1 AA compliance badge. The legal links have a minimum height of forty-four pixels using an inline-flex alignment wrapper. The logos have hover opacity transitions from seventy percent to full. The entire Footer is one hundred and four lines. One component, two sections, twenty-one dynamic links, and the product catalog drives both the service pages and the sitemap alternative in the footer.
The milestone bar fills with a gradient and glows on the active dot
Every project card in the portal shows a MilestoneTimeline. The component takes a milestones array and renders two things — a progress bar and a dot list. The progress bar is a one-pixel-tall rounded div with a five percent white background. Inside it, a child div fills from left to right with a gradient running from cyan-400 to emerald-400. The width is a percentage calculated from the number of completed milestones divided by the total. Three out of five milestones completed means the bar fills to sixty percent. The bar has role progressbar with aria-valuenow set to the computed percentage, aria-valuemin at zero, aria-valuemax at one hundred, and aria-label reading Project progress followed by the number. Screen readers announce the project's completion state without needing to count dots. Below the bar, each milestone renders as a flex item with a two-pixel dot and a text label. The dot colour comes from a config object keyed by milestone status. Done milestones get emerald-400 — a solid green that matches the right end of the gradient. Active milestones get cyan-400 with a box shadow — zero horizontal, zero vertical, six pixels blur, and the shadow colour at forty percent opacity. That creates the glow. The active dot appears to emit light while the others are flat. Todo milestones get ten percent white — nearly invisible against the dark background, signalling work that has not started. The whole component is forty-nine lines. It takes one prop, computes one percentage, and maps one array. The gradient fill gives a sense of overall momentum. The glowing dot tells you exactly where the work is right now. The dim dots show what is ahead. Three visual signals — fill, glow, dim — from one status field on each milestone record.
The sidebar card stops being a link when you are already looking at it
Every service page has a product sidebar listing every package for that service. The ProductSidebar component renders each package as a SidebarCard. The interesting part is the wrapper. When a card is not the active product, it wraps in a Next.js Link pointing to that product's checkout page. When a card is the active product — the one the client is currently viewing — it wraps in a plain div. No link, no pointer cursor, no navigation. You cannot click the thing you are already looking at. The implementation avoids duplicating the card markup by building the inner JSX first as a variable called inner, then conditionally wrapping it. If isActive is true, return a div containing inner. If false, return a Link containing inner. The Link uses scroll set to false so clicking a different package in the sidebar swaps the content without jumping the page back to the top. The scroll position stays put. The client can browse all four software packages without the viewport lurching with every click. The active card gets a visual treatment through inline styles. The border colour switches to the service accent CSS custom property. The background uses color-mix in sRGB to blend ten percent of the accent into transparent, creating a subtle tinted glass effect that signals which package is selected. The border width increases from one pixel to two. Inactive cards use the default glass border and glass background tokens so they recede visually. The hover state on inactive cards uses Tailwind's arbitrary variant syntax — hover colon ampersand greater-than div colon border-accent at fifty percent opacity. That nested selector targets the inner div from the outer Link's hover state, so the border tint appears on mouseover without any JavaScript event handlers or state updates. The whole sidebar is sixty-six lines. One component renders every product for a service, adapts its wrapper element based on selection state, and staggers its entrance with BlurReveal delays of one hundred milliseconds between cards.
Light mode rewrites twenty-four Tailwind colours in CSS so no component needs to change
The site was designed dark-first. Every component uses Tailwind's -400 shade classes — text-cyan-400 for audio accents, text-emerald-400 for production, text-fuchsia-400 for accessibility. Those shades look great on a near-black background. On a white background, they fail WCAG AA contrast. Cyan-400 on white is about 2.5 to 1. The minimum is 4.5 to 1. The fix is not in the components. It is in globals.css. Twenty-four CSS overrides sit under the data-theme light selector. Thirteen text colour rules swap every -400 and -300 shade to its -600 or -700 equivalent. text-cyan-400 becomes 0891b2 — cyan-600 at roughly 4.5 to 1 contrast. text-emerald-400 becomes 059669 — emerald-600 at 4.5 to 1. text-orange-400 becomes ea580c — orange-600 at 4.6 to 1. Every override has a comment with the target shade name and its exact contrast ratio so future changes can verify they still pass. Five border overrides match the text swaps — border-cyan-400, border-emerald-400, border-orange-400, border-fuchsia-400, and border-pink-400 all shift to the same darker values. Four background overrides handle badges and pills where the accent is a filled surface rather than text. Then there is the SVG rule. Every SVG logo on the site is white for dark mode. The light mode override applies filter invert one and hue-rotate 180 degrees to any image source ending in dot svg. The invert flips white to black. The hue-rotate corrects the colour shift that invert introduces. The logos appear in the correct brand colours on both backgrounds without maintaining two image files. The total cost is twenty-four CSS rules with the important flag. No component was edited. No conditional className was added. No ternary operator decides between text-cyan-400 and text-cyan-700 at render time. The components write dark-mode classes and the stylesheet rewrites them for light mode. One file, zero component changes, full WCAG AA compliance across both themes.
Font Awesome disables its own CSS injection so icons never flash full-size on first load
The root layout has two lines that solve a problem most Next.js sites with Font Awesome icons never fix. The first line is config.autoAddCss equals false. The second is an import of fontawesome-svg-core slash styles.css. Without those two lines, Font Awesome injects a style tag into the document at runtime. On the server, the icon renders as an inline SVG with no CSS applied. The SVG has an intrinsic size based on the viewBox — often several hundred pixels wide. When the browser receives the server-rendered HTML, it paints those oversized SVGs immediately. A fraction of a second later, the Font Awesome runtime JavaScript executes, injects the style tag, and the icons snap down to their intended size. The visual result is a flash — every icon on the page appears enormous for one frame, then shrinks. It looks broken. The fix is to load the CSS at build time instead of runtime. The manual import of styles.css tells Next.js to include Font Awesome's sizing and positioning rules in the page's CSS bundle. When the browser receives the HTML, the stylesheet is already parsed and the icons render at their correct size from the first paint. Setting autoAddCss to false tells the Font Awesome runtime not to inject its own style tag since the CSS is already present. Two lines of configuration. One import, one boolean. The result is that the service card icons on the homepage, the navigation icons in the mobile menu, the checkmark in the checkout progress bar, and every other Font Awesome icon across the site renders at the right size on the first frame. No flash, no layout shift, no runtime style injection. The same two lines appear in the Font Awesome documentation for server-side rendering but they are easy to miss if you are following a quick-start guide that assumes client-side rendering only.
The calendar only shows weekdays because weekends do not exist in this booking system
The booking calendar on every service page renders a five-column grid. Not seven. Five. Monday through Friday. The component loops through every day of the month, checks the day of the week with getDay, and skips any day that returns zero or six. Saturday and Sunday never make it into the cells array. They are not greyed out. They are not disabled. They do not exist in the DOM. A client looking at the calendar sees a compact weekday-only layout that communicates availability at a glance without wasting space on days I do not work. The grid is not static. Every future date starts in a blurred state — literally. The CSS class blur-[3px] with sixty percent opacity creates a frosted effect on dates that have not been checked yet. The component fires availability requests in batches of four using Promise.all inside a sequential loop. As each batch resolves, the dates in that batch transition from blurred to sharp over five hundred milliseconds. The effect is progressive revelation — the calendar sharpens from top to bottom as the API responds, giving the client a visual sense of the system working. A date with zero available slots gets a strikethrough and forty percent opacity. A date with slots becomes selectable — the blur lifts, the border appears, the hover scale activates. The whole thing looks like a grid of frosted glass tiles that clear one by one to reveal which days are open. The cancellation pattern is a boolean flag in a closure. When the month changes, the useEffect cleanup sets cancelled to true. Any in-flight fetch that returns after the month switch checks the flag and silently discards its result instead of updating stale state. Four parallel requests, sequential batches, progressive blur reveal, and zero wasted renders for months the client has already left behind.
The checkout progress bar uses aria-current step so screen readers know where you are
The checkout wizard has four steps — Package, Details, Pay, Done. The progress indicator at the top is a nav element with an ordered list inside. Each step is a list item. The active step carries aria-current set to step. That single attribute tells a screen reader exactly where the user is in the flow without them needing to parse visual cues like filled circles or accent colours. The visual layer is built on three states per step — completed, active, and future. A completed step shows a checkmark SVG inside a translucent accent circle. The accent colour comes from the service CSS custom property so the progress bar matches the page theme automatically — purple for software, cyan for audio, emerald for production. The active step fills its circle solid with the accent and renders the step number in black for maximum contrast. Future steps use a glass background with muted text. The connector lines between steps fill with the accent colour as you progress, creating a visual trail of completion. On mobile, the step labels disappear entirely. Only the numbered circles remain in a tight horizontal row. The labels are hidden with a responsive class — hidden on base, inline on sm breakpoint. The circles are twenty-eight pixels square, well above the minimum touch target for a non-interactive indicator. The whole nav is sixty-seven lines of code including the type definition. It takes one prop — currentStep — and derives everything else from the step index. No animation library, no transition group, no spring physics. Just conditional classes based on array position.
The notes page filters with a sentence, not a toolbar
The notes page does not have a filter bar with dropdowns and labels and a reset button. It has a sentence. Check out everything in all topics. The words everything and all topics are select elements styled to look like inline text. They use the same font size as the surrounding prose, a bottom border in the accent colour, a transparent background, and bold weight. When you tap one, a native dropdown opens with the options — thoughts, updates, building notes, tips for the type filter, and software, accessibility, audio for the topic filter. The sentence reads differently depending on your selection. Check out tips in accessibility. Check out updates in all topics. The language adapts naturally because the option labels are written as words that fit the grammar — everything, thoughts, updates, building notes, tips. Not Everything, Not All Types. Lowercase plural nouns that slot into a sentence. The select styling removes the default browser appearance with a negative webkit-appearance and a custom appearance-none class. The options themselves are styled with the site dark background and white text so the dropdown matches the page instead of rendering as a jarring system-native white box. The result count at the bottom updates live — 12 of 117 notes — giving immediate feedback on how many entries match. If nothing matches, a centred message replaces the list. The component does not debounce. It does not animate. It filters the array on every state change and React renders the new list. With a hundred-plus notes the filter is instantaneous because there is no network request — the entire notes array lives in memory as a static import. Two selects, one sentence, zero toolbars.
The background gradient is fixed to the viewport so the page scrolls over it
The site does not have a flat background colour. The body element has a CSS gradient at a hundred and sixty-five degrees with four colour stops — near-black at the top, deep purple at forty percent, a cooler dark at seventy percent, and back to near-black at the bottom. The key property is background-attachment fixed. That pins the gradient to the viewport, not the document. When you scroll, the content moves but the background stays still. The effect is subtle — you probably do not notice it consciously, but the page feels like it has depth. Content floats over a static atmosphere instead of sliding on a flat surface. The same gradient drives every page. Service pages, checkout pages, the notes section, the portal — they all inherit the body gradient because it is set once in globals.css. No per-page background configuration. No component-level overrides. Light mode swaps all four stops to lavender tones at the same angle and the same positions. The gradient structure is identical — only the palette changes. The colour-scheme property on the html element switches between dark and light based on the data-theme attribute, so native browser controls like scrollbars and select dropdowns match the theme automatically. One CSS rule on the body creates the entire visual foundation. No images, no JavaScript, no performance cost. The four-stop gradient is roughly sixty bytes of CSS. The fixed attachment means the browser composites the background as a single layer and never repaints it on scroll. It is the cheapest visual effect on the entire site and the one that touches every pixel.
Every notification email opens with a first name because automated updates should not feel automated
The portal sends five types of notification email — session notes added, milestone status changed, new comment posted, new deliverable uploaded, and new action item assigned. Every one of them opens with Hi followed by the client's first name. Not their full name, not Dear Client, not a generic greeting. The code calls clientName.split and takes the first element. Sarah Thompson becomes Hi Sarah. That is a deliberate decision about tone. These are not marketing emails. They are not transactional receipts. They are project updates between me and a client I am working with. The language should match the relationship. The subject lines follow the same principle. New session notes uses the project name as context. Milestone completed names the specific milestone. Action needed tells the client something is waiting for them. Every subject line describes what happened without padding it with branding or exclamation marks. The body text is two to four lines of plain text. No HTML template, no header image, no footer links, no unsubscribe mechanism. Just a sentence explaining what changed, a link to the portal, and James at the bottom. The reply-to on every email is my actual email address so the client can respond directly to the notification and it lands in my inbox. The getResend factory function returns null if the RESEND_API_KEY is missing. Every notification function checks for null before attempting to send. If the key is not configured — during local development, during testing, during a CI run — the notification silently skips instead of throwing an error. Email is useful but it is not a dependency. The project update still happens in the database whether or not the email goes out. Five notification types, one tone, zero HTML templates, and every greeting is personal.
The checkout page hides from search engines because a product URL is a funnel, not a destination
Every checkout page on the site sets robots to noindex nofollow. The route at checkout slash productId generates metadata dynamically — the title says Checkout colon product name for purchasable products and Get a Quote colon product name for quote-based products. Both tell search engines to stay away. The rationale is simple. A checkout page is the middle of a funnel. Nobody searches for checkout remote per track or get a quote web app. They search for sound engineering services or software development. That is what the service pages are for. The five service pages are fully indexed with OpenGraph tags, Twitter cards, and structured descriptions. They rank in search. The checkout pages convert. The separation is deliberate. The same page also adapts its sidebar behaviour based on whether the product has a price. When you are buying a fixed-price package, the sidebar defaults to the packages section — you can see the other options while you check out. When you are requesting a quote for a product without a fixed price, the sidebar defaults to the email section — the contact form is immediately visible because that is what you need. Both flows share the same layout, the same ServiceNav breadcrumb, the same sticky sidebar, the same accent colour inherited from the service. The accent colour itself is set as a CSS custom property on the outermost div of the page, read from a five-entry config map keyed by service name. Every child component — the wizard, the sidebar, the form accordion, the product cards — reads the accent through the CSS variable without receiving it as a prop. One route handles twenty-two products across five services. It adapts its metadata, its content, its sidebar state, and its visual theme based on a single product lookup. The only thing all twenty-two pages have in common is that none of them appear in search results.
The theme switches before React hydrates so nobody sees a flash
The site has a dark mode and a light mode. The toggle is a button fixed in the top-right corner of every page. When you click it, it does three things — sets a data-theme attribute on the document element, saves the value to localStorage, and updates local state. That last one re-renders the button so the icon swaps from a sun to a moon. The interesting part is what happens before any of that. The root layout has a six-line inline script in the head element, rendered with dangerouslySetInnerHTML. It reads localStorage synchronously and sets the data-theme attribute on the html element before the browser paints a single pixel. The script runs during HTML parsing, before React hydrates, before any component mounts. If you chose light mode yesterday, the page opens in light mode today with zero flash. No white-to-dark flicker, no layout shift, no FOUC. The script is one hundred and three characters. It costs nothing. The reactivity system is equally minimal. Every component that needs to know the current theme calls the useTheme hook. The hook does not use a context provider. It does not use an event listener. It sets up a MutationObserver that watches the data-theme attribute on the document element. When ThemeToggle flips the attribute, the observer fires, the hook updates its state, and the component re-renders with the new theme. No prop drilling, no context wrapper, no event bus. One DOM attribute is the single source of truth. The MutationObserver disconnects on unmount. The CSS layer handles the rest — the root selector defines dark mode custom properties, the data-theme light selector overrides them. Every Tailwind class, every accent colour, every glass surface reads from those properties. One attribute drives the entire visual system. The ThemeToggle button itself is forty-four pixels square with a glass background and the accent border on hover. It meets the WCAG minimum touch target. The aria-label updates dynamically — Switch to light mode when you are in dark, Switch to dark mode when you are in light. One button, one script, one observer, zero context providers.
The checkout wizard reads the URL so 3D Secure returns land on the right step
When a client pays with a card that requires 3D Secure verification, Stripe redirects the browser to the bank's authentication page. After the client confirms, the bank redirects back to the return URL. The problem is that the checkout wizard is a client component with local state. A full page navigation wipes that state. The wizard solves this with four lines in the initial state function. When the component mounts, it reads the URL search parameters. If step equals confirmation and redirect_status equals succeeded, it sets the initial state to confirmation and immediately calls window.history.replaceState to strip the query parameters from the URL. The browser address bar shows the clean product URL. The wizard opens on the confirmation screen. The client sees the payment confirmed message, the consultation booking calendar, and the what happens next steps — exactly as if the payment had succeeded without a redirect. If the query parameters are missing or the status is not succeeded, the wizard starts on the package step as normal. The state initialiser is a lazy function passed to useState so it only runs once on mount, not on every render. The replaceState call uses an empty object for state and an empty string for title because those parameters are ignored by modern browsers — only the URL matters. The confirmation step itself does not know or care how the client got there. It renders the same full-screen overlay with backdrop blur, the same checkmark in a tinted circle, the same consultation picker with real Google Calendar availability. The name and email fields are empty after a redirect because local state was lost, but the greeting falls back to there — Thanks, there — and the Stripe webhook has already sent the confirmation email with the full details. The booking request still works because the form fields are not required for the consultation. One URL check, one state initialiser, one replaceState call, and the redirect flow is invisible to the client.
Every focus ring uses the accent colour and every animation respects the user
The site has one global focus style. The focus-visible pseudo-class sets a two-pixel solid outline using the accent colour custom property with a two-pixel offset. Every interactive element on every page — buttons, links, inputs, the theme toggle, the skip-to-content link, the service card navigation, the checkout wizard — gets the same visible focus ring when reached by keyboard. No element has outline none without a replacement. No element uses a custom focus style that differs from the global one. The accent colour is purple by default and shifts to the service accent on service pages, so the focus ring adapts to context automatically. The skip-to-content link works through CSS positioning alone. By default, it sits at position absolute with left set to negative nine thousand nine hundred and ninety-nine pixels. It is off-screen but still in the accessibility tree. When a keyboard user tabs into the page, the link receives focus and a single CSS rule fires — left zero. The link slides into view at the top-left corner with a z-index of nine hundred and ninety-nine so it always appears above other content. The background uses the accent colour. The text uses the background colour. The padding is 0.75rem by 1.5rem, giving it a touch target well above forty-four pixels. The border-radius is zero on three corners and 0.5rem on the bottom-right, so it tucks into the top-left corner of the viewport. When focus moves away, the link slides back off-screen. Zero JavaScript. The reduced motion preference is handled at the root. When prefers-reduced-motion is set to reduce, the scroll-behavior on the html element switches from smooth to auto. Every scroll-into-view call, every anchor link jump, every hash-based sidebar switch snaps instantly instead of animating. The color-scheme property switches between dark and light based on the data-theme attribute so the browser's native form controls — scrollbars, select dropdowns, date pickers — match the theme without any additional styling. Three CSS rules handle three accessibility concerns — focus visibility, motion sensitivity, and native control theming.
The database connects when you query, not when you import
The Prisma client is a Proxy that pretends to be a PrismaClient but does not create one until the first property access. The module exports a const called prisma. That const is not an instance of PrismaClient. It is a Proxy wrapping an empty object. The get trap checks whether a real PrismaClient has been stashed on globalThis. If it has not, the trap calls createPrismaClient which reads DATABASE_URL, creates a PrismaPg adapter with the connection string, passes the adapter to the PrismaClient constructor, and stores the result on globalThis so every subsequent access reuses the same instance. If it has, the trap calls Reflect.get on the cached instance and returns whatever property was requested. The entire module is twenty-six lines. The reason this matters is Next.js imports every server module at build time to analyse the dependency graph. If the Prisma client was created at import time, the build would crash because DATABASE_URL is not set in the CI environment. The CI pipeline runs four checks — lint, type check, test, build — and none of them have any environment variables configured. No STRIPE_SECRET_KEY, no RESEND_API_KEY, no DATABASE_URL. The lazy Proxy solves the build problem without conditional imports, without dynamic requires, without splitting the module into a factory and a consumer. Any file can import prisma at the top level and use it in a server action or API route. The Proxy intercepts the first real call — prisma.client.findUnique, prisma.project.findMany, whatever — creates the connection at that moment, and every call after that hits the cached instance. One Proxy, one globalThis slot, zero build-time connections.
The sidebar reads the URL hash and opens to your intent
Every service page has a sidebar with up to three accordion panels — packages, book a call, send a brief. The FormAccordion component decides which panel opens based on the URL hash at load time. If the hash is #send-brief the email panel opens. If the hash is #book-call the call panel opens. If the hash is #packages-sidebar the packages panel opens. If there is no hash, the default is packages when packages exist or call when they do not. The component also listens for hashchange events after mount so clicking a link anywhere on the page can switch the active panel without a full navigation. The ServiceNav at the top of every service page has two call-to-action buttons — Book a Call and Send a Brief. Each one is an anchor link to the corresponding hash. Clicking either scrolls the sidebar into view and opens the right panel in one motion. The AccordionPanel component handles its own accessibility. When a panel is collapsed it gets role button and tabIndex zero so keyboard users can reach it and activate it with Enter or Space. When a panel is active those attributes are removed because the panel is now a container not a control. The Space key handler calls preventDefault to stop the browser from scrolling the page. Each panel has scroll-mt-24 so it clears the sticky header when scrolled into view. The border colour switches from the glass border token to the service accent colour when active. The background shifts from the glass token to a five percent tint of the accent using color-mix in sRGB. The border width goes from one pixel to two. Three panels, one state variable, one hashchange listener. The URL drives the UI and the UI stays in sync with the URL.
The Google OAuth routes tell you to delete them after you use them
The Google Calendar integration needs a refresh token. Getting a refresh token from Google requires a one-time OAuth consent flow. The site has two API routes dedicated to this — /api/auth/google and /api/auth/google/callback. The first route creates an OAuth2 client with the Google client ID and secret, generates an authorization URL with offline access and the consent prompt forced, and redirects the browser to Google. The scope is calendar only. After the user consents, Google redirects to the callback route with an authorization code in the query string. The callback route exchanges the code for tokens using the same OAuth2 client. Then it renders a full HTML page — not a JSON response, not a redirect, a styled HTML page with a monospace font on a dark background that looks like the rest of the site. The page shows the refresh token in a pre block with user-select all so you can click and copy. Below the token it tells you exactly what to do — add GOOGLE_REFRESH_TOKEN to your env file, restart the dev server, then delete the entire src/app/api/auth/google directory. The routes are designed to be used once and thrown away. They exist in the codebase as a setup tool, not as a production feature. The comment at the top of each file says the same thing — delete these routes after you have your refresh token. The callback page says it again in the rendered HTML. Three reminders across two files. Once the refresh token is in the environment, the Google Calendar client in google-calendar.ts uses it to generate access tokens on every request. The OAuth routes never run again. They are scaffolding that documents its own removal.
Forty-one tests guard the checkout before any human reviews the code
The site has forty-one unit tests across three files — products, checkout, and contact. Every test runs through Vitest with a Node environment and path aliases matching the tsconfig so imports like @/lib/products resolve the same way they do in production. The product tests validate the entire catalog at compile time. Every product must have a truthy id, name, description, and priceLabel. Every product must have at least one feature. Every id must be unique across the full array. Fixed-price products must have a positive pricePence. Contact products must have null pricePence. The isPurchasable function gets six tests — true for fixed with a positive price, false for contact, false for fixed with null, false for fixed with zero, true for every real fixed product in the catalog, false for every real contact product. The getProductsByService function gets a generated test per service — a loop over all five service keys asserting that every returned product matches the key and that the sum across all services equals the total product count. The checkout tests mock Stripe at the module level using vi.mock before the route is imported so the real Stripe SDK never loads. Eleven tests cover missing fields, invalid emails, honeypot submissions that return success without calling Stripe, non-existent product IDs, contact-only products that cannot be purchased, valid purchases that verify the PaymentIntent amount and metadata, idempotency keys passed through to the Stripe options object, message truncation to five hundred characters, whitespace trimming on names, and both idempotency and network errors. The contact tests mock Resend the same way and cover thirteen cases — missing name, missing email, invalid email, honeypot, invalid service type, valid contact without a service, valid contact with a service, all five valid service types in a loop, Resend errors, and missing API keys. Every test creates a real Request object with the right method, headers, and JSON body. No test touches a real third-party API. The coverage config targets src/lib and src/app/api so library code and API routes are measured but components and pages are not. Forty-one tests, three files, zero network calls.
The CI pipeline runs four checks and none of them need secrets
Every push to main and every pull request triggers a GitHub Actions workflow called CI. It runs on ubuntu-latest with Node 20 and npm caching enabled. Four steps run in sequence — lint, type check, test, build. The lint step runs the Next.js ESLint config. The type check runs tsc with noEmit so it validates every TypeScript file without producing output. The test step runs Vitest which executes all forty-one unit tests. The build step runs next build to confirm the production bundle compiles clean. None of these steps require environment variables or API keys. The Stripe SDK is mocked in tests. The Resend SDK is mocked in tests. The Prisma client generates from the schema without a database connection. The Google Calendar client is never imported in test files. The build succeeds because every external dependency is either mocked, tree-shaken, or loaded conditionally at runtime. That means the CI pipeline has zero secrets configured in GitHub. No STRIPE_SECRET_KEY, no RESEND_API_KEY, no DATABASE_URL, no CLERK_SECRET_KEY. The workflow file is thirty-three lines. One job, four steps, four quality gates. If any step fails, the pipeline fails and the merge is blocked. Railway watches the main branch and auto-deploys on every merge, so the deploy pipeline is one line of config on their side — connect the repo, pick the branch, done. CI catches the code problems. Railway handles the infrastructure. The two never overlap.
The sitemap knows ten URLs and the portal is not one of them
The sitemap is a TypeScript function that returns an array of ten URL objects. The homepage at priority one with weekly change frequency. The five service pages — software, accessibility, audio, production, musician — at priority 0.9 with monthly frequency. The notes page at 0.7 with weekly frequency because new entries appear regularly. The donate page at 0.4 with yearly frequency. The terms and privacy pages at 0.3 with yearly frequency. Every entry has a lastModified set to new Date at build time so crawlers know the content is current. The portal is not in the sitemap because it is authenticated content. The robots file is a separate TypeScript function that returns a single rule — allow everything for all user agents — and points to the sitemap URL. The portal pages handle their own exclusion through metadata. The portal layout sets robots to noindex nofollow on every child route. The donate thanks page does the same because a thank-you page has no search value. The note detail pages go in the opposite direction — each one generates its own metadata through generateMetadata with the note title, a 160-character excerpt as the description, and full OpenGraph and Twitter card tags for social sharing. The root layout sets the global metadata — title, description, OpenGraph with en_GB locale, Twitter card — and the metadataBase to the production URL so all relative paths resolve correctly. Ten public URLs in the sitemap. Fifteen portal routes excluded. Every page has metadata. Every exclusion is deliberate.
One middleware rule protects the portal and ignores everything else
The entire authentication boundary for the site is nine lines of middleware. Clerk's createRouteMatcher takes one pattern — /portal(.*) — and returns a function that checks whether the current request URL matches. If it matches, auth.protect fires and redirects unauthenticated users to the sign-in page. If it does not match, the middleware does nothing and the request passes through untouched. Every service page, every checkout flow, every API route outside the portal, the homepage, the notes, the donate page — none of them hit the auth check. The matcher config at the bottom excludes static files and Next.js internals so the middleware only runs on actual page and API requests. Two regex patterns handle that — one for pages that are not static assets, one for API and tRPC routes. The whole file imports two functions from Clerk and exports one middleware function and one config object. No role checks at this layer. No redirect logic. No session inspection. The middleware answers one question — is this a portal route and is the user signed in. Role-based access happens deeper in the stack. The admin layout checks publicMetadata.role. The server actions check requireAdmin or auth individually. The middleware is the outer wall. Everything inside handles its own permissions. One pattern, one check, one redirect. The marketing site stays completely open. The portal stays completely locked.
Ten models, three enums, and every delete cascades
The database schema is one Prisma file with ten models and four enums. Client is the root — every other model connects back to it directly or through Project. A Client has an optional clerkId that starts null until the Clerk webhook links it, a unique email, an optional stripeCustomerId for invoice lookups, and an archivedAt timestamp for soft deletes. Project belongs to Client and carries a ServiceType enum — SOFTWARE, ACCESSIBILITY, AUDIO, PRODUCTION, MUSICIAN — and a ProjectStatus enum — ACTIVE, IN_REVIEW, COMPLETED, ON_HOLD. Six models hang off Project with cascading deletes — Milestone, Session, Goal, Deliverable, ActionItem, and Attachment. Delete a project and everything underneath disappears in one operation. Milestone has a sortOrder integer so the timeline renders in the right sequence and a MilestoneStatus enum — TODO, ACTIVE, DONE. Session stores a durationMin integer, optional notes as a Text column for long content, and an optional calendarEventId that links it to Google Calendar. Comment is the most connected model — it has optional foreign keys to Session, Milestone, and Goal so the same comment model serves inline threads on three different entity types. Each comment carries an isJames boolean and an authorName string for the avatar system, plus editedAt and deletedAt timestamps for audit trails. Attachment stores the R2 key as url, the file size, the MIME type, and an uploadedBy string defaulting to james. Deliverable has its own four-stage enum — PENDING, IN_PROGRESS, DELIVERED, APPROVED — and an optional one-to-one link to an Attachment. Booking stands apart from the project tree — it connects directly to Client with a unique calendarEventId for upsert-based sync and indexed startTime for chronological queries. Every foreign key that connects to Project or Session uses onDelete Cascade. Every model that needs frequent lookups has an index on its parent key. Ten models, fourteen indexes, four enums, and the whole schema fits in two hundred lines.
Nine accent colours, two shades each, and one function picks the right one
The colour system lives in one file called theme-colors. It exports an accentColors object with nine named pairs — purple, violet, fuchsia, cyan, emerald, orange, blue, amber, and pink. Each pair has a dark value and a light value. The dark values are Tailwind's -400 shades — vibrant enough to read on dark backgrounds. The light values are -600 and -700 shades that pass WCAG AA 4.5 to 1 contrast ratio on white. One object, eighteen hex values, and every accent on the site pulls from it. The noteTypeColorPairs record maps each note type to its colour pair — thought gets violet, update gets blue, building gets emerald, tip gets amber. The tagColorPairs record maps each tag to its service accent — software gets purple, ai gets purple, accessibility gets fuchsia, audio gets cyan, production gets emerald. Both records reference the same accentColors entries so there is no duplication. The pickColor function takes a colour pair and a theme string — dark or light — and returns the right hex value. That is three lines of code. Every component that needs a theme-aware accent calls pickColor with the pair and the current theme from the useTheme hook. The MobileNav calls it for icon colours. The NoteCard calls it for badge and tag colours. The HomeNotesFilter calls it for dropdown accents. The service pages do not use this system because they set their accent as a CSS custom property at the page level and everything underneath inherits it. The theme-colors file handles the components that live outside a service context — notes, navigation, the homepage. Nine colours, two modes, one function. No conditional classNames, no ternary chains, no theme context provider. Just a lookup and a return.
Twenty packages, two pricing modes, one catalog file
Every service I offer — sound engineering, music production, session musician work, software engineering, accessibility consulting — is defined in a single TypeScript array called products. Twenty entries. Each one carries an id, a name, a description, a price in pence or null, a human-readable price label, a list of features, a service key, a delivery estimate, and a consultation duration in minutes. The key field is type — either fixed or contact. Fixed means the price is set and you can buy it right now. Contact means you need a quote. The isPurchasable function checks for fixed type and a non-null pricePence greater than zero. That one function decides everything downstream. If isPurchasable returns true, the service page renders a checkout wizard with Stripe Elements embedded in the page. If it returns false, the page shows the package details with an intake form that sends an enquiry email. Two completely different user journeys, one boolean. Sound engineering has four packages — single track mix and master at two hundred and fifty pounds, EP mix and master priced per track, album mix and master as a quote, and podcast or voiceover edit at a hundred pounds. Music production has five — composition at five hundred, full track at a thousand, TV and film as a quote, advertising as a quote, custom sample packs as a quote. Musician has four — remote track at a hundred, half day at four hundred, full day at eight hundred, multi-day as a quote. Software has four — landing page at fifteen hundred, brochure site at thirty-five hundred, web app as a quote, MVP from five thousand as a quote. Accessibility has four — quick audit at five hundred, full site audit at two thousand, remediation as a quote, training from a thousand as a quote. The getProductsByService function filters the array by service key. Each service page calls it once and maps the result into product cards. Adding a new package means adding one object to the array. No database migration, no dashboard update, no deploy config. Push to main and it is live.
The availability window is thirty days and every slot is checked twice
The availability API accepts a date and a duration. The date must be in YYYY-MM-DD format. The duration must be 15, 30, 45, 60, 90, or 120 minutes — anything else is rejected. Bookings more than thirty days out are blocked before the Google Calendar query even runs. The API fetches real calendar events from Google, finds gaps in the schedule, and returns an array of available slots. Each slot has a start and end time. The ConsultationPicker component on the front end renders these as a grid of buttons — one per slot, grouped by day. When a client picks a slot and hits confirm, the booking API receives the start time and duration. Before creating the calendar event, it calls getAvailableSlots again with the same date and duration. It checks whether the requested start time still exists in the returned array. If someone else booked that slot between the page load and the confirm click, the slot is gone. The API returns a 409 conflict with a message telling the client to choose another time. The booking only proceeds if the slot survives both checks — the initial load and the confirmation request. The actual calendar event is created via createBooking, which returns the start time, end time, and a Google Meet link if conference data exists. The booking confirmation email includes the date formatted as a full weekday and month, the time in 24-hour UK format, the service label, and the Meet link. If the booking came from the checkout confirmation page — where the client just paid for a package — the skipEmail flag is set to true and no email fires because the Stripe webhook already handled that. Two checks, one calendar, zero double bookings.
Five services, five colours, one layout
Every service page on the site follows the same structure. A ServiceNav header with the service name and call-to-action buttons. A two-column layout — content on the left, a sticky ServiceSidebar on the right with an intake form or package selector. Cross-links to sibling services at the bottom. The layout is identical across all five pages. What changes is the accent colour. Sound engineering is cyan — hex 22d3ee. Music production is emerald — hex 34d399. Professional musician is orange — hex fb923c. Software engineering is purple — hex c084fc. Accessibility consulting is fuchsia — hex e879f9. The colour is set as a CSS custom property at the page level. Every interactive element downstream reads from that variable — buttons, borders, hover states, focus rings, the ServiceNav highlight, the sidebar accent, the product card call-to-action, the checkout wizard progress indicator, the confirmation page checkmark. One variable propagates through the entire component tree. The ServiceIntakeForm renders different fields per service. The audio form asks for genre and track count. The software form asks for project type and timeline. The accessibility form asks about the platform and compliance target. But the form component itself is one file — ServiceIntakeForm.tsx. It accepts a service key and a fields array. The fields array is a configuration object defined on each service page. Each field has a name, label, type, required flag, placeholder, and optional select options. The component maps over the array and renders inputs, selects, or textareas based on the type. Five services, five configurations, one form component. The same pattern runs through the product cards, the sidebar, the checkout flow. One set of components, five colour themes, five field configurations. The service pages are not five separate implementations. They are five datasets rendered through one shared system.
Five tiers, one custom field, zero product records
The donate page does not use Stripe Products or Prices. Every donation is a one-off Checkout Session created from scratch with a price_data object containing the amount in pence, the currency set to GBP, and a product_data name that reads Donation — £5.00. No pre-created products in the Stripe dashboard. No price IDs stored in environment variables. The DonationTiers component renders five preset buttons — five pounds for a coffee, ten for phone credit, fifteen for infrastructure, twenty-five for pizza and a late night coding session, fifty for new strings or drumsticks. Each button calls the donate API with the amount in pence. Below the presets is a custom amount field — a number input with a minimum of one pound and a maximum of ten thousand. The validation is client-side first and server-side second. The API rejects anything below 100 pence or above 1,000,000 pence before Stripe ever sees it. The success URL points to a dedicated thanks page at /donate/thanks. The page is excluded from search indexing with robots noindex nofollow because there is no reason for a search engine to find a thank-you page. The thanks page has one emoji, one heading, one sentence, and a button back to the homepage. The entire donation flow is one client component, one API route, one Stripe Checkout redirect, and one static thank-you page. No subscription model, no recurring billing, no donor database. Someone wants to buy me a coffee, they pick an amount, pay through Stripe, and land on a thank-you page. That is it.
The newsletter catches bots before they subscribe
The newsletter signup sits at the bottom of the homepage in a glass card. One email input, one subscribe button, one honeypot. The honeypot is an invisible text field named website with aria-hidden true and tabIndex negative one. A real user never sees it. A bot filling out every field on the page fills it in. When the API receives a request where the website field has a value, it returns success true without doing anything. No error message, no feedback, nothing for the bot to learn from. It thinks it subscribed. It did not. For real subscribers, the API adds the email to a Resend Audience using the contacts.create endpoint. If the contact already exists — the API returns an error message containing already exists — the handler catches it and returns alreadySubscribed true instead of treating it as a failure. The component reads that flag and shows You're already subscribed instead of the generic success message. For new subscribers, the API sends a welcome email immediately after the contact is created. The email is plain text — a greeting, a one-sentence description of what to expect, and a sign-off. No HTML template, no images, no tracking pixels. The component handles four states — idle, loading, success, and error. The loading state disables the button and shows three dots. The success state replaces the entire form with the confirmation message so there is no way to accidentally double-subscribe. One form, one honeypot, one Audience, one welcome email, four states.
Every enquiry sends two emails and the client gets a reply-to
The contact form on every service page sends two emails through a single API call. The first goes to me — subject line tagged with the service name in square brackets, body containing every field the client filled in, reply-to set to the client's email so I can respond directly from my inbox. The second goes to the client — a plain text confirmation that their enquiry was received, a one-to-two working day response estimate, and a reply-to pointing back to my personal email. Two emails, two directions, one API route. The service-specific formatting is handled by a formatServiceFields function that iterates over every key in the request body, skips the standard fields — name, email, message, service, website — and formats the rest as labelled lines. The key is converted from snake_case to Title Case with a regex replacement. If the client filled in genre, track_count, and project_type on the audio intake form, the email reads Genre, Track Count, and Project Type followed by their values. The message field gets its own section at the bottom with a blank line above it. The subject line adapts to the service — [Sound Engineering] New enquiry from Sarah or [Accessibility Consulting] New enquiry from David. If no service is specified, the subject falls back to Contact from followed by the name. The honeypot check runs before any validation. Same pattern as the newsletter — if the invisible website field has a value, the API returns success without sending anything. Five services, five different intake form configurations, one API route, two emails per enquiry.
The purchase webhook writes to Stripe before it writes to any inbox
When a client buys a package — a mixing session, a software build, an accessibility audit — Stripe fires a payment_intent.succeeded webhook. The handler does four things in order. First it verifies the Stripe signature using the raw request body so tampered payloads are rejected before any logic runs. Second it reads the PaymentIntent metadata for a field called emailSent. If the value is true, the handler returns immediately. This is the idempotency check. Stripe retries webhooks. Serverless functions cold-start. Without this check, a client could receive two identical confirmation emails. Third — and this is the key — it writes emailSent true back to the PaymentIntent metadata before sending a single email. If two webhook deliveries hit simultaneously, only the one that wins the metadata update race proceeds. The loser reads emailSent as true on its next check and exits. Fourth it sends two emails through Resend. The customer gets an order summary — package name, price, delivery estimate, and a three-step guide: book your consultation, discuss the project, I deliver. If they left a message during checkout, it is quoted in the email. The reply-to is my personal email so they can respond directly. I get a separate notification with the customer name, email, product, their message, and a direct link to the Stripe dashboard payment page. Both emails are plain text. Both are wrapped in independent try-catch blocks. If the customer email fails, I still get notified. If my email fails, the customer still gets confirmation. Two emails, two catch blocks, one metadata field, zero duplicates.
The confirmation page turns a payment into a meeting
After a client completes checkout, the wizard does not show a thank-you message with a link to go somewhere else. It replaces the entire viewport with a full-screen confirmation splash — a fixed-position overlay with backdrop blur that fills the screen edge to edge. At the centre is a checkmark in a tinted circle, the client's name, and the package they just bought. Below that is a consultation booking calendar. The client just paid, and the very next thing they see is a grid of available time slots for a free 45-minute call. The ConsultationPicker component fetches real availability from Google Calendar through the availability API. Slots that are already taken do not appear. When the client picks a slot, the booking request includes the product name in the notes field — Purchased: Landing Page, Purchased: Quick Audit — so I know what the meeting is about before I join the call. The booking fires with skipEmail set to true because the Stripe webhook already sent a confirmation. No duplicate emails. If the booking request fails — network error, API timeout, calendar conflict — the payment is still confirmed. The client sees the consultation section but can book later through the confirmation email or by replying directly. The what happens next section adapts to whether they booked. If they did, step two reads Join your consultation at the scheduled time. If they did not, it reads Book your consultation call above. A Back to services link at the bottom takes them to the service page they came from, not the homepage, because the URL includes the product's service type. One confirmation page, two states, and the distance between paying and talking to me is one click.
Three files keep the portal from ever showing a blank screen
The portal has three fallback components and each one catches a different failure mode. The loading skeleton renders during server-side rendering while the page data is being fetched. It is a server component — no JavaScript, no state, just HTML with a pulse animation. The skeleton shapes match the real dashboard layout: a heading bar at 48 pixels wide, a subheading bar at 72 pixels, three summary cards in a responsive grid, and a large content area below. Every shape uses the same glass border and glass background as the real components so the skeleton feels like part of the portal, not a placeholder from a different design system. The error boundary is a client component because React error boundaries need component state to capture the error and the reset callback. If any server component or client component inside the portal throws, the boundary catches it and renders the error message with a Try again button. The button calls React's reset function which re-renders the errored component tree from scratch. The not-found page handles two cases with one sentence — This portal page does not exist or you do not have access. To a client, the distinction does not matter. Whether they typed a wrong URL or tried to access another client's project, the response is the same: a message and a link back to the dashboard. All three fallbacks share the same visual language. Centred text, accent-coloured interactive elements, 44-pixel minimum touch targets on every button and link. The error button uses the accent border. The not-found link uses the accent text colour. The loading skeleton uses the glass surface colours. Three files, fifteen lines each on average, and the portal never crashes to a raw error trace or a white screen.
Client records exist before the client signs up
When I take on a new client, the first thing I do is create their record in the admin panel. Name, email, optional company, optional Stripe customer ID. That is four fields and a submit button. No invitation email fires. No magic link gets sent. The client record sits in the database with a null clerkId, which means the notification guard — the check that prevents emails from going out — stays closed. No session notes emails, no milestone updates, no deliverable alerts. The record is just a placeholder that says this person exists and this is their email. When the client eventually signs up through the Clerk-powered sign-in page, a webhook fires. The Clerk user.created event hits the webhook endpoint. The handler checks whether a client with that email already exists in the database. If it does and the clerkId field is null, it links the two together — one update query that writes the Clerk ID onto the existing record. From that moment forward, the client can log in and see their projects, and the notification guard opens because clerkId is no longer null. If the client signs up before I create their record — maybe they find the sign-in page on their own — the webhook creates a fresh client record with their Clerk ID already attached. Either order works. Admin first, client first, it does not matter. The upsert handles both paths. The same webhook also handles user.updated events. If a client changes their email or name in Clerk, the database stays in sync automatically. One webhook route, two event types, zero manual data entry after the initial setup.
Two-letter avatars cost zero storage and zero API calls
Every comment in the portal shows an avatar next to the author name. The avatar is not an image. It is two letters — the initials of the person who wrote the comment — rendered in a 32-pixel circle with a border. The initials are generated at render time from the authorName field. Split on spaces, map to the first character of each word, join into a string, slice to two characters, uppercase. A client named Sarah Chen gets SC. I get JM. A single-name client gets the first two letters of their name. The generation is five chained string methods inline in the JSX. No utility function, no database field, no pre-computed value. The circle itself has two visual modes controlled by the isJames boolean on the comment record. When isJames is true, the border colour is the portal accent — the service-specific colour that runs through every accent element. When isJames is false, the border uses the muted glass colour that every non-interactive border shares. That single boolean — stored in the database at comment creation time — tells the client at a glance which comments are from me and which are from them. The author name below the avatar uses the same conditional. My name renders in the accent colour. Their name renders in the default text colour. Two visual signals — border and name colour — both driven by one boolean. No profile picture upload feature, no Gravatar lookup, no external avatar service. The comment model stores a plain string for authorName and a boolean for isJames. The component turns those two fields into a complete visual identity.
The booking sync matches clients to calendar events by attendee email
The portal shows every client their upcoming and past meetings with me. Those bookings do not come from a form or a manual entry. They come from my Google Calendar via a cron job that runs daily. The sync function starts by loading every active client from the database and building a Map — email address lowercase as the key, client ID as the value. Then it authenticates with Google using an OAuth2 client configured with a refresh token and fetches calendar events from ninety days ago to thirty days ahead. For each event, it reads the attendee list and checks every attendee email against the client Map. If an attendee matches a client, that event becomes a booking. The function extracts four things from the calendar event — the title, the start time, the end time, and the Google Meet link. The Meet link has a fallback chain: first it checks conferenceData.entryPoints for a video type, then it falls back to the hangoutLink property. If neither exists, the booking renders without a Join button. Each booking is upserted by calendarEventId so the same event never creates duplicate records. If I reschedule a meeting in Google Calendar, the next sync picks up the new times and updates the existing record. If I cancel it, it stays in the database as a past booking — the portal does not delete records that once existed. The cron endpoint authenticates with a CRON_SECRET bearer token so it cannot be triggered by anyone who does not have the secret. It also exposes a GET handler so I can trigger a manual sync from a browser during development. One cron job, one Map lookup, one upsert per event, and every client sees their meetings without me touching the portal.
One guard function protects every admin write operation
Every server action in the admin panel starts with the same four lines. Call requireAdmin. Get the Clerk session. Fetch the user. Check publicMetadata.role. If the role is not admin, throw before any database write runs. That is the entire authorisation model for the admin side of the portal. Seven server actions — createClient, createProject, addSessionNotes, updateMilestone, createMilestone, createDeliverable, createActionItem, createGoal, addAdminComment — all call the same function on line one. No decorator pattern, no middleware chain, no role-checking wrapper component. Just a plain async function that throws if you are not me. The client-side server actions use a different pattern. They call auth from Clerk, look up the client by clerkId, and verify ownership of the specific resource before writing. The toggleActionItem action goes further — it checks that the action item is assigned to client before allowing the toggle. A client cannot complete my tasks. I cannot accidentally run a client action because the client actions check clerkId, not role. Two guard patterns, ten server actions, zero unprotected writes. The admin guard is four lines. The client guard is three queries. Both throw before touching the database if anything is wrong. Security is not a feature you bolt on at the end. It is the first line of every function.
Notifications fire and forget because email is not a dependency
The portal sends five types of email notification — session notes added, milestone status changed, comment posted, deliverable uploaded, action item assigned. Every one is a plain text email sent through Resend with a single API call. No HTML template, no inline CSS, no image hosting. Just lines of text joined with newline characters. Every notification follows the same shape. A greeting using the client's first name extracted with split and index zero. A one-sentence update explaining what changed. A link back to the portal. A sign-off. The subject line includes the project name so the client can see which project the update relates to without opening the email. Every notification is called with a catch that swallows errors — fire and forget. If Resend is down, the notification silently fails. The admin action still succeeds. The database still updates. The portal still reflects the change. The email is a courtesy, not a dependency. The getResend function checks for the API key at call time, not import time. If the key is missing — in CI, in local development, in a test environment — the function returns null and every notification function returns early. No conditional imports, no environment checks scattered through the codebase. One null check in one factory function. The notification guard also checks whether the client has a clerkId before sending. If I create a client record through the admin panel before they sign up, they do not get bombarded with emails for every update. Notifications only fire for clients who have actually joined the portal.
The GlassCard is one component with two visual modes
Every card in the portal — dashboard summaries, project cards, booking entries, action items, session notes, settings panels — uses the same GlassCard component. It is nineteen lines of code. A div with rounded corners, a border, and backdrop blur. The component takes one boolean prop called accent. When accent is false, the card uses the default glass border and glass background — a subtle translucent container that blends with the dark portal background. When accent is true, the border shifts to twenty-five percent of the service accent colour blended into transparent using color-mix, and the background shifts to four percent of the accent. That single boolean creates two distinct visual weights. Default cards recede. Accent cards draw attention. The admin dashboard uses accent cards for upcoming bookings so they stand out from the project list. The client dashboard uses them for the next meeting. The bookings page uses them for upcoming sessions and default cards for past ones. The same component handles both without a className override. The colour values come from CSS custom properties — --color-glass-border and --color-glass for default, --color-accent for the tinted variant. Those properties are set once on the portal layout and every GlassCard underneath inherits them. No prop drilling, no theme context, no conditional class logic inside the cards. One component, one prop, two modes, and every surface in the portal has a consistent look because they all render through the same nineteen lines.
Server actions replaced every API route in the portal
The client portal and admin panel do not use a single REST endpoint. Every form submission — adding session notes, creating milestones, toggling action items, posting comments, creating deliverables — goes through React server actions. The admin side has seven server actions in one file. Each one starts with a requireAdmin call that checks the Clerk session, fetches the user, and reads publicMetadata.role. If the role is not admin, the function throws before any database write happens. The client side has three server actions — addComment, toggleGoal, and toggleActionItem. Each one authenticates with auth from Clerk, looks up the client by clerkId, and verifies ownership before touching the database. The toggleActionItem action goes one step further — it checks that the action item is assigned to client before allowing the toggle. A client cannot mark my tasks as done. Every server action follows the same shape. It receives the previous state and a FormData object. It validates, writes, and returns either a success flag or an error string. The component calls useActionState with the action and renders the result. No fetch call, no JSON.parse, no try-catch around a network request. The form posts directly to the server function and the page revalidates automatically. Seven admin actions and three client actions handle every write operation across fifteen portal routes. The entire portal has zero custom API endpoints.
Every portal query is scoped to one client and nothing leaks
The portal query layer is a single TypeScript file with seven exported functions. Every function takes a clerkId as its first argument. Every function starts by looking up the client record with that clerkId. If no client exists, it returns null or an empty array. No data is ever returned without first proving that the requesting user owns it. The getClientDashboard function uses a single Prisma query with nested includes — projects filtered to active and in-review statuses, milestones ordered by sort position, the latest session with its comments, and the next upcoming booking. One query, one round trip, one render. The getProjectDetail function takes a project ID and a clerkId. It finds the client first, then queries the project with a where clause that checks both the project ID and the client ID. If someone tries to access a project that belongs to a different client, the query returns null and the page renders a 404. The getClientBookings function splits results into upcoming and past using a Promise.all with two parallel Prisma queries — one for bookings after now ordered ascending, one for bookings before now ordered descending and limited to ten. The Stripe invoice query works differently because invoices live in Stripe, not the database. The getClientInvoices function takes a stripeCustomerId, hits the Stripe API directly, filters to succeeded and pending intents, and formats the response with Intl.NumberFormat using the actual currency code from each payment. Seven functions serve the entire portal. The data access pattern is always the same — authenticate, look up the client, scope the query, return the result. Adding a new portal page means calling an existing query function or adding one to the same file.
The admin project editor runs seven forms on one page
When I open a project in the admin panel, I land on a page with five tabs — Session Notes, Milestones, Deliverables, Action Items, and Comments. Each tab has its own form. A sixth form for adding goals sits permanently at the bottom. The page initialises seven useActionState hooks simultaneously — one for each server action. Each hook returns a state object, an action function, and a pending boolean. The forms are independent. I can add a milestone, switch to the session tab, write notes, switch to deliverables, add a file, and come back to milestones — each form maintains its own state and its own success or error message. The session notes tab has a duration field defaulting to forty-five minutes, a textarea for the notes, and a FileDropZone component for attaching recordings or documents. The milestones tab has a quick-add form and a status update form where I paste a milestone ID and select a new status from a dropdown. The deliverables tab collects a title, optional description, and optional due date. The action items tab has an assignment selector — James or Client — that controls who sees the task and who gets the notification email. The comments tab lets me post as James on any session, milestone, or goal by pasting its entity ID. Every form has a forty-eight pixel minimum height on inputs and buttons. Every form disables its submit button while pending and shows a text indicator — Saving, three dots, or the action name. Every form clears its feedback message when you switch tabs because the tab switch re-renders only the active panel. One client component, seven hooks, five tabs, zero page navigations between admin operations.
One project page shows everything without a single tab switch
When a client taps into a project from the portal dashboard, they land on a single page that shows every dimension of the work. The project detail page is an async server component that calls getProjectDetail with the project ID and the user's Clerk ID. If the project does not belong to that user, notFound fires and the page never renders. If it does, everything loads in one query — milestones with their comments, sessions with their attachments and comment threads, deliverables with status tracking, action items with assignment labels, and goals with discussion threads. The page lays it all out vertically. Milestones come first with a MilestoneTimeline gradient bar at the top and each milestone listed underneath with a coloured dot — emerald for done, cyan with a glow for active, dim white for upcoming. Every milestone has an inline CommentThread so the client can ask a question about a specific phase without navigating away. Sessions come next, each in its own GlassCard with the date, duration, notes, and any attached files rendered through AttachmentList. Audio attachments get a Play button. Everything else gets Download. Each session has its own CommentThread too. Deliverables follow with a vertical connector line tracking the four-stage pipeline — pending, in progress, delivered, approved. Then action items with the shared checklist. Then goals with their completion dots and discussion threads. One page, one query, one scroll. A client working on a mixing project and a software build sees the same layout for both because the components do not know or care what service type the project belongs to. The service type is a database field. The UI is universal.
The admin dashboard loads six queries at once and nothing waits
The admin dashboard is the command centre for every service I deliver. It needs to show active projects, open action items, pending deliverables, upcoming bookings, recent sessions, and the full client list — all on one page, all from the database, all before a single pixel renders. The page is a server component that opens with a Promise.all containing six Prisma queries running in parallel. Clients with a count of their projects and bookings. Active and in-review projects with their milestones, session counts, deliverable counts, action item counts, and the client name. The ten most recent sessions with their project and client names. Upcoming bookings ordered by start time with client names. All incomplete action items with their parent project. All pending or in-progress deliverables with their parent project. Six queries, one round trip, one render. The summary cards at the top show four numbers — active projects, open actions, pending deliverables, and upcoming bookings. Below that, every active project is a card with a MilestoneTimeline, a status badge, and counts for sessions, deliverables, and actions. Each card links to the admin project editor. The upcoming bookings section shows the next ten meetings with client names, formatted dates, and a Join button for each one that has a Google Meet link. The open action items section shows every incomplete task with a colour-coded badge — cyan for mine, orange for the client's — and a link to the project it belongs to. The pending deliverables section does the same with due dates. One page gives me the full picture across every client and every service type. No switching between tabs, no filtering by service, no separate tools for audio and software projects. Six parallel queries and one vertical scroll.
The portal layout wraps every page in the same shell
Every portal page — dashboard, projects, sessions, invoices, bookings, goals, settings — renders inside the same layout component. The PortalLayout is an async server component that checks authentication first. If currentUser returns nothing, the redirect fires before any child page even attempts to render. If the user is authenticated, the layout renders three structural layers around the page content. The first layer is a sticky top navigation bar that mirrors the ServiceNav breadcrumb pattern from the public site. The James Music logo on the left links home. A slash separator and the word Portal in the accent colour link to the dashboard. On the right, a line of text shows who is signed in — first name if available, email address if not, with a fallback to Client. The second layer is a horizontal tab bar with six links — Dashboard, Projects, Sessions, Invoices, Bookings, Goals. The tabs sit in a scrollable container with hidden scrollbars so on mobile you can swipe through them without a visible scroll track. Every tab link has a 44-pixel minimum height and rounded hover state. The third layer is the main content area, constrained to 900 pixels wide with horizontal padding. Below the content sits a footer with the logo, Privacy, and Terms links. The metadata sets robots to noindex nofollow because the portal is authenticated content that should never appear in search results. The layout does not know which page it is wrapping. It provides the shell — authentication, navigation, structure, footer — and the page fills in the content. One layout, fifteen portal routes, zero duplicated navigation code.
The dashboard is a personalised briefing, not a list of links
When a client logs into the portal, they do not land on a generic welcome page. They land on a dashboard that knows who they are and shows them exactly where things stand. The PortalDashboard is a server component that calls currentUser from Clerk, fetches the client record with all their projects, bookings, and sessions, pulls their invoices from Stripe, and renders the whole picture in one page load. No client-side fetching, no loading spinners, no skeleton screens. The page renders with real data from the first frame. The layout is three summary cards at the top — active project count, outstanding balance, and total sessions — followed by the next upcoming booking with a Join Meet button, then active projects with milestone progress bars, then the latest session notes, then recent invoices. A client with three active projects sees all three with their milestone timelines. A client with no projects yet sees a single message explaining that James will set things up shortly. The summary cards use a GlassCard component with backdrop blur and a subtle border. The outstanding balance shows emerald green with a checkmark when everything is paid. The projects section links to the full projects list. The invoices section links to the full invoice history. Every link has a 44-pixel minimum touch target. The whole dashboard is one async function, one database query with nested includes, one Stripe API call. No useEffect, no useState, no client-side state management. Server components mean the data is already there when the HTML arrives.
Goals track the big picture when milestones track the detail
Milestones are granular. Ship the homepage. Integrate Stripe. Pass the accessibility audit. Goals are broader. Launch the platform. Reach WCAG AA compliance across all pages. Get the first paying client through the portal. The goals page in the portal shows every goal across every project a client has with me. Each goal is a GlassCard with a circular status indicator — an emerald filled dot when complete, a hollow bordered circle when still in progress. Completed goals get a strikethrough and reduced opacity so the eye goes straight to what is still outstanding. Every goal links back to the project it belongs to so you can drill into the detail without switching context. The page header shows the completion ratio — 3/7 completed — giving the client a single number that answers the question how far along are we. The data comes from a server query that filters goals by the logged-in client's Clerk ID, includes the parent project name, and orders by completion status so active goals appear first. The whole page is a server component with no client-side JavaScript. It renders with the data already present. No loading state, no optimistic updates, no WebSocket subscriptions. One query, one render, one answer to one question — what are we working toward and how close are we.
Every session has a paper trail the client can read
When I finish a mixing session, a software sprint call, or an accessibility audit walkthrough, I write session notes in the admin panel. Those notes appear in the client's portal immediately. The SessionCard component renders each session with four pieces of context — the date formatted in UK English using Intl.DateTimeFormat, the duration in minutes, the comment count, and a link to the parent project. The notes themselves are line-clamped to three lines on the session list so every card has a consistent height, but the full text is available when you tap through to the project. The date uses a semantic time element with an ISO datetime attribute so machines can parse it and humans can read it. The comment count appears with proper pluralisation — 1 comment or 3 comments — using a single ternary. If there are no comments, that metadata disappears entirely rather than showing zero comments. The project name links back to the full project view using the accent colour so it visually connects to the navigation. The card itself is a glass container with the same border and blur treatment as every other portal component. One component handles every session type. A ninety-minute mixing session and a thirty-minute software standup use the same card, the same date format, the same comment indicator. The client has a complete record of every interaction without me sending a single follow-up email.
Audio files play inside the portal without a download
When I deliver a mix or a mastered track through the portal, the client does not have to download it to hear it. The AttachmentList component checks every file's MIME type and renders a Play button next to any streamable audio format — MP3, MP4, AAC, OGG. Tap Play and the component fetches a signed URL from the API, sets it as the source of a native HTML audio element, and starts playback immediately. The audio element renders inline with full browser controls — play, pause, scrub, volume. When the track ends, the state resets and the button goes back to Play. Tap it again while it is playing and it stops. One toggle, one audio element, one signed URL per play. The same component handles every file type in the portal. Audio files get a Play button and a Download button. PDFs, images, and code archives get Download only. The distinction comes from two utility functions — isAudio checks whether the MIME type starts with audio or matches a known audio format, and isStreamableAudio narrows that to formats the browser can actually play. WAV files are audio but not streamable in every browser, so they get Download only. File sizes display in human-readable format — bytes, kilobytes, or megabytes — using a three-step formatter. The signed URL expires after twenty-four hours, but that does not matter because a fresh one is generated every time you press Play or Download. The client can listen to their mix, leave a comment in the thread underneath, and come back tomorrow for another listen with a new URL. One component handles stems, masters, accessibility reports, and software deliverables. The audio preview is just a feature of the same list.
Every booking has a one-tap Join Meet button
The portal bookings page shows every meeting the client has with me — upcoming and past. Each one is a BookingCard with the title, the date formatted in UK English, the time range, and a Join Meet button that opens Google Meet in a new tab. The button only appears on upcoming bookings. Past bookings render the same card but dimmed to sixty percent opacity with no button — you can see that the meeting happened, but there is nothing to tap. The visual distinction between upcoming and past is a single boolean prop called isPast. When isPast is true, the card uses the default glass border. When false, it uses an accent-tinted border generated with color-mix — twenty-five percent of the portal accent blended into transparent. That subtle border shift is enough to tell you at a glance which meetings are still ahead. The date uses Intl.DateTimeFormat with en-GB locale so it reads like Mon 5 May, not 5/5/2026. The time range uses the same formatter for both start and end with an en dash between them. Every timestamp is wrapped in a time element with an ISO datetime attribute for machine readability. The Join Meet button is forty-four pixels tall, uses the accent colour as a solid background, and labels its destination with a trailing arrow. One card component for every booking across all five service types. A mixing session review, a software sprint call, an accessibility audit walkthrough — they all use the same card, the same button, the same date format.
Not every package has a price tag and that is deliberate
Some services cannot be priced upfront. A full web application, a multi-day musician booking, an enterprise accessibility remediation — these need a conversation before a number. The product catalog handles this with a single check. Every product has a pricePence field. Fixed-price packages store a real number. Quote-based packages store null and a priceLabel like Get a quote. The isPurchasable function checks whether pricePence is a valid positive number. If it is, the checkout page renders the full four-step Stripe payment wizard. If it is not, the page renders a QuoteRequest component instead. The QuoteRequest shows the package name, description, features with accent-coloured checkmarks, the price label, and two calls to action — Call me about this project and E-mail your dream. Both are anchor links that jump to sections in the sidebar accordion on the same page. The call button uses a solid accent background. The email button uses a bordered outline. Both have a two-percent scale on hover. No dead ends. A client who lands on a quote-based package is guided straight into a conversation instead of bouncing off a missing Buy Now button. The ProductCard component on the service page adapts too. Purchasable products show a Buy now button with the accent background. Quote products show a Get a quote button with a bordered outline. Same card, same layout, one conditional style. The entire branching logic between paid checkout and quote request is one boolean check on one field. No separate routes, no feature flags, no conditional page components. One product array, one checkout page, one boolean.
The homepage service cards are a gateway, not a grid
The homepage does not list my services in a vertical stack or a flat grid. It arranges them in two rows — software and accessibility on the first row, audio, production, and musician on the second. The ServiceCards component maps over a typed array of five service definitions, each one storing a title, a description, an href, a Font Awesome icon, and two colour values — dark and light. The useTheme hook picks the right shade at render time. Each card is a Next.js Link wrapping an icon, a heading, a description, and a Learn more arrow. On mobile, the cards stack vertically with a compact inline layout — icon and title side by side with the description underneath. On desktop, they fan out horizontally with large centered icons. The hover effect is a three percent scale transform, pure CSS. The accent colour for each card is set as a CSS custom property called --card-color on the link element so the border tint, the icon colour, the heading colour, and the arrow colour all read from one variable. No conditional classNames per service. One card component, one colour variable, five services. The layout intentionally puts the two consultancy services — software and accessibility — on the top row because those are the services most visitors are looking for. The creative services sit underneath. The visual hierarchy guides attention before anyone reads a word.
Uploaded files never touch the Next.js server
The portal has a FileDropZone component that handles file uploads for session recordings, stems, accessibility reports, and software deliverables. The upload flow is three steps and the file never passes through the Next.js server. Step one — the client-side code posts the filename, content type, and size to an API route that generates a presigned upload URL from Cloudflare R2. Step two — the browser uploads the file directly to R2 using that presigned URL with a PUT request. The file travels from the client's device to object storage without touching the application server, which means a 500MB WAV file does not consume server memory or block the event loop. Step three — the client calls a completion endpoint that creates an Attachment record in the database linking the R2 key to the project and optionally to a session. The drop zone itself is a div with role button, tabIndex zero, and keyboard handlers for Enter and Space so it works without a mouse. Drag a file over it and the border highlights. Tap it on mobile and the native file picker opens with an accept list filtered to audio, PDF, and image types. Multiple files upload in sequence with a progress label for each one. Completed files appear below the drop zone with an emerald checkmark, the filename, and a formatted file size. The same component works in the admin panel for uploading deliverables and in the session view for attaching recordings. One drop zone, one three-step flow, zero server-side file handling.
Action items are a shared checklist between me and the client
Every project in the portal has an action item checklist. The ActionItemChecklist component splits items into two groups — the client's action items and my action items. The client can tick off their own items. They cannot tick off mine. That single boolean — canToggle — controls the entire permission model for the checklist. Each item is a form with a hidden input carrying the action item ID and a submit button styled as a checkbox. The button uses useActionState with a server action so toggling an item posts directly to the server without a fetch call or JSON serialisation. The aria-label on each checkbox adapts to state — it says Mark followed by the item title as done when unchecked, and Unmark followed by the title when checked. A screen reader user knows exactly what each toggle will do before they activate it. Completed items get a strikethrough and reduced opacity so the visual hierarchy shifts toward what still needs doing. The emerald fill on a checked box matches the same emerald used for completed milestones and approved deliverables — the portal's universal colour for done. My items render with muted heading text so the client's eye goes to their own responsibilities first. The whole component is one array, one filter, one server action. No WebSocket, no optimistic update, no local state management. The form posts, the server toggles, the page reflects the change. One checklist for every project type — audio, software, accessibility, production, musician.
The product grid scrolls one card at a time
The audio and production service pages display their packages in a horizontal carousel instead of a vertical list. The ProductGrid component renders a scrollable track with CSS snap points — snap-x and snap-mandatory on the container, snap-start on each card. Swipe on mobile and the next card snaps cleanly to the left edge. On desktop, arrow buttons appear at the edges. The arrows are not always visible. A scroll event listener checks the container's scrollLeft against scrollWidth minus clientWidth and shows or hides each arrow based on whether there is more content in that direction. Scroll all the way left and the left arrow disappears. Scroll all the way right and the right arrow disappears. The scroll distance is not a magic number. When you click an arrow, the component queries the first card element with data-product-card, reads its offsetWidth, adds the 16-pixel gap, and scrolls by exactly that distance. One card per click, every time, regardless of card width or screen size. The native scrollbar is hidden with three CSS rules — WebKit's pseudo-element, Firefox's scrollbar-width none, and IE's overflow style. The arrows are 44 by 44 pixels with aria-labels so keyboard and screen reader users get the same navigation. The component takes one prop — the service name. It calls getProductsByService internally, maps over the results, and renders a ProductCard for each one. Two service pages use the same component with different data. Zero duplicated scroll logic.
Invoices come straight from Stripe, not a database
The portal does not store invoice records in its own database. When a client opens the invoices page, the server hits the Stripe API with their stripeCustomerId and pulls back every payment charge. The InvoiceTable component receives that array and renders it as an accessible HTML table with proper scope attributes on every column header and an aria-label on the table itself. Amounts arrive in pence from Stripe. The formatCurrency function uses Intl.NumberFormat with the invoice's actual currency code so a GBP payment shows a pound sign and a USD payment shows a dollar sign. No hardcoded currency symbols, no manual conversion. The status column uses the same colour language as the rest of the portal — emerald for paid, orange for pending, red for failed. Each status maps to a badge class and a display label through two Record objects. Adding a new status from Stripe means adding one entry to each record. The receipt column links directly to Stripe's hosted receipt page. Each link has an aria-label that includes the invoice description so a screen reader user knows which receipt they are opening. On mobile screens below 640 pixels, the date column hides entirely using sm:table-cell. The table fits on a phone without horizontal scrolling because the least critical column disappears first. One component, one Stripe API call, one Intl formatter, and every client sees their full payment history without me manually entering a single invoice.
Every note card adapts to the theme without a single conditional className
The NoteCard component renders each blog entry on the notes page and the homepage feed. It reads the current theme through the useTheme hook — the same mutation observer that watches the html element's data-theme attribute — and passes it to a single function called pickColor. That function takes a colour pair object with a dark value and a light value and returns the right one based on the theme. The type badge at the top of each card — thought, update, building, or tip — gets its text colour from noteTypeColorPairs. The tag pills at the bottom get theirs from tagColorPairs. Both are plain objects mapping a key to a dark and light hex value. Purple for software. Fuchsia for accessibility. Cyan for audio. Emerald for production. The badge background is the text colour with 20 in hex appended for twelve percent opacity. The tag pill background uses 15 in hex for eight percent opacity. That subtle difference gives badges more visual weight than tags without maintaining two separate colour systems. The card itself is a Next.js Link wrapped in a glass container with a blurred border. On hover, the border tints toward the service accent using a fifty percent opacity modifier, and the title text shifts from white to the accent colour. The body text is line-clamped to three lines so every card in the grid has a consistent height regardless of how long the note is. One hook, one function, two colour maps, and every accent on every card adapts to both themes automatically.
The breadcrumb dropdown knows every service and every colour
Every service page has a navigation bar at the top. It looks like a breadcrumb — my logo on the left, a slash, then the current service name. The service name is a button. Tap it and a dropdown appears listing all six sections — Software, Accessibility, Audio, Production, Musician, and Notes. Each item in the dropdown has its own icon rendered in that service's accent colour. The active service gets a tinted background using color-mix to blend twelve percent of the accent into transparent. The component is called ServiceNav. It takes the current service name as a prop, finds the matching entry in a navLinks array, and extracts the accent colour based on the current theme. Dark mode gets the -400 shade. Light mode gets the -600 or -700 shade. The dropdown button has aria-expanded and aria-haspopup attributes so assistive technology announces whether the menu is open or closed. A full-viewport invisible overlay behind the dropdown closes the menu when you tap outside it. The overlay is aria-hidden because it is decorative — screen readers close the menu through the button. On desktop, two action buttons appear to the right — Call me about a project and E-mail your dream. Both link to anchor sections in the sidebar accordion using hash fragments. Both buttons are styled with the current service accent — border, text, and a ten-percent tinted background for the call button, solid fill for the email button. Both hit the 44-pixel minimum touch target. The ServiceNav does not know which service it is rendering. It reads a prop and picks a colour. One component, six services, six colour pairs, two action buttons, and a dropdown that closes on outside click and keyboard escape.
The footer is a sitemap you can click
On desktop screens, the footer has two layers. The first is a five-column navigation grid that lists every product across every service. Software Engineering on the left, then Accessibility, Sound Engineering, Music Production, and Musician. Under each heading, every package is a link straight to its checkout page. A client scanning the footer can jump from the homepage to a specific package without navigating through the service page first. Search engines can too. Every checkout URL is discoverable from the footer of every page on the site. The grid is hidden on mobile because twenty-two product links stacked vertically would push the actual footer off the screen. On small screens, you get the second layer only — company information, legal links, and a WCAG 2.1 AA compliance badge. The legal links to Terms and Privacy have 44-pixel minimum touch targets through inline-flex with min-height. The company details include the registered office, company number, and a copyright range that auto-updates using new Date().getFullYear() so it never goes stale. Three brand logos sit in a row — Blind Drummer, the initials mark, and the full logo — each with hover opacity transitions. On desktop, logos sit to the right and info sits to the left. On mobile, the order reverses using Tailwind order utilities so logos appear first. The entire footer is a nav landmark for the package grid and a footer landmark for the company info. Two semantic regions, one component, twenty-two deep links to checkout pages.
Every note gets its own page and its own metadata
Each note in this section has its own URL. Tap the card and you land on a standalone page at /notes/the-note-id. The page is statically generated at build time using generateStaticParams — Next.js reads every entry in the notes array and pre-renders a page for each one. No server-side rendering on request, no loading spinner, no API call. The HTML exists before anyone visits. Each page also gets its own metadata through generateMetadata. The title becomes the note title plus a suffix. The description is the first 160 characters of the body text. OpenGraph and Twitter cards are generated automatically so sharing a note on social media shows a proper preview with the correct title and excerpt. The note page itself is a narrow single-column article with a back link to the full notes list, a type badge showing whether it is a thought, update, building, or tip entry, a timestamp formatted for the UK, tag pills colour-coded by service area, and the full body text at a comfortable reading width. The service accent for the notes section is pink — set as a CSS custom property on the root div so the ServiceNav breadcrumb, the back link, and any accent elements all inherit the same colour without hardcoding it. At the bottom of every note page there is a placeholder for comments — sign in with TikTok to leave a comment, coming soon. The comment system is not built yet but the space is already reserved in the layout. When it ships, it will slot in without touching any of the surrounding markup. One TypeScript object per note, one static page per note, one metadata function per note, zero runtime database queries.
Dark mode without a flash of the wrong theme
The site supports dark and light themes. The toggle is a 44px button fixed to the top right of every page. Click it and the entire site flips — backgrounds, text, accents, glass borders, everything. The trick is making it instant. The ThemeToggle component sets a data-theme attribute on the html element and saves the choice to localStorage. Every colour on the site is a CSS custom property that changes based on that attribute. But if React handled the initial theme, you would see a flash of the default dark theme before the saved preference loaded. So there is a six-line inline script in the document head that reads localStorage and sets data-theme before a single pixel renders. No flash. No hydration mismatch. The theme is correct from the first frame. The other half of the system is the colour pairs. Every accent colour on the site has a dark variant and a light variant. Dark values are the -400 Tailwind shades — vibrant on dark backgrounds. Light values are -600 and -700 shades that pass WCAG AA 4.5:1 contrast on white. A single pickColor function takes a colour pair and the current theme and returns the right shade. The MobileNav uses it for icon colours. The NoteTypeBadge uses it for category labels. The NoteTagPills use it for tag backgrounds. One function, one theme state, and every accent on the site adapts. The useTheme hook watches for changes using a MutationObserver on the html element's attributes. When the ThemeToggle flips the data-theme attribute, the observer fires, and every component that reads the theme re-renders with the correct colours. No context provider, no prop drilling. Just a DOM attribute and a mutation observer.
The photo gallery reveals images one at a time
Every service page has a photo gallery at the bottom. The first image spans the full width of the grid. The rest sit two per row underneath. Each image is wrapped in a BlurReveal component with a staggered delay — the base delay plus the image index times one hundred milliseconds. The first image appears immediately. The second image fades in a tenth of a second later. The third a tenth after that. The whole gallery cascades from left to right, top to bottom, like cards being dealt. The gallery component takes three props — a photos array, an accent colour, and an optional base delay. It does not know which service page it is rendering for. The accent colour comes from the page above and controls a gradient overlay at the bottom of each image — a subtle tint that ties the photos to the service's colour identity. Cyan for audio. Emerald for production. Purple for software. The overlay is a pointer-events-none div with a linear gradient that goes from the accent colour at fifteen percent opacity to transparent. It sits on top of the image but lets clicks through. On hover, the image scales up five percent over half a second. The zoom is CSS-only — a transition on transform with a group-hover trigger. No JavaScript animation, no intersection observer, no scroll handler. Next.js Image handles the optimisation. The first photo renders at 800 pixels wide. The rest render at 400. AVIF and WebP conversion happens at build time. The entire gallery is a responsive CSS grid, a staggered reveal, a gradient overlay, and a hover zoom. About fifty lines of code for a component that appears on every service page.
The mobile menu knows which colour belongs to which service
On screens below 640 pixels, a hamburger button appears in the top left corner. Tap it and a navigation panel drops down with links to all six sections — Software, Accessibility, Audio, Production, Musician, and Notes. Each link has an icon next to it. Each icon is rendered in that service's accent colour. Purple for software. Fuchsia for accessibility. Cyan for audio. Emerald for production. Orange for musician. Pink for notes. The colours are not hardcoded once — each nav item stores a dark variant and a light variant. The MobileNav component reads the current theme using the useTheme hook and picks the right shade. Switch to light mode and the icons shift from -400 to -600 and -700 shades so they still pass contrast requirements on the lighter background. The menu button is a fixed-position element with a z-index high enough to sit above everything on the page. It is exactly 44 by 44 pixels — the WCAG minimum touch target. The aria-label switches between Open menu and Close menu depending on state. The aria-expanded attribute tracks whether the panel is visible. When the menu opens, a semi-transparent backdrop covers the full viewport. Tapping the backdrop closes the menu. The backdrop is aria-hidden because it is purely decorative — screen readers interact with the menu through the button, not the overlay. Every link in the panel has a 44-pixel minimum hit area through vertical padding. Tapping any link closes the menu before navigation. The whole component is seventy-five lines of code, handles two themes, six colour pairs, and full keyboard and screen reader accessibility.
The sidebar opens to the section you asked for
Every service page has a sidebar with three sections — packages, book a call, and send a brief. The sidebar is a FormAccordion component that reads the URL hash on mount. If you land on /software#book-call, the booking section is already open. If you land on /accessibility#send-brief, the contact form is already open. No scroll-to animation, no flash of the wrong section. It just opens to where you wanted to go. The accordion listens for hashchange events too, so in-page links work the same way. The homepage service cards link to /audio#packages-sidebar. The call-to-action buttons link to /production#book-call. Every link can target a specific accordion section on any service page. Under the hood, the FormAccordion initialises its active state from window.location.hash. Three string comparisons — one for each section ID. A useEffect registers a hashchange listener so browser back-and-forward navigation updates the accordion without a page reload. Each section is wrapped in a BlurReveal with staggered delays so the panels animate in sequence when the page first loads. The collapsed panels are still interactive — they respond to click, Enter, and Space — because they set role button and tabIndex 0 when inactive. One accordion component handles deep-linking, keyboard access, and progressive disclosure for all five service pages.
Every section on the page reveals itself in order
The service pages do not load all at once. Each section fades in with a staggered delay — hero first, then content blocks, then the sidebar panels, then the photo gallery. The entire effect is a single component called BlurReveal. It takes a delay prop in milliseconds and wraps its children in a div that starts blurred and translucent. After the delay, a state flip triggers a CSS transition that removes the blur and brings opacity to full. The transition takes 500 milliseconds. The component is eleven lines of code. No intersection observer, no scroll tracking, no animation library. Just a setTimeout that flips a boolean. The FormAccordion uses it with delays of 0, 100, and 200 milliseconds so the three sidebar panels cascade downward. The PhotoGallery uses it with an incrementing delay per image — baseDelay plus index times 100 — so photos reveal left to right, top to bottom. The hero image on each service page gets a delay of 0 so it appears instantly. The testimonials section gets a longer delay so it arrives last. The whole reveal sequence is controlled by passing different delay numbers to the same component. No choreography config, no timeline object. Just a number on each section.
The intake forms validate when you leave, not while you type
Every service has an intake form — audio asks for genre and track count, software asks for budget and project type, accessibility asks for compliance target. All five forms share one component called ServiceIntakeForm. It takes a FieldConfig array and renders the right inputs for each service. Validation runs on blur, not on change. You type your email, tab to the next field, and if the address is invalid the error appears under the field you just left. No red borders while you are still typing. No error messages that flash and disappear as you correct a character. The error uses role alert so screen readers announce it immediately. Each field tracks whether it has been touched. Errors only display when both conditions are true — the field has been touched and the field has an error. When you hit submit, every field is marked as touched at once so any remaining errors surface together. A useRef flag prevents double submission the same way the booking form does — synchronous check before any async work starts. A honeypot field sits off-screen with aria-hidden and tabIndex -1 so bots fill it out but real users and assistive technology skip it entirely. If the honeypot has a value, the form silently returns without sending. No error, no feedback, nothing for the bot to learn from. Five services, five different field configurations, one form component, one validation pattern.
The filter is a sentence, not a form
The notes section on the homepage does not have a filter bar. It has a sentence. Check out everything in all topics. The two underlined words are native select elements styled to look like inline text. Pick thoughts from the first dropdown and the sentence reads Check out thoughts in all topics. Pick software from the second and it reads Check out thoughts in software. The selects use appearance-none to strip the browser chrome, a bottom border to hint that they are interactive, and bold pink text to stand out from the surrounding paragraph. The options render with a dark background so they are readable in the dropdown. Because they are real select elements, they work with keyboards, screen readers, and every browser without a single line of JavaScript for accessibility. The filter state is two useState hooks — activeType and activeTag. The filtered array is one chained conditional. The whole thing is seven lines of logic. No filter chips, no toggle buttons, no multi-select popovers. Just a sentence that you edit by choosing words from a dropdown. When more notes match than the homepage can show, a link at the bottom says View all 47 notes and carries the active filters into the URL query string so the notes page opens pre-filtered. One sentence, two selects, zero form labels.
The booking flow recovers when a slot gets taken
The book-a-call component is a four-step state machine — date, time, details, confirmed. Pick a day from the calendar, pick a time from the available slots, enter your name and email, confirm. If everything works, you land on a confirmation screen with a Google Meet link. But the interesting part is what happens when it does not work. Between selecting a slot and hitting confirm, someone else might book the same time. When the API returns a 409 conflict, the component does not show a generic error and give up. It sends you back to the time picker, re-fetches the availability for that date, and renders the updated slot list with the taken slot removed. You pick another time and try again. The whole recovery happens without leaving the component or losing your name and email. Underneath the conflict handling, a useRef flag prevents double submissions. The ref check runs synchronously before any async work starts, so two rapid clicks cannot both enter the fetch call. The state-based booking flag handles the visual feedback — disabling the button and showing Booking... — but the ref handles the actual guard. The finally block resets both the ref and the state so the form is always recoverable. One component, four steps, one ref, one conflict recovery path.
The checkout progress bar talks to screen readers
The checkout wizard has a four-step progress indicator at the top — Package, Details, Pay, Done. Sighted users see numbered circles connected by lines. The active step is filled with the service accent colour. Completed steps show a checkmark. Future steps are dimmed. Screen reader users get something different but equally clear. The component is a nav landmark with an aria-label of Checkout progress. Inside is an ordered list. Each step is a list item. The active step has aria-current set to step, which tells assistive technology exactly where you are in the sequence. A screen reader announces something like step 2 of 4, Details, current step. The connector lines between steps are marked aria-hidden because they are purely decorative. The checkmark SVGs on completed steps are inline and inherit the accent colour. The entire visual language — filled circles, dimmed circles, checkmarks, coloured lines — maps to a single CSS variable. A client checking out for a mixing package sees cyan progress. A client buying a software landing page sees purple. The component does not know which service it is rendering. It reads the accent from the page and adapts. Four steps, one ordered list, one aria-current attribute, one CSS variable. That is the entire accessible progress indicator.
The calendar checks availability before you click
The consultation picker does not wait for you to pick a date before checking whether I am free. As soon as the calendar renders, it starts hitting the availability API for every future weekday in the visible month — four dates at a time, in parallel batches. Dates blur until the response comes back. If a day has zero slots, it gets a strike-through and becomes unselectable. If a day has slots, the blur lifts and the button activates. By the time you have scanned the calendar, most dates are already resolved. The API behind it queries Google Calendar's freebusy endpoint for the requested day, generates every possible 45-minute slot in 15-minute increments between 9am and 5pm UK time, then filters out anything that overlaps an existing event. The overlap check is two comparisons — slotStart < busy.end && slotEnd > busy.start. That covers every case: partial overlaps, events that start mid-slot, events that contain the entire slot. Weekends are excluded at the component level — the grid is five columns, Monday to Friday. Past dates are excluded at the API level — the effective start time is either the beginning of business hours or right now, whichever is later. The whole thing uses a cancelled flag in the useEffect cleanup so switching months does not cause stale state updates. One calendar component, one API route, one freebusy query per date, and the client never has to pick a day only to find out I am booked solid.
The checkout survives 3D Secure redirects
When a client pays with a card that requires 3D Secure, Stripe redirects them to their bank's authentication page. That means the checkout wizard unmounts. The client leaves the site entirely, verifies with their bank, and comes back to a fresh page load. The wizard handles this by reading URL parameters on mount. If step=confirmation and redirect_status=succeeded appear in the query string, the wizard initialises directly into the confirmation step. No intermediate loading screen, no flash of the package selection, no re-render of the payment form. It clears the URL with history.replaceState so the params do not linger, then renders the full-screen confirmation overlay with the consultation booking picker ready to go. The same confirmation flow fires whether the payment succeeded inline or after a 3D Secure redirect. The idempotency key system sits underneath all of this. Every PaymentIntent is created with a UUID. If the client goes back and changes their name or email, the key regenerates so a new intent is created. If the details have not changed, the existing intent and client secret are reused. That means hitting the back button and then forward again does not create a duplicate charge. One wizard component handles the entire four-step flow — package review, detail collection, Stripe Elements payment, and post-payment confirmation with calendar booking — and it survives a full browser redirect without losing state.
Project progress is a gradient bar and a row of dots
The client portal shows project progress with two visual elements — a gradient progress bar and a row of coloured dots. The MilestoneTimeline component counts how many milestones are marked DONE, divides by the total, and renders a percentage-width bar with a cyan-to-emerald gradient. Below the bar, each milestone is a dot and a label. Emerald for done. Cyan with a glow shadow for the active milestone. Dim white for milestones that have not started. The same pattern carries through to deliverables. The DeliverableTimeline uses a vertical connector line with four status colours — dim white for pending, glowing cyan for in progress, orange for delivered, emerald for approved. Each deliverable shows its title, description, due date, and whether a file is attached. The ProjectCard on the dashboard wraps the MilestoneTimeline inside a link, so tapping a project shows at-a-glance progress before you even open it. The status badge in the corner uses the same colour language — cyan for active, orange for in review, emerald for completed, dim for on hold. Every status has a Record mapping it to a dot class, a badge class, and a label. Adding a new status means adding one entry to the record. The entire visual system for tracking project progress across all five service types is built from two timeline components, a few coloured dots, and one gradient bar.
Five intake forms, one configuration object
Every service on the site has a different intake form. Audio asks for genre, track count, and project type — recording, mixing, mastering, full production, or podcast. Software asks for budget range, company name, and whether you want an MVP, a web app, AI integration, or fractional CTO. Accessibility asks for a compliance target — WCAG 2.1 AA, WCAG 2.2 AAA, ADA, European Accessibility Act, or not sure. Musician asks which instrument you need and whether it is a session, a live gig, a recording, or a dep. Production asks for genre and creative direction. Five completely different sets of questions. One component renders all of them. The ServiceSidebar reads a serviceConfigs object that maps each service to its own typed field definitions, headings, descriptions, and success messages. The FormAccordion underneath does not know which service it is rendering. It just receives props and draws the accordion with three sections — packages, book a call, send a brief. Each field is a FieldConfig object with a name, label, type, placeholder, and optional list of select options. Adding a new field to any service means adding one object to an array. Adding a sixth service means adding one key to the config map. The sidebar, the form, and the validation all adapt because they read the config, not the service name.
Every comment lives on the thing it is about
The portal has one comment component that works on three different things — sessions, milestones, and goals. The CommentThread takes an entityType and an entityId. It does not care whether it is rendering under a mixing session or a software milestone or an accessibility audit goal. It renders the same thread, the same form, the same submit button. Comments from me show with the accent colour. Comments from the client show in the default text colour. Each comment has an avatar with the author's initials, a timestamp formatted for the UK, and the body text. The form uses React's useActionState hook with a server action — no fetch call, no JSON serialisation, no loading spinner beyond a three-dot indicator on the submit button. The form posts directly to the server. The input and the button both hit the 44px minimum touch target. The hidden fields carry the entity type and entity ID so the server knows where to attach the comment without any client-side state management. One component, three entity types, two visual styles, zero duplicated code. The same thread that tracks feedback on a mastered track also tracks discussion on a software sprint milestone.
Ten lines of middleware protect the entire portal
The authentication layer for the client portal is ten lines of code. One import for Clerk's middleware, one import for the route matcher. One line creates a matcher for everything under /portal. One function checks whether the current request matches that pattern and, if it does, calls auth.protect() to redirect unauthenticated users to sign-in. That is it. Every public page on the site — the homepage, the five service pages, the notes section, the checkout flow, the donation page — loads without any auth check. Every portal page — dashboard, projects, sessions, bookings, invoices, goals, settings, admin — requires a logged-in user. The line between public and protected is a single regex pattern. No per-route wrappers. No higher-order components. No auth checks scattered through page files. The middleware runs before any page renders, so an unauthenticated request never even reaches the React tree. When Clerk redirects you to sign-in and you complete the flow, the webhook has already synced your profile to the database. By the time the portal loads, your data is ready. Ten lines of middleware and a webhook. That is the entire auth story.
Five colours, one CSS variable
Every service page on the site has its own accent colour. Audio is cyan. Production is emerald. Musician is orange. Software is purple. Accessibility is pink. The entire colour system runs on a single CSS custom property — --service-accent — set as an inline style on the root div of each page. Every component underneath reads that variable. Headings, buttons, active states, card borders, progress indicators, form highlights — all of them pull from --service-accent. The checkout page reads the product's service type and sets the accent automatically, so a client buying a mixing package sees cyan through the whole flow and a client buying a landing page sees purple. No theme prop drilling, no context providers, no conditional className logic. One CSS variable at the top, and every child component adapts. The sidebar highlights the active product with an accent-coloured border using color-mix to generate a subtle tinted background. The wizard steps use the accent for progress. The form accordion uses it for the active section indicator. Adding a sixth service colour means changing one hex value in one config object. The components do not know or care which service they are rendering for. They just read the variable.
Signed URLs expire so files do not
The portal stores files in Cloudflare R2, but the database never holds a full URL. It holds a key — a path like uploads/1714000000-mastered-track.wav. When a client clicks download, the API generates a signed URL that expires in twenty-four hours. When an admin uploads a file, the API generates a signed upload URL that expires in one hour. The file lives in R2 indefinitely. The access to it is always temporary. This means there are no permanent public links to client files floating around in email threads or browser history. Every download is authenticated — the API checks whether the logged-in user is an admin or whether their client record matches the project the file belongs to. If neither, the request is rejected before a URL is ever generated. The upload flow is two steps. First, the admin requests a presigned upload URL. The client-side code uploads directly to R2 using that URL — the file never passes through the Next.js server. Second, the admin calls a completion endpoint that creates an Attachment record in the database linking the R2 key to the project. One file, one key, one record. The same pipeline handles audio stems, accessibility reports, software source code, and production session recordings.
Twenty-two products in one TypeScript file
Every service package on the site lives in a single TypeScript file. Twenty-two products across five services. Each one is a typed object with an id, a name, a description, a price in pence, a feature list, a delivery estimate, and a service category. Fixed-price packages store a real pricePence value. Quote-based packages store null and a label like Get a quote instead. A helper function called isPurchasable checks whether a product has a valid price — if it does, the checkout page renders a full Stripe payment flow. If it does not, the page shows a contact form instead. That one boolean check is the entire branching logic between a purchasable product and a quote request. Adding a new package to any service means adding one object to the array. The sidebar picks it up. The checkout page picks it up. The service page picks it up. No migration, no API call, no admin panel update. The TypeScript compiler checks every field at build time so a misspelled property name or a missing feature list fails the build before it ever deploys. Twenty-two products, five services, one file, zero runtime errors.
Ten models, four enums, zero spreadsheets
The entire service delivery platform runs on a single Prisma schema. Ten models — Client, Project, Milestone, Session, Goal, Comment, Booking, Attachment, Deliverable, ActionItem. Four enums — ServiceType, ProjectStatus, MilestoneStatus, DeliverableStatus. That is everything. A mixing project and a software build both use the same Project model. The only difference is the ServiceType field — AUDIO, PRODUCTION, MUSICIAN, SOFTWARE, or ACCESSIBILITY. Every project has milestones with status tracking, sessions with notes and attachments, deliverables with approval workflows, action items with assignments, and comments threaded on milestones, sessions, and goals. The schema is the service contract. It defines what a project looks like, what can be tracked, what gets notifications, and what the client sees in the portal. Adding a new service type means one line in an enum. The relationships, the queries, the admin panel, the client view — they all work automatically because they query by project, not by service type. The commit that introduced the schema was the same commit that shipped the portal. One migration, one deploy, one data model for the entire operation.
Deliverables track themselves from upload to approval
Before the portal, delivering finished work meant emailing a WeTransfer link and hoping the client downloaded it before it expired. Now deliverables are database records with a four-stage status — PENDING, IN_PROGRESS, DELIVERED, APPROVED. I upload a mastered track or an audit report through the admin panel. The file goes to Cloudflare R2. A deliverable record is created in the database, linked to the project and the attachment. The client gets an email notification. They log into the portal, see the deliverable with its description and status, and download it. When they are happy, the status moves to APPROVED. If revisions are needed, the status stays at DELIVERED and we discuss it in the comment thread. No shared drives. No expired links. No more checking spam for a file I sent last Tuesday. Every deliverable has a paper trail — when it was created, when it was uploaded, when it was delivered, when it was approved. The same workflow handles audio stems, software source code, accessibility reports, and production session recordings. One pipeline for every service type.
The deployment pipeline is the publish button
There is no publish button for these notes. No save-draft state, no preview mode, no scheduling. I write a TypeScript object, add it to an array, commit, and push. GitHub Actions runs lint, typecheck, and 41 tests. If everything passes, the site deploys. The note is live. That is the entire publishing workflow. It works because the content and the code are the same thing. The notes array is type-checked — if I misspell a field name or forget a required property, the build fails before anything deploys. If a note has a malformed date string, TypeScript catches it. The CI pipeline that validates the checkout flow also validates the blog. Every note in this section went through the same pipeline as every feature commit. Same branch, same tests, same deploy. The friction is so low that writing a note takes less time than opening a CMS. That is not an accident. The whole system was designed so that documenting the work is part of the work, not something separate that happens after.
One admin panel runs five different services
The admin dashboard manages audio mixing projects, music production sessions, session musician bookings, software builds, and accessibility audits from a single interface. Every project has the same shape — milestones, sessions, deliverables, comments, action items — regardless of whether the deliverable is a mastered track or a WCAG remediation report. The service type is a field on the project, not a separate system. That means the same admin workflow applies everywhere. I open the dashboard, see every active project across all five services, check which deliverables are pending, review which action items are overdue, and update session notes. One screen. One mental model. The alternative was five different tools — a DAW project tracker for audio, a Trello board for software, a spreadsheet for accessibility audits, a calendar app for musician bookings, a folder of production stems somewhere on a hard drive. Now it is all in the portal. The commit history shows each service type being added as a single enum value, not a new feature branch. That is the whole point of the architecture. A new service type is a database migration, not a rewrite.
Every automation replaces a conversation
Before the webhook chain existed, every service interaction required at least three emails. Client pays, I send a confirmation. Client wants to book a call, I check my calendar and suggest times. Client needs a progress update, I write one from memory. Now the confirmation sends itself. The booking checks my calendar and offers real slots. The progress lives in the portal where the client can see it any time. Each of these automations replaced a conversation I used to have manually. Not because the conversation was bad — because it was the same conversation every time. The webhook fires, the email sends, the calendar updates, the portal reflects the change. The client gets a faster response than I could ever type. I get time back to do the actual work — mixing a track, writing code, running an audit. The git log from the past week shows these automations landing one at a time. Stripe webhook first, then Clerk sync, then calendar cron, then portal notifications. Each one removed one more manual step from the service delivery pipeline. The system now handles the administrative side of running five services so I can focus on the craft side.
Thirty notes in thirty days
This notes section started as a way to document the build. One note per feature, one paragraph per decision. Thirty days later there are over thirty entries covering everything from Stripe checkout flows to screen reader testing to the cron job that syncs Google Calendar. Each note is a TypeScript object in an array. No CMS, no database, no draft state. Write it, commit it, deploy it. The notes have become the changelog for the entire platform. When I look back at the sequence — first the landing page, then service pages, then checkout, then the portal, then admin, then webhooks, then notifications — it reads like a product being built in public. Every architectural decision is documented in the week it was made, not reconstructed months later for a blog post. The format works because it is low friction. No title image, no SEO keyword research, no publish button. Just a paragraph and a commit message. That is enough to show the work.
Webhooks do the work so I do not have to
When a client buys a mixing package, I do not manually send a confirmation email. I do not open Google Calendar and create an event. I do not update a spreadsheet. A Stripe webhook fires on payment_intent.succeeded, verifies the signature, checks idempotency metadata so duplicate deliveries cannot happen, sends the client a confirmation email through Resend, sends me a notification with a link to the Stripe dashboard, and the confirmation page offers instant calendar booking. When someone signs up for the portal, a Clerk webhook fires, verifies via Svix, and upserts their profile into the database before the redirect even finishes. If a client record already exists from an admin-created project, the webhook links the Clerk account to the existing record instead of creating a duplicate. Two webhook endpoints. Two external services doing the triggering. Zero manual steps between a client action and the system responding. The git log shows these webhooks going in early because they had to. Every feature that came after — notifications, portal personalisation, booking sync — depends on the data these webhooks put in place.
Five notification types, one pattern
The portal sends five different kinds of email notification. Session notes added. Milestone status changed. New comment posted. Deliverable uploaded. Action item assigned. Each one is a single function that takes the client email, the project name, and the relevant detail, then fires a Resend API call. No queue, no background worker, no retry logic. Fire and forget from the route handler. If Resend is down the notification silently fails and the portal still works — the email is a courtesy, not a dependency. Every notification links back to the portal so the client can see the full context. Every one uses the same from address, the same reply-to, the same tone. The pattern is so consistent that adding a sixth notification type would take ten minutes. That is what happens when you extract the pattern after building five real examples instead of designing an abstraction before building any.
A cron job keeps ninety days of bookings in sync
There is a cron endpoint that pulls my Google Calendar, matches attendees to client emails in the database, and upserts booking records. Ninety days back, thirty days ahead. It runs on a schedule, authenticated with a bearer token so nobody else can trigger it. Every calendar event with a client attendee becomes a booking row with the title, start time, end time, and Google Meet link extracted automatically. If the event already exists it updates. If it is new it creates. The client sees their upcoming and past bookings in the portal without me ever manually entering one. The sync also supports GET for health checks so I can hit it in a browser to verify it is working. One endpoint, one function, one source of truth. Google Calendar is where I manage my schedule. The portal is where clients see theirs. The cron job is the bridge.
Authentication that stays out of the way
Clerk handles all the authentication for the client portal. Sign up, sign in, session management, protected routes — the whole thing. I did not build a custom auth system because building auth is how you introduce security bugs. The signup flow takes about thirty seconds. Once you are in, you see your projects, your bookings, your invoices, your deliverables. The portal knows who you are and shows you only your stuff. No shared dashboards, no magic links that expire after an hour. Just a proper auth layer that remembers you and gets out of the way. The commit history shows the auth integration going in alongside the portal features — not as an afterthought, not bolted on after launch. Authentication was part of the first portal commit because it has to be. A client portal without auth is just a public page. Clerk also syncs to the database through webhooks, so when someone signs up their profile exists in Prisma before they even finish the redirect. That means the portal can show personalised content from the first page load.
Email runs through everything
Resend handles every email the site sends. Payment confirmations after Stripe checkout. Welcome emails when someone joins the newsletter. Notification emails when I update a project, add a deliverable, post a comment, or assign an action item. Each one is a single API call from a Next.js route handler — no email service dashboard, no template builder, no drag-and-drop editor. The templates are React components. The same TypeScript that renders the site renders the emails. The git log shows email touchpoints scattered across a dozen commits because email is not a feature — it is infrastructure. Every service interaction eventually produces an email. The client buys a mixing package and gets a confirmation. I upload stems and they get a notification. The newsletter subscriber gets a welcome. One provider, one API, one pattern. Resend and a single import statement.
Every feature is a TypeScript object before it is a UI component
Before I build any UI, the data exists as a typed object in a TypeScript file. Products are objects in an array. Services are objects in an array. Testimonials are objects in an array. These notes are objects in an array. The shape is defined by an interface, the compiler checks every field, and the component just maps over the data. Adding a new product to the software page means adding twelve lines to a data file. No migration, no API call, no CMS publish step. The entire content layer deploys with the code because it is the code. This pattern shows up in every commit that adds content — three new notes means three new objects pushed to the front of an array. A new service package means one object added to the products file. The UI already knows how to render it. The checkout already knows how to charge for it. The architecture does the work so I can focus on writing the content, not wiring up the plumbing.
One week of privacy-first analytics
Plausible has been running for a week now. No cookies, no tracking scripts, no consent banners — just a 1KB script that tells me what pages people visit and where they come from. After seven days of real traffic data I can see which service pages get the most attention, which notes people actually read, and how long they stay. The whole analytics dashboard fits on one screen. No funnel analysis, no cohort segmentation, no heatmaps. Just the numbers that matter: visitors, page views, referrers, devices. Half the traffic is mobile, which validates every hour I spent on the mobile-first rebuild. Screen size distribution confirms the touch target work was worth it. Bounce rate tells me whether the landing page is doing its job. This is what analytics should be — a simple answer to a simple question. Who visited, what did they look at, and did they stick around. Everything else is noise.
The git log is the case study
People ask for case studies and I point them at the commit history. Every feature on this site started as a commit message. Stripe checkout, Google Calendar sync, client portal, admin dashboard, accessibility audit, newsletter, file uploads — each one has a SHA, a timestamp, and a description of exactly what changed. No after-the-fact narrative, no polished retelling. Just a sequence of decisions recorded in real time. The git log for jamesmusic.uk now has over thirty commits spanning three weeks. It reads like a product roadmap that someone actually executed. That is the case study. Not a PDF with before-and-after screenshots. A live repo you can clone, inspect, and verify. Every architectural decision, every bug fix, every integration — documented in the same place the code lives. When a potential client asks what my development process looks like, I do not open a slide deck. I open the changelog.
A platform, not just a portfolio
This started as a portfolio site. Show some work, list some skills, add a contact form. Three weeks later it is a platform. Clients can browse five services, pick a package, pay through Stripe, book a consultation through Google Calendar, sign up for the portal, track their project, receive deliverables, and download files. All in one Next.js app. The portfolio is still here — you are reading it — but it is wrapped inside something that actually runs the business. Every service I offer has its own page, its own pricing, its own checkout flow. The musician page and the software page look different but share the same payment infrastructure. The sound engineering page and the accessibility page have different copy but the same booking system. That is the whole point. One platform for everything I do. Not five separate tools duct-taped together. One codebase, one deploy, one place to look when something needs fixing.
One payment flow for five completely different services
A client booking a mixing session and a client buying a software landing page go through the exact same four-step checkout. Same Stripe Elements form, same confirmation screen, same automatic consultation booking via Google Calendar. The only difference is the data — the product name, the price, the description. Everything else is shared. That was the whole point of the component architecture. The checkout wizard, the sidebar accordion, the product cards, the booking flow — they are all service-agnostic. Adding a new package to any service means adding one object to a TypeScript array. No new components, no new API routes, no new payment logic. The git log shows dozens of commits building this system. What it does not show is how little effort it takes to extend it now that the foundation is in place.
This site is the accessibility audit
I offer accessibility consulting as a service. The site you are reading is the portfolio. Every 44px touch target, every ARIA role, every keyboard trap handled correctly, every screen reader announcement — these are the same things I flag when I audit a client's site. I built jamesmusic.uk the way I tell other people to build theirs. Native HTML semantics over div soup. Focus management on every modal and dropdown. Colour contrast that passes WCAG AA. Font sizes that never drop below 12px. Line lengths constrained so text does not run edge to edge on tablets. If you want to know what an accessible site looks like, you are already on one. That is the pitch. Not a slide deck, not a case study. A working product you can tab through right now.
Three weeks of commits and the site keeps growing
Last week I wrote about two weeks of git log taking the site from a landing page to a full platform. One more week in and the changelog keeps filling up. Since then: documented the five-integration architecture powering Stripe, Google Calendar, Resend, Clerk, and Cloudflare R2. Added reliability engineering that nobody sees — idempotency keys, webhook deduplication, lazy database connections, notification guards. Wrote up the shared component system that lets five service pages run on zero duplicated code. Shipped keyboard navigation coverage across every interactive element. Moved the entire content layer to typed TypeScript arrays with version control instead of a CMS. Each of these was a commit, a build, a deploy. No sprint planning. No standups. Just ship, write about it, ship again. Claude Code and a clear picture of what the site needs to be.
Keyboard navigation is not optional
Every interactive element on this site can be reached and activated with a keyboard. Tab moves forward. Shift-Tab moves back. Enter and Space activate. Escape closes. Arrow keys navigate within groups. This is not a nice-to-have bolted on at the end. It was built into every component from the first commit. Buttons use native button elements. Links use native anchor tags. Dropdowns use proper ARIA roles and manage focus. The hamburger menu traps focus until closed. When you use native HTML semantics and manage focus correctly, keyboard navigation mostly comes for free. The bugs happen when developers reach for divs with onClick handlers instead of real interactive elements.
Five pages, one checkout, zero duplication
Each of the five service pages — audio, production, musician, software, accessibility — has its own URL, its own copy, and its own product listings. But the underlying components are shared. One service navigation bar handles switching between pages. One sidebar accordion handles packages, booking, and contact. One checkout wizard handles the four-step payment flow. One product card grid handles the pricing display. Adding a sixth service would mean creating one data file and one page component. Everything else is already built. This is what reusable components are supposed to do — not premature abstractions, but proven patterns extracted from five real pages that all needed the same thing.
Static content does not need a database
The notes on this site, all the product listings, and the testimonials are TypeScript arrays in the codebase. Not a CMS. Not database rows. Just typed data exported from .ts files. Every content change goes through version control, gets type-checked at build time, and deploys with the code. There is no admin panel for editing posts because I do not need one — I edit a file, commit, and push. The tradeoff is obvious: you lose a rich editor UI. What you gain is a content layer that is impossible to break at runtime, impossible to lose to a database outage, and trivial to roll back with git revert.
Five integrations, one Next.js codebase, zero microservices
The site now talks to five external services — Stripe for payments, Google Calendar for booking, Resend for emails, Clerk for authentication, and Cloudflare R2 for file storage. All of it runs inside one Next.js app. No microservices, no message queues, no separate backend. Each integration gets its own API route that validates input, calls the external service, and returns a response. Webhooks handle the async glue: Stripe fires on payment, which triggers a confirmation email through Resend and creates a calendar event. Clerk fires on signup, which syncs the user to the database. The whole thing deploys as a single unit. One repo, one build, one deploy. When something breaks, there is exactly one place to look.
The reliability work nobody sees
Spent time this week on the kind of work that never gets a feature page. Idempotency keys on every Stripe PaymentIntent so a network retry cannot create a duplicate charge. Webhook deduplication using Stripe event metadata so a retried webhook does not send a second confirmation email. A lazy Prisma client that only connects to the database at runtime, not at build time, so CI can build the app without a live database connection. Notification guards that check whether a client has actually signed up for the portal before emailing them. Nobody opens the site and notices any of this. That is the point. Reliability is invisible when it works.
Two Stripe flows for two different jobs
The site uses two different Stripe integration patterns. Service checkout uses PaymentIntents with Stripe Elements — the payment form is embedded directly on the page, no redirect, and the entire four-step flow stays on-site. The donation page uses Checkout Sessions — click a tier, get redirected to Stripe's hosted payment page, come back when done. Same provider, different approaches for different jobs. PaymentIntents make sense when you need full control over the UX and want to chain post-payment actions like booking a consultation. Checkout Sessions make sense when the flow is simple and you want Stripe to handle the entire page. Knowing when to use which saves a lot of overengineering.
Two weeks of git log: landing page to full platform
Went back through the commit history and counted it up. In two weeks, the site went from a basic landing page to five dedicated service pages, a four-step checkout with Stripe, a client portal with auth and project tracking, an admin dashboard with file uploads and deliverable management, automated calendar sync, email notifications, a newsletter, a donation page, a CI pipeline with 41 tests, a full SEO audit, Core Web Vitals optimisation, and WCAG accessibility fixes across every page. 30-plus commits. All built with Claude Code. The git log reads like a product roadmap that someone actually shipped.
Every package now auto-books a consultation
When someone buys a package, the confirmation page offers instant calendar booking. It hits an availability API that checks my Google Calendar for free 45-minute slots over the next 30 days, then renders a date picker with only the times I am actually free. Pick a slot, confirm, and the booking endpoint creates a Google Calendar event with a Meet link and sends both of us an email. The whole thing chains together: Stripe payment completes, webhook fires, confirmation page loads, calendar slots appear, booking is made. No back-and-forth emails needed.
Shipped a support page with tiered Stripe Checkout
Added a Buy Me a Coffee page to the site. Five preset tiers plus a custom amount field. Each one hits a single API route that creates a Stripe Checkout Session and redirects. No embedded payment form here — donations are simple enough that a redirect to Stripe hosted page makes more sense than embedding Elements. The tiers are just a data array, the component maps over them, and the API route validates the amount and creates the session. Custom amounts are capped because at some point you should probably just email me directly.
Built a four-step checkout with Stripe
Checkout is live on the site. Pick a package, enter your details, pay with Stripe Elements, and land on a full-screen confirmation. Behind the scenes, a webhook fires a confirmation email through Resend and books a 45-minute consultation via Google Calendar automatically. Used PaymentIntents instead of Checkout Sessions so the whole experience stays on-site with no redirects to a third-party page. Handles 3D Secure cleanly — reads the URL params on return and jumps straight to confirmation. Idempotency keys on every intent so duplicate charges cannot happen.
Every service now has its own page
Each of my five services — sound engineering, music production, session musician, software engineering, and accessibility consulting — now has a dedicated page with full descriptions, fixed-price packages, and integrated checkout or contact forms. A sticky sidebar lets visitors switch between packages, booking a call, or sending an email without scrolling back up. Built a shared service navigation component so people can jump between services without going back to the homepage. Five pages, one Next.js codebase, no CMS.
Redesigned the landing page from scratch
Ripped out the old single-page layout and rebuilt the homepage as a gateway to everything. New hero section with my profile photo, compact service cards linking to dedicated pages, and a notes feed at the bottom showing what I have been building. Added newsletter signup and a donation link. The old layout crammed everything onto one page. Now each service has room to breathe and the homepage just points you in the right direction.
Added social proof to every service page
Every service page now has a testimonials section. Built a reusable component with glass-card styling that pulls quotes filtered by service type. Each testimonial sits in a proper blockquote with attribution. It is the kind of thing that builds trust before someone even reaches the contact form. Next step is replacing the placeholder content with real client quotes.
Fixed every touch target on the site
Went through every interactive element on the site and made sure they all hit the 44x44px WCAG minimum. Scroll buttons, month navigation, dropdown items, footer links, back buttons — all of them were undersized. Also added a proper mobile hamburger menu with backdrop overlay, bumped the tag label font from 10px to 12px because that was below readable size, and constrained line lengths on service pages so text doesn't run edge to edge on tablets. Six accessibility bugs squashed in one pass.
Rebuilt the layout mobile-first
Reworked the entire site layout starting from mobile. Hero section stacks vertically on small screens with tighter spacing. Service cards show an inline icon and title on mobile instead of the full expanded card. Footer flipped to horizontal on desktop with info on the left and logos on the right. The about section got broader copy covering all five services. Half my traffic is mobile so this was overdue.
File uploads, deliverables, and a proper admin panel
Rebuilt the admin dashboard from the ground up. It now tracks every active project, open action item, pending deliverable, and upcoming booking in one place. Added Cloudflare R2 for file storage so I can upload session recordings, stems, and documents directly from the admin panel. Clients see their deliverables with status tracking and can download files through signed URLs. Drag-and-drop uploads on desktop, tap-to-upload on mobile. The whole admin experience is mobile-first now because half the time I'm managing projects from my phone.
Shaved 4.5MB off my page loads
Ran a Core Web Vitals audit on the site and the numbers were not great. Resized all hero images down from their original camera resolution to 1920px max width. Converted one 2.8MB PNG to JPEG at 375K. Enabled AVIF and WebP auto-conversion in Next.js. Added font display swap so text is visible immediately instead of waiting for the web font to load. Total savings: about 4.5MB stripped from page loads. Performance is a feature, especially for users on slower connections.
Privacy-friendly analytics and an SEO sweep
Switched to Plausible for analytics. No cookies, no tracking scripts phoning home, GDPR-compliant out of the box, and the whole script is about 1KB. Also did a full SEO metadata audit — added OpenGraph and Twitter cards to every service page, the notes section, and the donate page. Blocked the portal from search indexing since that is authenticated content. Updated the sitemap to include pages that were missing. Small things but they add up.
Newsletter signup is live
Added a newsletter signup to the homepage using Resend Audiences. It validates your email, checks a honeypot field to block bots, adds you to the audience, and sends a welcome email. If you are already subscribed it handles that gracefully instead of throwing an error. Simple, clean, no third-party newsletter platform needed. Just Resend and a single API route.
Automated calendar sync and client notifications
Wired up Google Calendar sync so bookings are pulled automatically — 90 days back and 30 days ahead. It matches calendar attendees to client emails and upserts booking records. On top of that, clients now get email notifications when I add session notes, update a milestone, post a comment, add a deliverable, or assign them an action item. All fire-and-forget through Resend so nothing blocks the admin workflow. Notifications only go out to clients who have actually signed up for the portal.
Building a client portal from scratch
I just shipped a full client portal for jamesmusic.uk. Clients can log in, see their projects, track milestones, read session notes, download invoices, and leave comments. Built with Clerk for auth, Prisma and PostgreSQL for data, all inside the same Next.js app. The whole thing went from design doc to deployed code in a single session with Claude Code. Zero to full portal in hours, not months.
Automated accessibility tests miss the hard stuff
I run accessibility audits for clients and the most common thing I hear is 'but it passed the automated scan.' Automated tools catch maybe 30% of real issues. They find missing alt text and low contrast. They miss broken keyboard navigation, confusing screen reader announcements, and focus traps. The stuff that actually makes or breaks the experience for disabled users. That is why I test with a real screen reader, real keyboard navigation, and real assistive technology. Every time.
Added tests and CI to the site
Finally set up a proper test suite. 41 unit tests covering the product catalog, checkout API (Stripe PaymentIntent creation), and contact form API (Resend email). GitHub Actions runs lint, typecheck, test, and build on every push. Caught 18 pre-existing lint errors on the first run. The safety net is real now.
I built this entire site with AI
This whole website was built using Claude Code — an AI coding agent. I described what I wanted, refined it step by step, and shipped it. As a blind developer, AI tools like this are a game-changer. I can focus on what I want to build, not fight with visual interfaces.
Turning my audio services into packages you can just buy
I've been working on turning my mixing, mastering, and production services into fixed-price packages. No awkward back-and-forth about pricing. You pick a package, you book it, you get your music back. Stripe integration coming soon so you can checkout right from the site.
Why I mix with my ears, not my eyes
Most engineers watch waveforms and meters. I can't. Every decision I make is based on listening. Turns out, that's exactly how your audience experiences the music too. No visual shortcuts, no cheating with my eyes. Just sound.
My TikTok AI assistant now talks back
I built an AI companion for my TikTok live streams. It reads the chat, understands context, generates responses with Claude, and speaks them aloud with ElevenLabs voice synthesis. It's like having a co-host that never gets tired. Currently working on giving it memory so it remembers regulars.
Still getting refused entry with my assistance dog
It's 2026 and businesses still don't know the law. Assistance dogs have a legal right of access to all public spaces in the UK. I get turned away at least once a month. That's why I consult on this — because the law exists but awareness doesn't.
Stop hiding things from screen readers
Quick tip: if you use display:none or visibility:hidden, screen readers can't see it either. If you want something visually hidden but still accessible, use the sr-only pattern. And please stop putting aria-hidden='true' on things that aren't decorative. My screen reader literally skips them.
Blind Drummer went from idea to live SaaS in 11 days
I shipped a full music education platform — student portals, mentor management, booking, Stripe payments, safeguarding compliance — in 11 days. 113 plans executed. People ask how. The answer: I think in systems, not screens. Being blind means I architect everything in my head before I write a line of code.
126 of 126 notes