M. MAUN STUDIO
Let's Work Together
All work
Case Study7 min read

Case Study: GreenSun Hotel


title: "Greensun Hotel — a quiet WordPress theme for a Makati hotel" slug: greensun-hotel client: "Greensun Hotel & Events Venue · Magallanes, Makati" role: "Design system port · WordPress theme development · Blocks & CPTs · Booking flow · Deploy pipeline" stack: ["WordPress 6.6+", "PHP 8.1", "Gutenberg blocks", "Tailwind CSS v3", "ACF", "Node + wp-scripts", "Lenis", "GitHub Actions"] year: 2026 status: "Shipped" cover: "/work/greensun/cover.jpg"

Greensun Hotel

A modern hotel and events venue in Magallanes / Makati — open since 2014 — needed a website that felt as quiet and unhurried as the property itself, while doing the heavy lifting of a working hotel: real-time room bookings, venue inquiries, an event calendar, and a gallery filterable by category.

I took a Claude-Design React prototype and ported it into a production WordPress theme: 19 server-rendered Gutenberg blocks, three custom post types with editorial detail pages, a multi-step booking wizard wired to the eZee Reservation API, and a CI deploy pipeline to CloudPanel.


The brief

The previous site was a stock template. The new direction came from a high-fidelity React prototype — a single-page app with editorial typography (Cormorant Garamond + Manrope + JetBrains Mono), a forest-and-sun palette, slow Lenis-driven scroll, Ken Burns image motion, and a design system tuned to feel like a hotel that respects your time.

The job was twofold:

  1. Translate every page of the prototype into a content-managed WordPress theme without losing the rhythm of the design.
  2. Productionise the booking experience — the prototype's wizard had to talk to eZee, the real reservation engine the hotel runs on.

Editors needed full control over every section without touching code, but the site needed to feel hand-crafted, not template-shaped.


Approach

I made three structural decisions early that paid for themselves the entire build.

Blocks, not page templates

Every section in the prototype became its own server-rendered Gutenberg block: hero-carousel, about-teaser, rooms-preview, amenities-grid, stay-smart, events-teaser, reviews, cta-section, page-hero, timeline, values-grid, team-grid, pull-quote, gallery-grid, venues-list, contact-channels, contact-form, contact-info — 19 in total. Each ships its own block.json, edit.js, render.php, and style.css.

Blocks are auto-discovered by scanning assets/js/blocks/ on init, so adding one is purely a folder operation — no functions.php edits. The home, About, and Contact pages are composed entirely of blocks; editors rearrange sections without ever leaving the editor.

Where a block needs to display a list of content (rooms, venues, events), it uses a CPT picker powered by @wordpress/core-data's useSelect, with a graceful fallback to "first N by menu order" when nothing is picked. Editors get content reuse for free.

Three CPTs, real permalinks

room, venue, and event are first-class post types with their own ACF field groups (synced to acf-json/ in git), single templates, and archive routes. Venues live at /venues/<slug>/ and don't need to live inside an Events page. The booking wizard's "Choose a room" step queries the Room CPT directly. ACF fields are the source of truth for price, capacity, layout, gallery — never block attributes.

One design system everywhere

CSS custom properties on :root drive the entire site:

--forest:   #2a5a4a;
--sun:      #ffd266;
--ivory:    #fbf7ec;
--display:  "Cormorant Garamond", serif;
--sans:     "Manrope", sans-serif;
--mono:     "JetBrains Mono", monospace;

Tokens were sourced from the prototype's tweaks panel, then derived ramps (forest-2, moss, sun-2, paper, bone) computed via the same shade() function the prototype used. A small set of component classes (.shell, .section, .eyebrow, .display, .btn--sun, .btn--ghost, .btn--light, .chip, .ph, .kb, .reveal, .linedot) reads identically across templates and blocks. The visual rhythm carries across every page a guest could land on — including the 404, search, and blog fallbacks.


Highlight features

The booking wizard

/booking is a four-step server-rendered shell (dates → choose room → your details → review & confirm), with a vanilla-JS controller (booking-flow.js) driving step transitions, validation, live totals (Intl.NumberFormat per-room currency), and the final POST. The forest-green summary card on the right updates in real time as the guest moves through the flow.

Architecture trade-off: the wizard is deep-link-friendly because the home-page booking-bar block can hand off checkin, checkout, guests, and room_type GET params. The wizard pre-populates from them and skips the user back into step 1 if they came via the bar. The server pre-encodes the full room list as a data-rooms JSON attribute, so the JS has zero extra fetches before submit.

Failure modes are designed in: if the eZee API is in mock mode, the final submit soft-fails into the confirmation screen with a generated GS-###### reference, so the entire flow works end-to-end before live credentials arrive.

The gallery

A spanning CSS grid (4 cols × 220px rows) with per-image colSpan / rowSpan of 1–2, category filter chips, hover-darken scrim, and a keyboard-navigable lightbox (←/→/Esc) that cycles through whatever's currently filtered. The block's inspector lets editors set each image's category, span, and lightbox caption inline — no separate gallery plugin.

The events teaser

Master/detail composition: a 540px image preview on the left, a list of clickable cards on the right where the active card flips forest/ivory. The interaction is 20 lines of JS hooked onto data-index attributes; the markup ships server-rendered for SEO and JS-off rendering.

The hero

Three crossfading slides with Ken Burns motion, an editorial vertical mark, an animated compass SVG that breathes, three floating leaf SVGs at varying drift speeds, and a scroll-hint line that pulses. Autoplay starts unconditionally and IntersectionObserver only pauses it when the carousel scrolls out of view.


Engineering details I'm proud of

  • No global JS bundle. Block JS (hero-carousel.js, booking-flow.js, gallery-grid.js, contact-form.js, etc.) is enqueued only when its block / template is on the page, via has_block() and is_page_template() checks. Pages without heavy interactions don't pay for them.

  • Above-the-fold CSS path. Header chrome, focus rings, the skip link, and the reveal-on-scroll base ship in critical.min.css enqueued ahead of the main stylesheet. Reveal animations are gated behind prefers-reduced-motion with a global animation-kill override.

  • Non-blocking fonts. Google Fonts loads via <link rel="preload" as="style" onload="this.rel='stylesheet'"> with a <noscript> fallback. display=swap keeps text readable in fallback fonts until the WOFF2s arrive.

  • Deferred scripts. Lenis smooth scroll and critical.js both use WordPress 6.3+'s strategy => 'defer' enqueue option, so the head parse isn't blocked. critical.js waits on DOMContentLoaded so the dependency order survives.

  • Responsive images. A greensun_post_thumbnail_html() helper wraps wp_get_attachment_image() so featured images everywhere render with full srcset + sizes + width + height + decoding="async". CLS lands near zero.

  • Accessibility built in, not bolted on. Skip-to-content link as the first focusable element. Consistent :focus-visible ring (forest on light surfaces, sun on dark). aria-current="page" on the active nav link, applied via a nav_menu_link_attributes filter. Every <main> carries id="site-main" + role="main" so the skip link lands on a proper landmark.

  • CI deploy. GitHub Actions builds the theme on every push to main and rsyncs a clean artifact (no node_modules, no source CSS, no dev configs) to the CloudPanel site path over SSH. Concurrency-guarded so two pushes don't race. Optional opcache-bust hook for production.


What shipped

  • 8 design pages covered: home, rooms list, room detail, booking wizard, about, events, gallery, contact — plus search, 404, single post, and a smart page template that auto-detects block content.
  • 19 Gutenberg blocks, all server-rendered, all content-managed.
  • 3 custom post types (rooms, venues, events) with ACF field groups in git.
  • 4 REST routes under /wp-json/greensun/v1/: booking search, booking create, booking status, contact intake — nonce + rate-limit protected.
  • eZee API client with mock and live modes, toggleable from an admin settings page.
  • CI deploy pipeline to CloudPanel via GitHub Actions.

The hotel's editors can now add a room, set its ACF fields, drag it into the rooms-preview block on the home page, and watch it show up on /rooms/, in the booking wizard, and on its own detail page — all without ever touching a template file.


Tech stack

  • WordPress 6.6+, PHP 8.1+, classic theme (not block-theme) hosting Gutenberg blocks
  • Tailwind CSS v3 with custom design tokens, scanned across PHP / JS / block render files
  • @wordpress/scripts for per-block builds; wp-scripts + a small Node orchestrator (scripts/build-blocks.js)
  • ACF Pro (bundled) for room/venue/event fields, with local JSON sync to git
  • Lenis for smooth scroll, gated behind prefers-reduced-motion
  • GitHub Actions + rsync for CloudPanel deploys

Sample work

  • /booking — 4-step wizard with live forest summary card
  • /rooms — alternating 1.2fr / 1fr rows with floating spec card
  • /venues — design's "Events" page treatment (image + capacity grid)
  • /gallery — filterable spanning grid + keyboard lightbox
  • /contact — stylized SVG map + paper-card form with success state
  • 404 — full-bleed forest hero with floating leaves and a rescue search field

Built solo over the course of an iterative collaboration. Design prototype by Claude (Anthropic's design model); production WordPress theme, blocks, CPTs, booking flow, and deploy pipeline by me.