← Back to the board

How I built it

The board shows what I made. This page is for people who want to know how: what the hard parts were, which choices I made and what I would do differently.

Website with a custom shop

hellenkotte.nl

An author website where readers can pre-order a book, pay online and get it sent to their door. The author runs everything herself from an admin page.

  • Next.js
  • TypeScript
  • Tailwind CSS
  • Supabase
  • Mollie
  • Sendcloud
  • Resend
  • Vitest
Visit ↗

The problem

The shop sells one product: a book. A full shop platform would bring monthly costs, a theme to fight with and a lot of features nobody needs. So I built the ordering part myself, which meant I also had to get the parts right that a platform normally does for you: payments, order status, shipping labels and the bookkeeping export.

How an order works

  1. The order form posts to the server. The server works out the price itself from the quantity. The browser never sends an amount, so it cannot be tampered with.
  2. The order is saved as open and a Mollie payment is created with the order id in its metadata. The customer is sent to Mollie to pay.
  3. Mollie calls a webhook when the payment changes. That call only contains a payment id, and the server does not trust anything else in it. It asks Mollie for the real status and updates the order from that.
  4. The customer lands on the thank-you page, which shows the result.

The tricky parts

  • Webhooks arrive more than once. Mollie can call the webhook several times for the same payment. The handler only acts on the change from unpaid to paid, so the confirmation email goes out once, however often the webhook fires.
  • Webhooks can fail. If something goes wrong while handling one, the server answers with an error on purpose. Mollie then tries again later, instead of the order silently staying unpaid.
  • The customer can be faster than the webhook. Sometimes the customer is back on the site before Mollie has reported the payment. The thank-you page then checks the status every few seconds for a short while and stops as soon as the order has a final status.
  • No order ids in the URL. The thank-you page finds the order through an httpOnly cookie that expires after two hours. Order ids stay out of browser history, shared links and server logs.
  • A label must exist before an order counts as shipped. Shipping labels come from Sendcloud. If Sendcloud creates the shipment but returns no label, the order is not marked as shipped. Creating a label twice for the same order is blocked.

The admin page

The author can search and filter orders, correct a status by hand (including refunds), print PostNL labels one at a time or as a single PDF for a whole selection, and export everything as CSV for the bookkeeping. When a label is made, the customer gets an email with track and trace. A second list holds the sign-ups for the book launch.

Security

The site takes real payments and stores addresses, so I went through it as an attacker would. Forms and the admin login are rate limited, all input is cut to a maximum length, state-changing admin requests are checked for their origin, and every query passes values as parameters instead of building SQL from text. The site also sends the usual security headers.

Tests

The parts where a mistake costs money or data have unit tests in Vitest: the CSV export, the database query helper and the rate limiter.

What I would do differently

The site degrades on purpose: if the payment or database settings are missing, the form tells the visitor to send an email instead of crashing. That was the right call. What I would change is the order of the work. I added the security and reliability fixes in a review round after the features were done. Next time I would write the webhook and its failure cases first, because everything else depends on them.

Web app

rocdevspace.nl

A private platform for the ICT students of ROC van Twente: share ideas for the shared workspace, see events, show your projects and find each other.

  • Next.js
  • TypeScript
  • Tailwind CSS
  • Prisma
  • PostgreSQL
  • Auth.js
  • Zod

What it does

  • Ideas board: students post ideas for the space, vote and comment. Admins move an idea through statuses from open to done.
  • Events: admins post events, students sign up with one click.
  • Projects and people: a showcase of student projects and a directory with profiles, skills and a linked GitHub name.
  • TV mode: a full-screen page for the screen in the room, with upcoming events, world clocks and a globe showing where today's tech news comes from.

Only for students

Login runs through the school's Microsoft accounts with Auth.js. The sign-in callback rejects every address outside the school's student domain, so there is no separate registration and no passwords to store. All pages behind the login are protected in one place, in the proxy that runs before each request, instead of per page.

Rules that live in the database

One vote per student per idea, and one sign-up per student per event. Both are unique constraints in PostgreSQL and not only checks in the code. Two fast clicks, or two tabs, cannot create a double vote because the database refuses the second row.

Every form is handled by a server action that validates its input with a Zod schema before anything reaches Prisma.

Privacy

The users are students, so privacy was part of the design. On first login a student has to give consent before using the platform. In the settings they can download everything stored about them and delete their account, which removes their data.

The news globe

TV mode pulls news from several RSS feeds, Hacker News and The Guardian. Each source has its own limits, so each is cached for a different time: the open feeds refresh every minute, the Guardian less often to stay under its daily quota. Responses are validated before use, and a broken feed is skipped. If every source fails, the screen shows fallback items so it is never empty.

What I would do differently

The platform has no automated tests yet. For the voting and sign-up logic I leaned on the database constraints, which works, but the permission checks in the server actions deserve tests of their own. That is the first thing I would add.

Experiment

this board

My portfolio is a whiteboard you can pan, zoom and rearrange. It is written in plain TypeScript with no framework and no canvas library.

  • TypeScript
  • Vite
  • CSS
  • Pointer Events

One transform for everything

The board is one big div with every note placed in it by absolute position. The camera is three numbers: x, y and zoom z. Moving around the board changes a single CSS transform on that div, which the browser handles on the GPU. Nothing is redrawn and no layout runs while you pan.

world.style.transform = `translate(${x}px, ${y}px) scale(${z})`;

Zooming where you point

Zooming has to keep the point under your cursor, or between your fingers, in place. For a zoom from z to z' around screen point c, the camera moves by the same ratio:

k = z' / z
x = c.x - (c.x - x) * k
y = c.y - (c.y - y) * k

Pinch zoom uses the same function. The board tracks every active pointer, takes the distance between two fingers compared to the distance when the pinch began, and zooms around their midpoint.

Dragging at any zoom level

A drag in screen pixels is divided by the zoom level before it is applied, so a note follows your cursor exactly whether you are zoomed in or out. Mouse, touch and pen all go through Pointer Events, with pointer capture so a drag keeps working when the cursor leaves the note.

Two layouts from one content file

All text and projects live in one typed file. On wide screens the board is spread out. On a phone the same zones are stacked in a single column, so you mostly scroll in one direction. Positions you drag things to are saved per layout in your browser.

Usable without a mouse

A canvas-style page is easy to make unusable from the keyboard, so this one has arrow keys to pan, + and - to zoom and 0 for the overview. When you tab to a link that is off screen, the camera flies to that part of the board. Camera animations are skipped if your system asks for reduced motion, and without JavaScript the page still shows who I am and how to reach me.

What it is not

The red cursor with my name is a scripted guide, not me watching live. Notes you leave are stored in your own browser and nobody else sees them. I would rather say that here than pretend the board is multiplayer.

What I would do differently

Placing everything by hand gives the board its look, but it also means every new project needs the layout checked on both screen sizes. If the board grows much further I would let zones measure their own content instead of using fixed coordinates.

Questions about any of this? mail@lassevelthof.nl