
- Ref
- P-01
- Period
- 2026 -
- Status
- Live
- Role
- Build
A freelance build for a luxury event styling business, now their live site. It takes a visitor from browsing to a paid deposit: a configurator that turns interest into a costed brief, and an admin portal that lets the owner run quoting, deposits and the whole catalogue without me.
The problem
Bespoke styling has no price list, so every enquiry arrived as an open-ended Instagram message: no date, no guest count, no budget. Each one took a long back-and-forth to pin down, and none of it was recorded anywhere. The site had to do that qualifying up front without feeling like a checkout, and the owner had to change prices and seasonal ranges without a developer.
What I built
Around fifty routes across three surfaces: a public marketing site, a set of customer tools, and an admin portal. The centrepiece is a thirteen-step 'Build Your Event' wizard that prices selections as they are made, alongside a drag-and-drop backdrop designer on canvas and a postcode-driven delivery estimator. Customers get an account to track their enquiry and pay a deposit by card. The owner gets an enquiry workflow (quote, set deposit, accept, reject) and full CRUD over the entire catalogue.
Architecture
Next.js App Router, one deployable. The marketing pages are statically rendered and read the catalogue straight from Postgres, so an admin edit revalidates the pages it affects instead of waiting on a deploy. Row Level Security is the authorisation boundary rather than the route handlers. The wizard holds its selection on the client, autosaves a draft locally, and submits in one request that records the enquiry before it sends the owner's notification. Deposits run through Stripe Checkout, confirmed by a signature-verified webhook rather than the browser redirect. Photos live in object storage.
Stack
Decisions
- 01Priced the configurator, but never let it quote. A tool that asks thirteen questions and returns nothing reads as broken, so it shows a real itemised estimate. The database keeps that separate from what the owner later quotes: the binding number stays a human decision.
- 02Made Row Level Security the boundary, not the route guards. The admin flag lives in a profiles table, never in auth metadata, because metadata is user-editable and surfaces in the JWT: checking it would be self-serve privilege escalation. The per-route requireAdmin() checks are defence in depth.
- 03Moved the catalogue and all site content into the database behind the admin portal, so seasonal ranges need no deploy. The cost is that every admin write must revalidate the pages it touches, or an edit saves correctly and changes nothing visible.
- 04Write the enquiry to the database before sending the notification email. The provider had gone down before, and the portal reads from Postgres, so the row is the record and the email is only the alert. If the email fails after the row saved, the customer is still told it worked: telling them otherwise just makes them submit twice.
- 05Read the deposit amount from the database, never the request, and treat only a signature-verified webhook as proof of payment. Anyone who can name their own price can pay £1 for a £1,450 booking, and the success redirect is a URL anyone can visit.
- 06Link a guest's earlier enquiries to their account on email confirmation, not signup. Matching an unverified address would let anyone register with someone else's email and read their event date, phone number and quoted price.
Outcome
Live at opuluxemoments.co.uk as the business's own site, replacing the Instagram DM process. An enquiry now arrives fully specified: event type, date, guest count, venue, itemised decor, delivery distance, reference photos and an estimated total. Quoting, deposits and the catalogue are all run from the portal rather than by me.