Crystal Ski Rental
Online booking for a ski rental with four stores, a staff front desk and an admin, built and documented the way a client project would be.







Built like client work
Most portfolio projects are todo apps. I wanted one that behaves like a real engagement, for a (fictional) ski rental with four stores in Slovak resorts, and that means the documents come first and the code answers to them.
Brief and requirements
Functional requirements and business rules with IDs (
RequirementsBR-20: no ski on two rentals at once), so code, tests and seed data can point at the rule they enforce.A plan
Stack, architecture decisions, the data model and a commit sequence: foundation, domain and API, screens, quality, deployment.
PlanReviewable commits
Around ninety small commits, each one coherent on its own, so the history reads like a project a team could review.
HistoryAn honest gap list
What a rental taking real customers would still need, from launch blockers (GDPR, payments) to later nice-to-haves, with the decisions only the business can make.
Production readiness
Three roles, one system
Customers
Find, book, rate
Search only the skis that are free for your dates, filtered by brand, type, level, length, price and rating. Book several pairs at once with the length-of-rental discount already in the price, cancel, and rate both the rental and the ski model afterwards.
Store staff
Run the front desk
Today’s pickups and returns, no-shows, anything overdue and a lookup by reservation code. Staff also manage their store’s fleet and customer accounts, and a manager can only change skis in their own store.
Admins
Own the catalogue
Brands, models, prices, stores and staff accounts. Anything with history is soft-deleted, so reservations keep pointing at it; anything still in use is refused, with a dialog saying what is in the way.
Decisions a reviewer would ask about
The calls that are not obvious from the screens, each written down with its reason in the repository, the way I would leave them for the next developer on a client project.
No double booking, guaranteed by the database
Checking for a clash and then inserting is two statements, and two customers booking the same pair at the same moment can both pass the check. So the rule lives in Postgres as an exclusion constraint over the ski and the date range, partial on items that still hold their dates. The API check only exists to explain the ordinary case; the constraint is what is actually true.
prisma/migrations/…_init/migration.sql ALTER TABLE "reservation_item" ADD CONSTRAINT "reservation_item_no_overlap" EXCLUDE USING gist ( "skiId" WITH =, -- '[)': start inclusive, end exclusive, matching the rest of the app. daterange("startDate", "endDate", '[)') WITH && ) WHERE ("holdsDates");Money is exact
Prices are
Decimal(10,2)in Postgres, strings in the API anddecimal.jsin arithmetic; a JavaScript number never holds a price. The search, the basket and the booking all quote through one function, so the price on the card is the price saved, and the reservation keeps a snapshot a later price change can never rewrite.src/lib/pricing.ts export const DISCOUNT_TIERS = [ { fromDays: 1, toDays: 3, percent: 0 }, { fromDays: 4, toDays: 6, percent: 10 }, { fromDays: 7, toDays: 10, percent: 15 }, { fromDays: 11, toDays: 19, percent: 20 }, { fromDays: 20, toDays: 30, percent: 25 }, ] as const;Rentals are whole days in UTC
A date picked in a calendar is local midnight, which
toISOStringturns into the previous day anywhere east of Greenwich. Dates travel asYYYY-MM-DDstrings, become UTC at the server, and ranges are half-open, which is what lets one rental end on the day the next begins.Roles are checked twice
Page guards redirect a signed-in user who is in the wrong area to their own, so a stale bookmark is never a dead end. But pages are not data: every API procedure carries its own role level, and sign-up cannot smuggle in a role.
One endpoint per file, over a service
Each tRPC endpoint says who may call it, what it accepts, and makes one call into a service. Services are wired in a dependency container and never read the session: whoever is acting is passed in, so authorisation stays next to the role check.
Rules in pure modules
The reservation lifecycle and the rating windows are plain functions with table-driven tests. The server enforces them and the screens import the same functions to decide which buttons to show, so a screen never offers an action the server would refuse.
View state lives in the URL
Filters, sorting, pages and tabs are query parameters, so any view can be reloaded, shared and bookmarked. The URL is untrusted input, parsed with the same Zod schema as the API, and a parameter that does not survive is dropped on its own.
Demo data that never goes stale
The seed is a committed snapshot, and nothing in it is dated: every date is an offset from the day of seeding, so the demo always has rentals in the past, today and the coming weeks.
It is also scripted. The demo customer has a rental whose rating is locked, one returned today whose rating is still editable, a family booking coming up and one cancelled, so every screen has something meaningful on it. Seed files refer to each other the way people do (stores by slug, models by "Brand Model", accounts by e-mail), a generator rewrites them the same way every time, and the seeder checks every rule the database cannot enforce before it writes a single row. Demo data can never be a history the app would have refused.
Safe to leave open to anyone
Reset every night
A GitHub Actions workflow migrates and reseeds the demo database at 03:00 UTC, and signs everyone out. The seeder refuses any remote database unless it is explicitly allowed.
Rate limits that every instance sees
Sign-in and sign-up attempts are counted in Postgres rather than in memory, so every serverless instance agrees. Only failures count, so someone who knows their password is never slowed down.
E-mail that goes nowhere by accident
Seeded accounts use reserved domains no server can deliver to, so their mail goes to a capture inbox instead of bouncing. Password resets are never captured: every seeded account shares one password, and the link would hand the account over.
Ready for search, kept out of it
Canonical URLs, Open Graph cards, a sitemap and structured data built from the stores’ addresses and hours, all switched to
noindex: the demo describes four stores that do not exist.A database per preview
Every Vercel preview deployment gets its own Neon database branch, so previews and their migrations never touch the demo’s data.
CI gates production
Lint, types, formatting, unit tests and a production build in one job, the Playwright suite against Postgres in another, and a deployment is only promoted once both pass.
Tested like production
Unit tests where the edges are
Vitest covers every discount tier and the rounding, UTC dates across time zones and a daylight-saving change, every lifecycle transition, the rating window at 59 and at 60 minutes, role rules, pagination and URL parsing.
End to end against a real database
Playwright runs the main flow of every role against a production build and its own database, reseeded before every run, because the overlap constraint, sessions and redirects only exist there. The suite refuses any database that is not local and named *_test, so it can never touch the demo.
- App
- Next.js 16 App Router
- React 19
- TypeScript
- next-intl
- UI
- Tailwind CSS 4
- shadcn/ui on Base UI
- React Hook Form
- API
- tRPC 11
- TanStack Query
- Zod 4
- Awilix
- Data
- Prisma 7
- PostgreSQL 17
- decimal.js
- Auth and mail
- Better Auth
- Nodemailer
- Resend
- Mailpit
- Quality
- Vitest
- Playwright
- ESLint
- Prettier
- GitHub Actions
- Hosting
- Vercel
- Neon
Where it stands
Works today
- Booking, cancelling and rating for customers, with the length-of-rental discount
- Front desk, fleet and accounts for staff; catalogue, stores and staff for admins
- E-mail confirmation and password reset
- Deployed on Vercel and Neon, reseeded every night
Next up
- A reworked front desk
- Photos for the seeded skis in object storage
- Demo payments with Stripe in test mode
