@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.
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.
hotel, dates, lead guest, booking ref, pax, email
per-pax data + documents
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
useHotelBranding → useHotelTheme 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 (qrToken → resolveCheckinByIdOrRef). 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.
--accent (warm amber default)
+ --hotel-font-primary at runtime
--font-body: var(--hotel-font-primary, …)
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.
Paso 2 — Datos del huésped
Confirma tu información personal.
Paso 2 — Datos del huésped
Confirma tu información personal.
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)
Accent (default = warm amber, hotel-overridable)
Status — error / warning / success / info
HappNest brand (locked, not tokenized)
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
// 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.
/* 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
/* globals.css — 700+ lines */ .step-indicator .step { background: #e5e7eb; color: #6b7280; } .step.active { background: var(--hotel-primary); color: white; } .step.completed { background: #10b981; /* hardcoded */ }
// 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
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
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
Step 5 Bespoke primitives ✓ DONE
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
Step 7 Migrate screens to tokens ✓ DONE
Step 8 Tests ✓ DONE
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
Step 11 Deferred work out-of-scope
@happnest/core components consume tokens) tracked in packages/client/FOLLOW_UP_CORE_COMPONENT_THEMING.md. Not in this plan.