Mobile-first guest check-in · Next.js 15 App Router

@happnest/webcheckin

The guest-facing online check-in app. A hotel guest opens a check-in link (optionally scoped with ?hotelId=…), walks a short multi-step wizard — reservation details, then traveller documents — and finishes on a confirmation screen. The chrome is the same off-white as client, but the accent and brand font are multi-tenant at runtime: every hotel paints over --hotel-primary so its guests see its colours, while surfaces and ink stay neutral (a red-branded hotel never gets a red background).

npm package @pamaconu/webcheckin · source in packages/webcheckin/ · part of the Happnest monorepo.

SHIPPED · runtime-branded

How it works

It's a Next.js 15 app (App Router, React 18) served on :3005 in dev. A guest never logs in — they reach a public check-in URL, and the whole flow is wrapped by CheckinProvider (src/contexts/CheckinContext.tsx), which holds the wizard state (checkinData, currentStep) and the per-step save handlers. WebcheckinModuleGuard blocks the flow if the hotel hasn't enabled the web-checkin module, and useIsInIframe lets a hotel embed the wizard inside its own site.

/checkin/start
landing · pick / confirm hotel
/checkin/new/paso-1
Step 1 — general info
hotel, dates, lead guest, booking ref, pax, email
/checkin/[id]/paso-2
Step 2 — traveller details
per-pax data + documents
/checkin/[id]/confirmacion
summary · legal texts · submit

State & steps

The wizard is rendered step components (GeneralInfoStep, PaxInfoStep, ConfirmationStep in components/checkin-steps/) driven by CheckinContext. Submitting step 1 creates a check-in and routes to /checkin/[id]/… so an in-progress check-in can be resumed by id.

Per-hotel config

useWebcheckinConfig(hotelId) supplies hotel rules (e.g. age of majority for the adults/children split); useDocumentRequirements / useNationalityDocumentRequirements decide which ID documents each traveller must provide.

Runtime branding

useHotelBrandinguseHotelTheme read the hotel's colours/fonts; BrandingThemeProvider writes --hotel-primary-hsl + --hotel-font-primary on <html>, and tokens.css bridges them into --accent / --font-body. See the live demo below.

Backend & i18n

Services in src/services/ (checkin-backend, checkinLookup, webcheckin-config, hotel-policies, legal-texts, bot-faqs, hotel-integration) call the @pamaconu/backend API. UI strings come from src/i18n/ (I18nProvider + messages.ts), so the wizard is multi-language.

Who uses it — personas & cases

The wizard is keyed on one field: bookingReference (the reservation locator). It is not unique — several check-ins can share the same code, and the backend groups them into a CheckinGroup ("familia, corporativo"). The number of adults + children a guest enters in step 1 is the size of their own party, not the whole reservation, and it's what generates the per-traveller forms in step 2. Those two facts are what make the personas below behave differently.

Direct guest · personal code Supported

Booked straight with the hotel, so the locator belongs to them alone. They land on the hotel (picked in step 1 or pre-resolved by link), set their party size — e.g. a couple → adults: 2 — fill both travellers in step 2, and finish with a QR + confirmation email. One reservation, one check-in.

Family with minors Supported

A parent checks in the whole family: adults + children generate the right mix of forms, with the lead adult pre-filled. Children are type: "child" and the document rules relax according to the hotel's age-of-majority setting (useWebcheckinConfig / useAgeOfMajority).

Agency / group · shared "common" code Supported · with caveat

An agency books, say, 10 people under one locator. Each sub-party self-serves: this guest enters the shared code, sets adults: 2 (himself + wife) and submits only his two travellers. Every sub-party's check-in carries the same bookingReference, so the hotel sees them aggregated as a single CheckinGroup with per-member progress.

Caveat: the "Continue" search resolves a code+date to a single check-in, so to resume reliably each member must use their own short code / QR link rather than the shared locator (see Coverage & gaps).

Returning guest · resume Supported

Started earlier and came back. On /checkin/start they use "Continuar": a UUID-like short code is looked up by id; any other code asks for the arrival date and runs searchCheckinByBookingAndDate. They re-enter at the step they left off (currentStep, status in-progress).

International / foreign guest Supported

Switches language (es / en / fr, per the hotel's enabled set). Their nationality drives which ID document is offered, required and validated — passport vs DNI / NIE / others — via nationality-document-requirements.json and useNationalityDocumentRequirements.

QR / email deep-link guest Supported

Opens a hotel QR or an emailed link carrying ?hotelId=… or a signed token (qrTokenresolveCheckinByIdOrRef). The hotel — and often the exact check-in — is pre-resolved, so they skip hotel selection and land directly in the flow.

Embedded-widget guest Supported

Completes the check-in inside the hotel's own website. useIsInIframe flips the layout to checkin-layout--embedded: no full-screen chrome, just the wizard.

Walk-in · brand-new check-in Supported

No prior record to resume. They take the green "Nuevo check-in" path (/checkin/new), pick the hotel and enter everything from scratch — including the locator handed to them at the desk.

Hotel staff / operator Planned

/login exists but is an explicit placeholder ("integrar auth en fase posterior"). The operational group view that reads CheckinGroup progress lives in @happnest/enterprise / admin, not in this guest-facing app.

Coverage & gaps

Covered

Direct, family, foreign-language, QR/deep-link, embedded and walk-in flows; creating per-sub-party check-ins under a shared agency code; resuming a known check-in by short code / id.

Partial

Group resume by the shared code is ambiguous — search returns one check-in, with no member picker when a locator maps to several. There's no in-app view of "who in my group still has to check in".

Not yet

Staff authentication (/login is a stub). Check-in expiry validation is a documented TODO in checkinLookup.service.ts. Group orchestration stays in admin, not the guest app.

Where it lives

Everything is under packages/webcheckin/ in the monorepo. The shape that matters:

# packages/webcheckin/
src/app/                          # Next.js App Router routes
  checkin/start/                  # entry / landing
  checkin/new/paso-1/             # step 1 — new check-in (general info)
  checkin/[id]/paso-1/            # step 1 — resume an existing check-in
  checkin/[id]/paso-2/            # step 2 — traveller (pax) details
  checkin/[id]/confirmacion/      # confirmation / submit
  demo/branding/                  # theming sandbox
  login/                          # staff login
src/components/
  checkin-steps/                  # GeneralInfoStep · PaxInfoStep · ConfirmationStep
  ui/                             # Alert · BrandingButton · DateInput · Field · StepDot · Stepper
  StepNavigation.tsx              # wizard progress indicator (numbers / full modes)
src/contexts/CheckinContext.tsx   # wizard state + step persistence
src/hooks/                        # useCheckin · useHotelTheme · useWebcheckinConfig · …
src/services/                     # backend API calls
src/i18n/                         # I18nProvider + messages
src/styles/tokens.css             # design tokens (HSL) + runtime-branding bridge

Run it

# from the monorepo root
pnpm --filter @pamaconu/webcheckin dev      # → http://localhost:3005
pnpm --filter @pamaconu/webcheckin build
pnpm --filter @pamaconu/webcheckin test     # Jest unit
pnpm --filter @pamaconu/webcheckin test:e2e # Playwright

Depends on

@pamaconu/core (shared component library, workspace:*) for primitives and the branding context, plus react-datepicker for date fields, zod for validation, and react-icons / clsx / tailwind-merge. Styling is Tailwind reading the token CSS variables.

Deployment (Docker)

The monorepo ships a Docker Compose stack. Two services matter for web check-in — the app itself and the docs site you're reading right now.

webcheckin · :3005

Production Next.js build from packages/webcheckin/Dockerfile. The container listens on 3000 and is published to the host as 3005 ("3005:3000"). It talks to the backend service at :4001 and waits for it to be healthy.

docs · :3099

This page. An nginx:alpine container serving the ./docs folder read-only, published on 3099 ("3099:80"). Open http://localhost:3099 to browse the design-system docs in the browser.

Dev variants

docker-compose.dev.yml and docker-compose.simple.yml build from Dockerfile.dev with src/ mounted for hot reload. Same host port (3005); good for iterating without a full production build.

# production stack (app on :3005, docs on :3099, backend on :4001)
docker compose up -d webcheckin docs

# hot-reload dev container
docker compose -f docker-compose.dev.yml up webcheckin

# …or run it straight from the workspace, no Docker
pnpm --filter @pamaconu/webcheckin dev   # → http://localhost:3005

Note: the app and the docs are different containers. :3099 is always the documentation; the live guest check-in app is :3005.

Architecture

A BrandingThemeProvider writes --hotel-primary-hsl on <html> when a hotel is loaded. The bridge in tokens.css falls through to that value via var(--hotel-primary-hsl, <default>). Chrome variables (bg, surface, ink) deliberately do not fall through.

tokens.css :root
--bg, --surface, --ink (neutral)
--accent (warm amber default)
BrandingThemeProvider
writes --hotel-primary-hsl
+ --hotel-font-primary at runtime
Bridge :root
--accent: var(--hotel-primary-hsl, …)
--font-body: var(--hotel-font-primary, …)
↓ consumed by ↓
components/ui/*
BrandingButton · DateInput · StepDot · Stepper · Alert · Field

Multi-tenant theming, live

Three identical check-in cards, three "hotels". Only --hotel-primary-hsl changes — the rest of the UI stays exactly the same.

default · no hotel branding
1 2 3

Paso 2 — Datos del huésped

Confirma tu información personal.

hotel-x · --hotel-primary-hsl: 0 72% 50%
1 2 3

Paso 2 — Datos del huésped

Confirma tu información personal.

hotel-y · --hotel-primary-hsl: 270 55% 45%
1 2 3

Paso 2 — Datos del huésped

Confirma tu información personal.

Tokens

From packages/webcheckin/src/styles/tokens.css. Same surface palette as client; different accent default.

Surfaces & ink (shared neutral)

--bg
40 33% 98%
--surface
36 25% 95%
--surface-2
36 18% 91%
--ink
220 13% 9%

Accent (default = warm amber, hotel-overridable)

--accent
28 80% 52%
--accent-fg
0 0% 100%
--accent-soft
28 80% 92%

Status — error / warning / success / info

--success
146 55% 36%
--warning
38 92% 50%
--error
0 72% 50%
--info
212 92% 45%

HappNest brand (locked, not tokenized)

brand yellow
#f7cf08 · wordmark only

Mobile-first hard rules

These never bend — they protect the guest-facing experience.

Touch targets ≥ 44 × 44

Apple HIG. Every interactive primitive enforces min-height: 44px on every breakpoint. Webchecking ups it to 48px on mobile blocks.

Inputs ≥ 16 px font

iOS Safari zooms the viewport when an input font-size is below 16 px. Hard rule: never go lower.

prefers-reduced-motion: reduce

Every @keyframes is opted out under that media query. Wizard transitions stop animating entirely.

Viewport-height fix

Older iOS Safari miscalculates 100vh. Webchecking ships the well-known custom-property workaround in globals.css.

The redo

Two examples of what the slim-down rewires.

Hotel-runtime bridge

Before Tailwind reads hotel vars directly
// tailwind.config.js
colors: {
  primary: "var(--hotel-primary)",
  secondary: "var(--hotel-secondary)",
}
// No fallback. No HSL. No way for hotels
//   to brand only the accent — bg follows too.
After layered tokens + bridge
/* tokens.css */
:root {
  --accent: var(--hotel-primary-hsl,
                  28 80% 52%);
  --accent-soft: color-mix(
    in srgb,
    var(--hotel-primary, hsl(var(--accent))) 12%,
    white
  );
  --font-body: var(--hotel-font-primary,
                     var(--font-sans));
}

Stepper atom

Before CSS-only, leaky
/* globals.css — 700+ lines */
.step-indicator .step {
  background: #e5e7eb;
  color: #6b7280;
}
.step.active {
  background: var(--hotel-primary);
  color: white;
}
.step.completed {
  background: #10b981; /* hardcoded */
}
After typed primitive
// components/ui/StepDot.tsx
export function StepDot({ state, children }: Props) {
  return (
    <span className={clsx(
      "core-stepdot",
      state === "active" && "is-active",
      state === "completed" && "is-done",
    )} aria-current={state === "active" ? "step" : undefined}>
      {children}
    </span>
  );
}

The plan, walked through

From packages/webcheckin/DESIGN_SYSTEM_PLAN.md. All 10 steps COMPLETE — May 2026. 19 test suites / 177 passing. Build + lint green.

Step 1 Baseline audit ✓ DONE
32 color leaks identified | 133 palette class instances found | Dead-code cleanup already completed in prior session. All audit criteria met.
Step 2 Token system + runtime bridge ✓ DONE
tokens.css created with HSL variables; imported first in globals.css before core styles. Bridge in place: --accent falls through to --hotel-primary-hsl. BrandingThemeProvider writes both hex and HSL variants.
Step 3 Tailwind reads tokens ✓ DONE
Tailwind config already updated: colors map to hsl(var(--token)). All token classes available: bg-bg, text-ink, bg-accent, bg-success-soft, etc. Backward-compatible hotel-* aliases preserved.
Step 4 Slim globals.css to ≤ 300 lines ✓ DONE
Reduced 383 → 289 lines. Consolidated ≤640px + ≤768px media queries; removed 6 dead utilities; compacted keyframes. Preserved all 16 px / 44 px / reduced-motion hard rules. Build passing.
Step 5 Bespoke primitives ✓ DONE
6 typed React primitives under src/components/ui/: Alert, StepDot, Stepper, Field, BrandingButton, DateInput. All use token classes only (no hex/palette). Proper accessibility roles + focus rings.
Step 6 Dead-code purge ✓ DONE
Completed in prior session. All backup files, old jest configs, and test scripts already deleted. No pending deletions.
Step 7 Migrate screens to tokens ✓ DONE
confirmacion/page.tsx migrated: replaced 15 hardcoded hex colors with token variables. Audit confirms: zero remaining palette classes in src/. Build passing.
Step 8 Tests ✓ DONE
19 test suites / 177 tests passing (1 todo). Full coverage of all 6 ui/ primitives (Alert, BrandingButton, DateInput, Field, StepDot, Stepper), plus StepNavigation, PaxDetailsForm, DocumentSelector, CheckinSteps, LoadingSpinner, NationalitySelector and integration / hook suites. Every test queries actual rendered output — no mock-heavy shortcuts.
Step 9 Documentation ✓ DONE
packages/webcheckin/DESIGN.md updated: sections 2–4 replaced with token-system overview, tokens.css pointer, and the full ui/ primitives table. Hard-rules, personas, and checklist updated to reference the new primitives instead of CSS-only patterns.
Step 10 Final acceptance ✓ DONE
All criteria met: build passing · lint passing (no errors) · 19/19 test suites green · globals.css 289 lines ≤ 300 · zero duplicate keyframes · dead files deleted · DESIGN.md updated.
Step 11 Deferred work out-of-scope
Cross-package core theming (making @happnest/core components consume tokens) tracked in packages/client/FOLLOW_UP_CORE_COMPONENT_THEMING.md. Not in this plan.