John Aleman
All projects

Birthday Card

A digital birthday card that friends and family sign together. Each heart holds a message.

Live
Birthday card for Olivia covered in colourful hearts, one open to show a birthday message in a thought bubble
The problem
A group card should be easy for anyone to sign, and impossible to sign as someone else.
Who it’s for
Friends and family signing from a shared link, and each signer managing their own messages afterwards.
What I built
A public card of hearts with messages, email confirmation through a magic link, a manage view scoped to the signer, and invitations.
Key decision
A signature stays off the card until the address that wrote it is confirmed through a one-time link.

The problem

Back to contents

A shared card works only if signing is effortless, so no accounts and no passwords. But a public form that publishes instantly lets anyone write under anyone’s name and turns the card into a list of scrapeable email addresses.

Product experience

What the product does for the people using it, and why it works that way.

Back to contents
  1. One message cloud, moved to the active heart

    Hover or tap a heart to read its message. A single cloud is moved to whichever heart is active, so only one can be open at a time, and it can be kept inside the card’s edges instead of hanging off the side.

  2. Touch and mouse are handled as different inputs

    A tap fires both focus and click, which would open and immediately close the cloud. The card picks one interaction model based on whether the device can hover.

  3. A playful layout that never jiggles

    Hearts are scattered with a seeded random number generator keyed on each signature, so the server and client agree and hovering never reshuffles the field. Taps hit a 44 px target while the heart reads at about 28 px. All motion is disabled under reduced motion with CSS alone.

  4. Confirmation that brings you back to your heart

    Opening the emailed link lands the signer on the card, with their heart now visible and a toast saying so. The toast cannot replay on refresh.

Engineering decisions

Each one as the decision, the reasoning, the trade-off accepted and the outcome.

Back to contents

Two signature types, split by who is asking

Decision
The public query returns a signature type with no email field. The type that carries the address appears only behind a valid session.
Why
Returning addresses on a publicly readable query would turn the card into a mailing list for anyone with devtools.
Trade-off
Two GraphQL types and mappers for what is one database row.
Outcome
Leaking an address would need a schema change, not just a forgotten filter.

Scope decided on the server, on every call

Decision
Every manage operation resolves the session from the request’s cookies, and user-scoped mutations re-assert the email in the SQL WHERE clause.
Why
The client should never say who it is through an argument.
Trade-off
Every query repeats the check, rather than trusting one done earlier in the request.
Outcome
An admin uses the same UI with a different cookie. “Not found” and “belongs to someone else” look identical, so IDs cannot be probed.

Confirmation scoped to the address, and idempotent

Decision
Redeeming a link confirms every pending heart on that address, and retrying it changes nothing.
Why
Whoever opens the link controls the address. Binding a link to one signature would add a foreign key from the shared access tables into this app and buy nothing.
Trade-off
Someone who signs twice before confirming confirms both hearts with one link. That is the intended behaviour.
Outcome
The access layer stays shared across apps, and a double-click on an email link is harmless.

Outcomes and lessons

Back to contents

Shipped

  • Public card, magic-link confirmation, a manage view with optimistic edits and deletes, invitations, and an admin path.

Validated

  • A component test asserts that two independent renders of the heart field are byte-identical. End-to-end tests cover the invitation flow.
  • Next.js
  • GraphQL + Drizzle/Postgres
  • TanStack Query