Chalets in Blue Mountain
A direct booking site for two rental chalets, with date-based pricing rules, minimum stays, Stripe checkout and an owner dashboard.
Visit site- Role
- Sole developer from August 2025 to January 2026, turning a hotel-booking codebase into a chalet booking site: pricing, payments, email and the owner dashboard
- Stack
- React · Express · MongoDB · Stripe · Clerk · Vite · Tailwind CSS · Cloudinary · Nodemailer · Vercel
- Year
- 2025
Live, for the owner of two rental chalets at Blue Mountain in Ontario. I built it between August 2025 and January 2026. Since May 2026 the owner has made further changes, so the live site also includes work that is not mine.
Context
Two rental chalets, Katherine and Alexander, near Blue Mountain Village. The site had to let a guest pick dates, see what those exact nights cost, and book directly.
None of that is a fixed price. Rates change with the season and the day of the week, holiday weeks are charged at a flat nightly rate, every stay carries a cleaning fee, some check-in dates need a longer minimum stay, and the owner takes bookings offline that the calendar has to respect.
The repository's first commit, in August 2025, was already a working hotel-booking app: hotel and room models, Clerk sign-in, Stripe checkout, and leftover placeholder content from a starter template. At the end of September I converted it to chalets, and built the chalet-specific features below on top of it over the following months.
What I built
A direct booking site, with an owner dashboard behind it.
- Chalet pages with a photo carousel, a two-month availability calendar that shades nights that are booked or blocked, and date pickers that will not offer a night that is taken.
- A pricing engine that prices each night separately from rules stored on the chalet: recurring date ranges with a priority, weekday and weekend rates or a flat holiday rate, ranges that wrap across New Year, and a cleaning fee on top. The same function produces the public quote and the price that is charged.
- Minimum-stay rules: a default, plus rules for a specific date or a recurring range, ordered by priority. They are checked in the browser for feedback and again on the server.
- Booking and payment as separate steps, a flow already in the first commit that I reworked for the chalets. A booking is created unpaid and holds its dates; the guest pays later from My Bookings through Stripe Checkout, which I moved to Canadian dollars with accommodation and the cleaning fee as separate lines. At the client's request, promotion codes are switched off for any stay that includes a Friday or Saturday night.
- A scheduled job that deletes unpaid bookings older than 48 hours (configurable) and emails the guest and the admins.
- Transactional email: booking received, payment confirmed, cancellation, and one admin notification that covers four events. Four HTML templates, rendered by a small placeholder-replacement engine rather than a templating library.
- Guests can cancel their own unpaid bookings. There is no refund code, so a paid booking is refused with a message to contact support.
- An owner dashboard, built out from the one in the first commit: every booking with guest, dates, chalet, amount and paid status; adding and editing chalets with photo uploads to Cloudinary, where removing one photo also deletes it from Cloudinary and new ones are added to the rest; an on/off switch per chalet; and a Block Dates tool for offline bookings and maintenance.
- An admin role and a user management page, added in January 2026 after a manager with the owner role could see no data, because data was filtered to the chalets that account owned.
- Google Analytics pageview tracking on every route change.
Architecture
- Shape
- Two apps in one repository, deployed separately on Vercel: a React single-page client and an Express API. The server is routes, then controllers, then Mongoose models.
- Front end
- React 19 on Vite 6, with Tailwind 4, React Router 7 and react-datepicker.
- API and data
- Express 5 and MongoDB through Mongoose. The chalet document is the centre of the model: its pricing rules, minimum-stay rules and blocked dates are embedded in it, so pricing a stay needs only that one document.
- Server-side pricing
- The browser never sends a price. On booking, the server reloads the chalet, re-runs the overlap, blocked-date and minimum-stay checks, and prices the stay itself. Stripe line items are then built from the stored booking, not from anything the client sends.
- Auth
- Clerk on both sides, as in the first commit, with a signature-verified webhook copying users into MongoDB, where each user's role is stored. In November 2025 I rewrote the auth middleware to reject a signed-in user with no database record; before that it let them through with no user attached, which surfaced as "Failed to create booking".
- Payments
- Stripe Checkout Sessions, with the booking id carried in the session metadata. The webhook route is registered ahead of the JSON parser and receives the raw body, so the signature check sees exactly what Stripe sent.
- Email and jobs
- Nodemailer over SMTP to Brevo. The auto-cancel job is node-cron, running inside the API process.
- Pricing data
- Rules live in MongoDB and are written by seed scripts run by hand. The dashboard edits the base nightly price, which only applies to nights no rule covers.
The hardest part: what a date means
Two chalets and a calendar sounds like the easy end of booking software. What kept producing bugs was dates: when two stays overlap, and which day a date actually is.
Overlap first. The original checks were inclusive, so the system treated a checkout day as taken and a guest could not arrive on the day another left. The client raised it with a Friday-to-Monday example. In December 2025 I switched to half-open ranges: a stay holds the nights from its check-in day up to, but not including, its checkout day, and two stays clash only when each one starts before the other ends. That went into the booking query, the blocked-date check, the check between blocks, and the availability calendar.
The chalet page's date picker had its own copy of the rule, and I missed it. It kept the inclusive comparison until the owner aligned it in May 2026. Even now, by my reading of the code (I have not tested it in a browser), the picker cannot select a check-out day that is another booking's check-in day, because that day sits in the list of excluded days both pickers share, although the server would accept the stay. A rule written out in several places drifts, and this one did.
Then timezones. A booking date is a calendar date, but Mongoose stores "2026-02-20" as midnight UTC, which in Ontario is the previous evening. In November 2025 I made the pricing engine parse the date string itself and read only the UTC month, day and weekday, so a night's price depends on the date and nothing else. In January 2026 people viewing from India and Canada were still seeing different nightly prices for the same February weekend at one chalet. The fix was in the browser: the pickers now record the year, month and day the guest actually clicked, and hand the date back to the picker anchored at noon UTC, which is the same calendar day everywhere from UTC-12 to UTC+11.
It is not finished. Beyond UTC+11, New Zealand in summer for example, the calendar would shade the wrong day. A few other screens still format stored dates without fixing the timezone, which by my reading would show them a day early to anyone west of UTC. It is the overlap problem again: a fix applied in some places and not others. Even my seed script for the Valentine's 2026 minimum stays labels 11 February a Thursday. It was a Wednesday, and the script set no rule for the real Thursday.
The decision I would revisit first is how a booking is written. The server checks for overlapping bookings and then inserts, as two separate operations with no transaction and no unique constraint between them, so two requests for the same nights at the same moment could both succeed. Storing one record per chalet per night under a unique index would let the database refuse the second one, instead of trusting a read that may already be out of date.
How AI was used to build it
I used Claude Code during the build. The clearest trace in the repository is the Stripe setup guide I committed in November 2025, which starts by connecting Claude Code to the Stripe account through Stripe's MCP server, for development, debugging and testing. The promo-code guide also gives a way to create coupons through it.
None of my commits carry co-author trailers, so the history cannot show which lines were agent-written, and I have not guessed. Several commit messages read as an assistant's summary pasted in; one begins "Implemented the weekend promo code restriction. Here's what the code does".
The lesson I would pass on is about the documents that shipped with the features. Long guides went into the repository alongside the code, and a document like that reads as authoritative whether or not it is true. Some stopped being true: the date-blocking guide still shows the inclusive overlap check I replaced in December 2025. Some never were: the architecture document describes indexes on the bookings collection and a pending, confirmed, cancelled status workflow, and the code has never had either. A paid booking's status stays pending, the payment email tells the guest it is confirmed, and every cancellation deletes the record.
Outcome and current status
Live at chaletsinbluemountain.com. A custom domain first appears in the code on 3 November 2025, and checkout moved to Canadian dollars on 10 November. The repository records no launch date, so I have not given one. My last commit was in mid-January 2026: the date-specific minimum stays.
From May 2026 the changes have come from the owner's side. They moved the backend, database, image hosting and sign-in onto their own accounts, and added a phone number required at booking, a short-term rental licence number on the chalet page, a sign-in step that resumes an interrupted booking, a video tour and a promotional banner on the home page, the checkout-day fix to the date picker, and, in September 2026, new seasonal rates in the pricing seed script.
Two other May 2026 changes are fixes for running on serverless functions, and one of them reverses a decision of mine. I had moved booking emails to run after the response was sent, because sending them inside the request was holding the response up. On Vercel a function is frozen once its response goes out, so the second email, the admin notification, was being cut off. The emails are now awaited before responding. The other fix replaced a database connection opened once at start-up, whose queries timed out on cold starts, with a cached connection awaited on each request.
What I would still change, beyond the booking write above:
- A real booking lifecycle. In practice a booking is only ever unpaid, or paid through Stripe: nothing moves it to confirmed or cancelled, and there is no way to record a payment taken outside Stripe.
- Run the auto-cancel job on the platform's scheduler. It is node-cron inside a serverless function, with no Vercel cron configured, so it can only run while an instance happens to be alive, which probably means not every hour. I have not seen the logs that would confirm it either way.
I have quoted no booking numbers or revenue. The dashboard's revenue total counts unpaid bookings as well as paid ones, so it is not a figure I would quote.
Screenshots
Screens from the live site in September 2026, so they include the owner's later changes. The License Number field on the Add Resort form is one of them.