H1🧩 MICRO GOALS (ATOMIC + IMPLEMENTABLE)
H2Commercial Trips / Experiences Roadmap
- now: harden Stripe + booking state sync so paid joins stay consistent.
- next: finish
/experiences/[experienceId]as a sellable page with unified catalog/db data and availability checks. - later: trip/offer PDFs, booking management, admin repair tools, and notifications.
- audit: FareHarbor identity mapping is confirmed for Córdoba, Granada, and the full journey bundle; Madrid/Málaga/Sevilla still need real links.
- update: public trips routes now use
Link/Imageinstead of raw internal anchors or img tags, leaving the root layout as the only HTML shell owner. - update:
/trips/publicnow has a JSX poster/grid gallery toggle using tripmainImagefallbacks; no Prisma/model changes were made for this frontend concern. - update: public trip poster cards now borrow the Spaces poster framing, keep the smaller-card ratios aligned with the grid view, and use stronger dark/light-readable typography overlays.
H2Active (2026-05-10) — Duration Calibration Constant + Audit Bars
- intended goal: Establish a universal "final recorded video duration" calibration constant (baseline: 150 wpm) and surface passive audit bars at every level of the content hierarchy (card → group → stack → path) so users can see fullness at a glance without interrupting flow. Plus split/combine actions for overflow/underflow, and a recompose panel for unbound items.
- route or feature area:
lib/estimateDuration.ts— word-count → estimated video minutes (core utility)app/components/AuditBar.tsx— visual fullness bar (green/yellow/red), shared across domainsapp/(content-layer)/session-paths/components/PathBinderView.tsx— audit bars at all 3 levels + split/combine + recomposeapp/(content-layer)/session-paths/components/RecomposePanel.tsx— unbound items viewerapp/(content-layer)/concept-groups/actions/splitConceptGroup.ts— split group server actionapp/(content-layer)/concept-groups/actions/combineConceptGroups.ts— combine groups server actionapp/(content-layer)/session-stacks/actions/splitSessionStack.ts— split stack server actionapp/(content-layer)/session-stacks/actions/combineSessionStacks.ts— combine stacks server action
- expected done condition:
- All core utilities available: countWords, estimateDuration, estimateFromStrings, formatDuration, DURATION_THRESHOLDS
- Audit bars render at group (max 180), stack (max 480), and path (cadence-based) levels
- Card-level estimated duration shown inline when diff from actual
- Split button on groups ≥120 min, combine buttons on groups <60 min (edit mode)
- Split button on stacks ≥360 min, combine buttons on stacks <120 min (edit mode)
- Recompose panel shows unbound cards/groups/stacks (edit mode)
- Bar colors: green (<80%), yellow (80-99%), red (≥100%)
- achieved goal (2026-05-10):
lib/estimateDuration.ts— DEFAULT_WPM=150, countWords, estimateDuration, estimateFromStrings, formatDuration, DURATION_THRESHOLDS (group: max 180 / warn 120, stack: max 480 / warn 360)app/components/AuditBar.tsx— dual-bar (estimated + actual), color-coded, compact prop, exportable to any domain- PathBinderView: path-level aggregate audit bar, card-level estimated duration, wired thresholds, split/combine forms (ServerAction-gated) at group + stack levels, RecomposePanel gated behind edit mode
- 4 server actions: splitConceptGroup (halve cards), combineConceptGroups (merge to adjacent), splitSessionStack (halve groups), combineSessionStacks (merge to adjacent)
- RecomposePanel: queries unbound ConceptCards (no group), ConceptGroups (no stack), SessionStacks (no path), shows up to 50 each
- achieved goal (smart naming follow-up):
lib/smartName.ts—deriveName(items, fallback)uses first item title + count for dynamic namingprogramName()— date-based default ("Program - May 10") instead of "New Binded Program"splitConceptGroup: new group named after first moved card's title (e.g., "Async/Await Patterns" instead of "Group (part 2)")splitSessionStack: new stack named after first moved group's title- Original container only renamed to "(cont.)" if collision would occur (rare)
createSessionPath: usesprogramName()instead of hardcoded "New Binded Program"- Page improvements (session-paths):
InlineTitle.tsx— click-to-edit h1 in edit mode (input on click, Enter to save, Escape to cancel, auto-save on blur)- Session Management (toggler, new session button) + End Notes + Program Settings all gated behind
SessionCardEditorGate— public viewers see only content - PathRenameForm removed (replaced by InlineTitle)
- List page (
/session-paths): red ✕ delete button per program (hover-reveal), cadence/duration/sessions/estimated time shown, cleaner card styles deleteSessionPathaction reused from list page forms (redirects to/session-pathson success)
- ⌘/Ctrl+click multi-select compose system (all hierarchy levels):
lib/hooks/useMultiSelect.ts— generic hook:toggle(id),isSelected(id),clear(),handleClick(e, id)(⌘/Ctrl intercept)app/components/MultiSelectActionBar.tsx— floating bottom bar: selected count + "Bind as new [next level]" button + Clear- 3 batch-bind server actions:
bindCardsToGroup,bindGroupsToStack,bindStacksToPath— each creates the container, moves items in, redirects to the new instance SelectableCardGrid/SelectableGroupGrid/SkillSessionGrid— all 3 list pages now support ⌘+click multi-select → floating compose bar- Naming: uses
deriveName()fromsmartName.tsfor the composed container title - Pattern: normal click navigates, ⌘/Ctrl+click toggles selection, selected items get a ring-2 highlight
- Unified stepped card grid across all listing pages:
styles/card-grid.css— shared CSS:masonry-grid(CSS columns, 2→5 cols),masonry-item(break-inside),card-small/medium/large(aspect-ratio for images, min-height for text cards),dashboard-grid(CSS Grid uniform)app/components/CardGrid.tsx— exportsgetCardSize(index)using the 7-element coprime pattern, layout prop formasonryvsdashboard- All 4 listing pages (concept-cards, concept-groups, session-stacks, session-paths) now use
masonry-grid+getCardSize()for stepped sizing - Trips/public gallery: switched from
dashboard-grid(uniform squares) tomasonry-grid+ stepped card sizes, removed inlineaspectRatio: 1/1 - Text-only cards get
card-textclass for min-height variants (140/220/300px) - Image-based cards use the existing
card-imageaspect-ratio CSS (1/1, 2/3, 1/2)
- achieved goal (2026-05-10 follow-up):
- PathBinderView card rendering: Replaced inline card list (bullet points, manual step truncation) with
ConceptCardReadView— each card now renders as a properborder rounded p-4 bg-whitecard with full steps, materials, promise, and metadata - Filter inputs on all listing pages: Shared
FilterInputcomponent +useItemFilterhook atapp/components/FilterInput.tsx— live client-side filtering by title/promise (cards), title/summary (groups, stacks), title/cadence (paths) - Inline session stack title title edit:
InlineSessionTitleat session-stacks detail page — click-to-edit, Enter/blur saves, Escape cancels, replaces static<h1>+ edit-mode form - Removed duplicate edit-mode title form from SkillSessionView.tsx (now handled by InlineSessionTitle)
- Session-paths listing page: extracted inline rendering into
FilterablePathGridclient component (supports live filter + preserves delete actions) getCardSize(): removed inline duplication in session-paths page (delegates toFilterablePathGrid)npm run lintpasses clean for all changed files (656 pre-existing errors elsewhere in the codebase)
- PathBinderView card rendering: Replaced inline card list (bullet points, manual step truncation) with
- remaining open issue:
- Browser verification needed for all routes
- next action:
- Browser-test filter inputs on all 4 listing pages
- Browser-test InlineSessionTitle click-to-edit on session stacks detail
- Browser-test PathBinderView cards rendering
H2Active (2026-05-10) — Canonical Trip Routing + Promo Space Auto-Sync
- intended goal: Make
/trips/[tripId]the canonical editor; restructure/trips/builder→/trips/public; auto-create and sync Trip ↔ Promo Space marketing pages. - route or feature area:
app/trips/public/page.tsx,app/trips/public/dashboard/page.tsx(new)app/trips/builder/page.tsx,app/trips/builder/dashboard/page.tsx(redirects)app/trips/builder/[tripId]/page.tsx(deprecation notice)app/trips/t/[slug]/page.tsx(promo space link)app/trips/builder/actions/ensureTripPromotionalSpaces.ts(new)- All builder actions (
revalidatePathupdates)
- expected done condition:
/trips/builder→ redirects to/trips/public/trips/builder/dashboard→ redirects to/trips/public/dashboard/trips/builder/[tripId]→ functional with deprecation banner linking to/trips/[tripId]- All builder actions revalidate both legacy (
/trips/builder/*) and canonical (/trips/*,/trips/public/*) paths - Publishing a trip auto-creates/updates its promo marketing space
/trips/t/[slug]landing page shows "View full marketing page" link if a published promo space exists- "Magic fix" button validates 6-point link anatomy and regenerates if broken/archived
- achieved goal:
- Routing restructure complete:
/trips/builder/page.tsx→redirect("/trips/public")/trips/builder/dashboard/page.tsx→redirect("/trips/public/dashboard")/trips/builder/[tripId]/page.tsx→ has prominent deprecation banner with link to/trips/[tripId]
- Revalidate path updates (8 files):
updateTripLandingSections.ts: Added/trips/${tripId}deleteBuilderTrip.ts: Added/trips/public,/trips/public/dashboardcreateBuilderTrip.ts: Added/trips/publicfixAllTrips.ts: Added/trips/public,/trips/public/dashboardgenerateTripLanding.ts: Added/trips/${tripId},/trips/public,/trips/public/dashboarddashboardTripActions.ts: Added/trips/public/dashboardto all functionspublishBuilderTrip.ts: Added/trips/public(both publish/unpublish)
- Trip ↔ Promo Space auto-sync architecture complete:
- New file:
ensureTripPromotionalSpaces.tswith:ensureTripPromotionalSpace(tripId, options?): Single-trip ensureensureAllUserTripPromotionalSpaces(options?): Batch ensure for all user's tripsforceRegenerateTripPromotionalSpace(tripId): Force fresh rebuildgetTripPromotionalSpaceInfo(tripId): Lookup helper with validation report
- 6-point link anatomy validation (
validateSpaceLinkAnatomy()):hasValidSourceType:sourceType === "trip_builder"?hasValidSourceId:sourceId === String(tripId)?hasPrimaryRoute: At least one route withpath?hasSections: Any sections exist?hasPosterSection: Has criticalpostersection type?isArchived:archivedAt !== null?
- Smart action outcomes:
"healthy"+ timestamp fresh →"skipped_fresh""healthy"+ trip newer →"updated"(regenerate)"broken"(any check fails) →"repaired_broken"(force regenerate)"archived"→"replaced_archived"(create fresh instance)
- Auto-trigger on publish:
publishBuilderTrip.tsnow callsensureTripPromotionalSpace(tripId)after settingshareForOthers=true - Landing page link:
/trips/t/[slug]now:- Looks up associated published promo space (
status: "published",archivedAt: null) - Shows sticky top banner: "⚠️ This is a quick itinerary preview — View full marketing page →"
- Shows footer link: "View full marketing page"
- Looks up associated published promo space (
- Dashboard buttons in
/trips/public/dashboard:- 🎨 Sync Trip Promo Spaces — Smart sync (stale/broken/archived only)
- 🔄 Force Regenerate ALL Trip Promo Spaces — Fresh rebuild for every trip
- New file:
- Routing restructure complete:
- content-sync principle (architecture LOCKED):
- Trip is source of truth: Promo Space content is derived from Trip data
- Sync triggers:
- Automatic: On
publishBuilderTrip()call - Manual: Dashboard sync buttons
- Smart:
ensureTripPromotionalSpace()comparestrip.updatedAt > space.updatedAt
- Automatic: On
- Data flow: Trip →
createTripPromotionalSpace()→ Space with derived content (poster, hero, feature_grid, destination_grid, itinerary, pricing, faq, cta, footer) - Sync direction: ONLY Trip → Space. Space edits are NOT synced back to Trip (they live in projection layer)
- remaining open issue:
- Browser verification needed for all routes
- TypeScript/build check needed when Node runtime is available
- Trip's
mainImagenot yet wired to Space poster image sync
- 🔮 Future Dev Tasks (Noted from Session):
- Trip Gallery needs SpaceCard-like per-card controls:
- Image randomize (
🔄) - Hard reset + save (
🎲) - Save current preview (
💾) - Settings panel (
⚙️) with title/slug/image URL inputs - Clone (
⧉) - Image edit overlay on hover
- Image randomize (
Trip.mainImageunified architecture:- Use in:
/trips/publicgallery cards - Use in:
/trips/public/dashboardgallery cards - Use in: Trip detail components
- Sync to: Promo Space poster image when generating from trip
- Editable from: Each interface (trip editor, gallery, space editor)
- Use in:
- Poster Mini-Card Gallery View (toggleable):
- Optional alternate view in
/trips/publicgallery - Uses Spaces
poster-sectioncomponent CSS with mini modifier - Toggle: "Posters View" / "Grid View"
- Preserves stepped grid masonry aspect ratios
- Optional alternate view in
- Trip Gallery needs SpaceCard-like per-card controls:
- next action:
- Browser-test:
/trips/builderredirect →/trips/public - Browser-test:
/trips/builder/[tripId]deprecation notice - Browser-test: Publish button triggers promo space creation
- Browser-test:
/trips/t/[slug]shows "View full marketing page" link - Browser-test: Dashboard sync buttons work
- Browser-test:
H2🔮 Side Project — AI Dynamic Trip Planner (Recursive Context-Aware)
Status: Planning/Architecture phase (side project, not blocking core shipping)
Inspiration: Combines the AppScript _processTabRecursive() pattern with your existing Ideas module + Assistant + Content Cards + Google Maps API.
H3Core Architecture Pattern (AppScript → Next.js Mapping)
| AppScript Concept | Molino Equivalent | Role |
|---|---|---|
Document tab | TripCity / SessionStack | Container for prompt + output |
| Header as prompt | Ideas form panel | Structured input that describes what user wants |
[AI Context Summary] footer | SessionStack.data / TripCity.contentStack.data | Context carry-over between containers |
tab.getChildTabs() | Persona planners / "Historical character" influencers | Sub-models that influence choices |
_processTabRecursive() | New: trip planner engine | Sequential processor with context |
CacheService | React Query / Prisma / custom cache | Avoid re-processing identical prompts |
mergeAIGeneratorTabsToResult() | Trip entity finalization | Consolidate into single Prisma write |
LanguageApp.translate() / callAiModel() | Your existing Assistant + Ideas module | LLM API access |
H3Recursive Context Flow (The Key Pattern)
Trip Start
↓
[City Block 1: Córdoba]
├── Input: User prompt + city config (nights, etc.)
├── AI generates: choices, notes, activities
├── ✅ Summary written to: contentStack.data["aiContextSummary"]
├── ✅ Prisma: TripCity updated
↓
[City Block 2: Granada]
├── Context INJECTED: [Córdoba summary + trip context]
├── Input: "Design 3 nights in Granada, considering Córdoba was just visited"
├── AI generates: choices influenced by previous city
├── ✅ Summary written to: contentStack.data["aiContextSummary"]
├── ✅ Prisma: TripCity updated
↓
... more cities ...
↓
[Trip Finalization]
├── All city blocks processed
├── Consolidated Trip entity
├── Optional: Promo Space generation
└── ✅ User sees fully-built trip
H3Key Design Principles (From Session Discussion)
1. Google Maps as "Anti-Hallucination" Layer
BEFORE AI generates anything:
Query Maps API for:
├── Actual distances/durations between cities
├── Known locations/attractions (structured data)
├── Route capacity / constraints
├── Stopping points en route
├── Elevation / terrain (for pacing decisions)
└── Opening hours / seasonal availability
Feed this STRUCTURED DATA to AI as CONTEXT.
AI only generates: INTERPRETATION / SELECTION / RECOMMENDATION.
AI NEVER generates raw facts about locations.
2. Persona as "Child Tab" Influencers
Choose from historical/notable figures as planners:
Persona Selection:
├── "Washington Irving" — Romantic, literary-focused Andalusia
├── "Gerald Brenan" — Local, authentic, off-the-beaten-path
├── "Richard Ford" — Classical, architectural, historical depth
├── "Travel influencer 2026" — Instagram-friendly, modern
├── "Slow travel advocate" — Paced, deep, immersive
└── Custom — User-defined preferences
Persona context INFLUENCES:
├── Available city choices (filtered by persona)
├── Activity theme suggestions
├── Route recommendations
├── Pacing recommendations
└── Content card selection / generation
3. Ideas Module Pattern Reused
Your existing Ideas → Form auto-filler → Prisma action pattern:
1. Chat panel describes what user wants (natural language)
2. AI generates STRUCTURED TypeScript form values
3. Form auto-fills (user can review/edit)
4. User clicks "Generate" → triggers prisma server action
5. Trip/TripCity entity created/updated
6. (NEW) Context summary written to contentStack.data for NEXT city
4. Container Hierarchy Reused
Your existing nested container architecture is PERFECT for this:
Level 0: Trip (overall container)
├── Trip.data["aiTripPlan"] — overall trip plan context
├── Trip.contentStack — trip-level concept cards
│
└── Level 1: TripCity[] (sequence of cities)
├── TripCity.data["aiCityPlan"] — city-specific plan
├── TripCity.contentStack — city-level concept cards
│ └── SessionStack.data["aiContextSummary"] — ✅ CARRY-OVER TO NEXT CITY
│
└── Level 2: TripCityExperience[] (individual experiences)
├── Experience.data
└── Experience.contentStack
The data Json? field already exists on ALL models. This is your context-passing mechanism. No schema changes needed.
H3Anti-Hallucination Architecture (Critical)
┌─────────────────────────────────────────────────────────────────┐
│ STAGE 1: STRUCTURED DATA GATHER │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User: "I want to go from Córdoba to Granada" │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Google Maps API / Places API │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ Distance: 126 km (≈1h 30m by car, 2h by train) │ │
│ │ Route Options: │ │
│ │ - A-45 motorway (direct) │ │
│ │ - Via Antequera (scenic, with Dolmens stop) │ │
│ │ Points of Interest along route: │ │
│ │ - Antequera Dolmens (UNESCO) │ │
│ │ - Loja (pottery town) │ │
│ │ - Puerto Lope (natural stop) │ │
│ │ Granada attractions (structured): │ │
│ │ - Alhambra (UNESCO, ticket required) │ │
│ │ - Albaicín (UNESCO, sunset views) │ │
│ │ - Sacromonte (caves, flamenco) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ OUTPUT: Structured JSON (VERIFIABLE, CACHEABLE) │
│ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ STAGE 2: AI INTERPRETATION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ PROMPT TO AI: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ [CONTEXT from Córdoba: user chose mezquita focus, │ │
│ │ 3 meals/day, historical guide preference] │ │
│ │ │ │
│ │ [STRUCTURED DATA from Maps API: │ │
│ │ { distance: 126km, duration: "1h30", │ │
│ │ routeOptions: [...], pointsOfInterest: [...], │ │
│ │ granadaAttractions: [...] } │ │
│ │ │ │
│ │ [USER PREFERENCES (from form): │ │
│ │ - Nights: 3 │ │
│ │ - Pace: "slow travel" │ │
│ │ - Interests: ["history", "architecture", "food"] │ │
│ │ - Persona: "Washington Irving" (romantic, literary)│ │
│ │ │ │
│ │ Generate a 3-night Granada itinerary. │ │
│ │ │ │
│ │ CONSTRAINTS: │ │
│ │ - ONLY select from the attractions listed above │ │
│ │ - Consider the Córdoba context (just visited mezquita,│ │
│ │ maybe pace differently, vary the architecture focus)│ │
│ │ - Washington Irving persona: romantic, literary, │ │
│ │ atmospheric, sunset-focused │ │
│ │ │ │
│ │ RETURN: Structured form values only. │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ AI OUTPUT: Structured JSON (INTERPRETATION, NOT FACTS) │
│ │
│ - Selected attractions (from the list only) │
│ - Pacing / day allocation │
│ - Theme recommendations │
│ - Activity suggestions (based on known locations) │
│ - Context summary for NEXT city │
│ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ STAGE 3: USER REVIEW + COMMIT │
├─────────────────────────────────────────────────────────────────┤
│ │
│ - Form AUTO-FILLED with AI's structured values │
│ - User reviews, edits if needed │
│ - User clicks "Generate" │
│ - Prisma server action creates/updates TripCity │
│ - Context summary written to contentStack.data │
│ - Move to NEXT city block with context │
│ │
└─────────────────────────────────────────────────────────────────┘
H3Reuse of Existing Molino Components
| Component | Already Exists? | How It's Used |
|---|---|---|
| Ideas module (chat → form) | ✅ Yes | Core pattern reused |
| Assistant (LLM API) | ✅ Yes | LLM provider |
| Persona system | ✅ Yes | Persona planners / historical characters |
data Json? on all models | ✅ Yes | Context carry-over mechanism |
| Trip / TripCity / Experience | ✅ Yes | Container hierarchy |
| SessionStack / ConceptGroup / ConceptCard | ✅ Yes | Content + context storage |
| Google Maps API | ⚠️ Partial | Needs Places/Directions/Distance Matrix |
| Promotional Space generation | ✅ Yes | Final marketing output |
H3Build Phases (Side Project, Not Blocking)
Phase 0: Foundation (Map API Integration)
- Add Google Places API
- Add Google Directions API
- Add Google Distance Matrix API
- Create structured data fetchers for cities/routes/attractions
- Cache layer for API responses (avoid costs + hallucinations)
Phase 1: Single City Block AI Planner
- Create:
TripCityAIService.ts - Reuse Ideas module pattern: chat → structured form values
- Add: Per-city config options (nights, pace, interests)
- Add: Persona selection per-trip (influences all cities)
- Write context summary to
TripCity.contentStack.data - User review + Prisma commit
Phase 2: Recursive Context Carry-Over
- Create:
TripPlannerOrchestrator.ts(like_processTabRecursive) - Read previous city's
contentStack.data["aiContextSummary"] - Inject into NEXT city's prompt
- Handle edge cases (first city has no context)
- City sequence awareness (A affects B affects C)
Phase 3: Trip-Level Planning
- Before cities exist: Route planning with Maps API
- User: "I want to visit 3 cities in Andalusia"
- Maps API: Distance matrix, travel times
- AI: Suggests city sequence + night allocation
- Creates TripCity placeholders for user to refine
Phase 4: Integration with Existing UI
- Chat panel in
/trips/planneror new/trips/ai-planner - Live city block cards that show AI suggestions
- Toggle: "AI Suggest" vs "Manual" mode
- Integration with existing
TripItineraryCanvas - Sidebar: Persona selector, pace controls
Phase 5: Content Card Auto-Generation
- AI suggests appropriate ConceptCards for each city/experience
- Based on persona, interests, city
- Creates or links existing ConceptCards
- Writes to
TripCity.contentStackandExperience.contentStack
H3Files to Reference for Pattern
AppScript Pattern (from your session context):
_processTabRecursive()— sequential processing with context_summarizeAIResponse()— create carry-over summary_writeFooterSummaryToHeader()— store context_getFooterSummaryFromHeader()— retrieve context- Recursive children: persona as "child tabs"
- Cache key based on prompt (avoid re-processing)
Molino Existing Patterns:
app/ideas/— chat → structured form → prisma actionapp/assistant/— LLM API accessapp/spaces/— persona systemlib/factories/— deterministic content creation- All models have
data Json?field — use this for context SessionStack+ConceptGroup+ConceptCard— content hierarchy
H2Active — Stripe Payment Flow (Deposit / Full / Ground Services)
- intended goal: enable Stripe payment on existing trip and join-trip surfaces with three payment options (deposit 250€/pax, full trip cost, ground services only).
- route or feature area:
app/bookings/actions/booking.actions.ts,app/api/webhooks/stripe/route.ts,app/bookings/success/page.tsx,app/bookings/cancel/page.tsx,app/trips/[tripId]/TripJoinPanel.tsx. - expected done condition: user can join a trip and pay via Stripe Checkout with any of three options; webhook updates Payment + Order status; success/cancel pages render confirmation.
- achieved goal:
- Added
stripeSessionId String? @uniqueto Payment Prisma model (primary webhook lookup key). - Created manual migration
20260509_add_stripe_session_id. - Added Stripe env vars (test keys) to
.env(STRIPE_SECRET_KEY,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,NEXT_PUBLIC_APP_URL). - Enhanced
createBookingWithStripe()server action:- Recomputed pricing server-side via
tripEngine(no trust of client amounts). - Creates Order + TripJoin (linked via
orderId) + Stripe Checkout Session + Payment (withstripeSessionId). stripePaymentIntentIdis only set later by webhook (not at session creation).
- Recomputed pricing server-side via
- Created
app/api/webhooks/stripe/route.ts:- Verifies signature via
stripe.webhooks.constructEvent()ifSTRIPE_WEBHOOK_SECRETexists. - Handles
checkout.session.completed: updates Payment (stripePaymentIntentId,succeeded), computes Order status (confirmedif fully paid,partially_paidotherwise). - Handles
checkout.session.expired: marks Payment asfailed, Order ascancelledif no other payments.
- Verifies signature via
- Created
app/bookings/success/page.tsx(client component): readssession_idfrom URL, callsfindBookingBySessionId()to show confirmation + remaining balance. - Created
app/bookings/cancel/page.tsx(server component): cancel message + link back to trips. - Updated
TripJoinPanel.tsx: replaced two existing action buttons with three Stripe payment buttons (Pay deposit, Pay full trip services, Pay ground services) + kept "Register without payment" + "View accommodation options". Buttons callcreateBookingWithStripe()and redirect to Stripe Checkout. - Added
findBookingBySessionId(),findUserBookings(),findOneBooking(),payRemainingBalance()to booking actions. - Created
app/bookings/[bookingId]/pay/page.tsx— remaining balance payment page with Pay now button. - Removed explicit
apiVersionfrom Stripe constructor to avoid version mismatch errors. - Stripe CLI installed, webhook endpoint created in Dashboard for production (
https://molino-staging.vercel.app/api/webhooks/stripe), local forwarding viastripe listen. - Browser-tested: deposit payment flow works end-to-end (Order + TripJoin created, Checkout Session redirect, webhook processes completion, success page shows correct amounts).
- Added
- remaining open issue: no
STRIPE_WEBHOOK_SECRETin Vercel env for production webhook signature verification. - next action: verify remaining balance payment flow, set
STRIPE_WEBHOOK_SECRETin Vercel env, deploy migration to remote DB.
H2Active — Trip Route Logic + Engine Upgrades (Legacy Port)
- intended goal: port the legacy Google Apps Script TravelPlan route logic (arrival/departure night allocation, city type classification, conditional route sequencing) and upgrade the trip pricing engine with meals, per-city snapshots, and experience costs.
- route or feature area:
app/trips/builder/lib/trip-route-logic.ts,app/trips/actions/trip-engine/trip-engine.ts,app/trips/builder/components/TripBlockStack.tsx,app/trips/builder/components/TripCityPicker.tsx,app/trips/types/trip.types.ts,app/trips/actions/trip-engine/trip-mappers.ts. - expected done condition: route logic computes nA/nD/nTour/nMods/nMaroc from blocks; generateTravelPlan produces ordered city sequence; engine includes meals, per-city pricing snapshots, and experience costs; builder shows route stats badges and conditional city suggestions.
- achieved goal:
- Ported
addTourDetails()→computeRouteAllocation(): allocates arrival/departure nights (nA/nD), classifies BASE/MODS/MAROC city types, computes remaining night distribution, handles SVQ special counting. - Ported
getTravelPlan()→generateTravelPlan(): builds ordered city sequence based on arrival city (AGP → SVQ→ODB→GRX, MAD → GRX→ODB→SVQ, BCN → BCN→GRX→ODB→SVQ, etc), inserts default route when no BASE cities present, producesTravelPlanDay[]with in/out dates. - Added
buildCitySequence()+insertDefaultRoute(): smart merging of user-specified blocks with default route rules from legacy. - Added
computeNAndD(): exact port of legacygetNaNd()with 2-night max for pointA≠pointD, remaining night fallback to pointD. - Added
LEGACY_CITY_CODESmap for all known cities (AGP, GRX, ODB, SVQ, MAD, BCN, LIS, CMN, etc) with type classification. - Engine upgrade
trip-engine.ts:interpretMealsCost()parseschoiceMealsstring to meals/day × 15€ × nights × pax; per-cityTripCityPricingSnapshotreturned inmeta.cityPricing;experienceCostPerPaxoptional input supported. - Mapper upgrade
trip-mappers.ts: bothmapDraftToTripInputandmapEntityToTripInputnow passchoiceMeals. TripBlockStackupgrade: route stats badges (BASE/MODS/MAROC/ARR/DEP + total days) shown above blocks.TripCityPickerupgrade: "Suggested" tab usinggetSuggestedNextBlocks(), auto-skips already-added cities, arrival-only list when empty, connection-based filtering.- Type system: added
TripCityPricingSnapshottype with all cost categories. - Build passes clean.
- Ported
- remaining open issue: experience cost data not yet loaded from DB (loaders stubs exist but empty); builder block-add action doesn't yet pass experience cost to the engine.
- next action: implement experience pricing loader, wire experience cost into builder block-add, browser test route logic on existing trips.
H2Active — Canonical Trip Public/Edit Separation
- intended goal: continue the Builder → canonical
/trips/[tripId]merge by keeping public trip pages focused on final itinerary/join flow while editor/admin tooling stays behind edit capability. - route or feature area:
/trips/[tripId], canonical builder edit shell, offer bridge, content-stack/editor affordances. - expected done condition: public viewers do not receive or see offer/editor tooling; editors can still access canonical edit mode, content tooling, and offer generation from the trip page.
- current status before coding: canonical edit shell and offer modal are already integrated, but a follow-up pass found public page hardening gaps around associated offer lookup and content-stack editor rendering.
- achieved goal: associated offer lookup now only runs for editors on trips with a project, and content-stack/editor links are no longer passed to public Trip detail clients.
- checked route or behavior:
git diff --checkpassed forapp/trips/[tripId]/page.tsxand this doc. - remaining open issue: browser verification and TypeScript/build checks are still needed because this environment cannot run Node/Next tooling.
- next action: browser-test
/trips/[tripId]public mode and edit mode when runtime is available.
H3Current Sprint Addendum — Trip Layout Sidebar + Public Canvas Cleanup
- intended goal: keep canonical
/trips/[tripId]public view itinerary-first while moving route/configuration/publishing controls into an edit-mode sidebar at the trip-id layout level. - route or feature area:
app/trips/[tripId]/layout.tsx,TripDetailSidebar.tsx,app/trips/context/TripBuilderDataContext.tsx,TripCanonicalBuilderShell.tsx,TripItineraryCanvas.tsx,TripDetailsForm.tsx. - expected done condition: public users do not see internal choice panels (
What traveller does,Responsibility split,City configuration, publishing toggles); editors use edit mode + trip sidebar tabs for route, details, estimate, and itinerary controls. - achieved goal: staged a layout-level
<aside>sidebar for trip tools, added shared builder state context, moved publishing toggles into sidebar details, moved city meals/package fields into sidebar block details, removed operational choice/config panels from public city cards, and constrained city block images so they do not stretch with long card content. - checked route or behavior: code-level pass only; local Node/npm are unavailable in this shell, so Next build/type verification could not run here.
- remaining open issue: browser verification needed for
/trips/[tripId]public mode, edit mode sidebar open/close, block selection, route edits, and details saves. - next action: browser-test the layout sidebar and continue moving remaining offer/export/content controls from main page into sidebar tabs.
- current coding goal: upgrade
TripEditDockinto a compact workflow launcher for edit mode, following the documents dock model: open route/details/estimate/itinerary stages, provide city block quick-jump buttons, and keep add/reorder/edit actions discoverable through the layout sidebar instead of the public canvas. - latest achieved goal: Route controls moved inline into each city block card in
TripItineraryCanvas— move up/down, remove, and insert-between buttons appear on each card and between cards in edit mode only. Cordoba/Granada core separation triggers a confirmation dialog.TripBlockStackremoved from the shell entirely. Public view stays canvas-only. - latest sidebar changes:
TripDetailSidebarstripped of route/estimate/itinerary tabs. Now shows only a unified general details form + per-city block choice forms. Auto-scrolls to the selected block's form when a city block is clicked on the main canvas. Header changed from "Trip builder" to "Trip details". - sidebar styling consistency (2026-05-09): Changed
TripDetailSidebarwidth fromw-[420px]tow-[320px]to match documents sidebar width; updated layout.tsx content push margin fromxl:ml-[420px]toxl:ml-[320px]. No logic changes — only styling alignment with studio/documents/concept-cards sidebar patterns. - remaining open issue: browser verification for inline card controls, city inserters, core separation dialog, sidebar block form sync, and public itinerary-only view.
- next action: browser-test edit mode controls on each card, inseter flow, core Cordoba/Granada guard, and verify public view.
H3Addendum — TripCity Content Stack Architecture (Concept-Cards Per-City-Block)
- intended goal: Build generic access-controlled editable content architecture for concept-cards/session-stacks across Trip, TripCity, and Experience entities. Pre-booking content (TripCity level) separated from post-booking content (Experience level).
- route or feature area:
prisma/schema.prisma,lib/factories/cityContentFactory.ts,lib/factories/contentStackFactory.ts, future:/trips/[tripId]TripItineraryCanvas concept-group card rendering. - expected done condition:
TripCitymodel supportscontentStackIdrelation;cityContentFactory.tscreates standard city-level content stacks with city-aware defaults (Córdoba, Granada, Málaga, Seville, etc.); pattern follows existingcontentStackFactory.tsused by Trip and Experience. - current status before coding:
- Trip.contentStackId: exists ✅
- Experience.contentStackId: exists ✅
- TripCity.contentStackId: MISSING ✖️
- City defaults as templates: MISSING ✖️
- achieved goal (2026-05-10):
- Schema change: Added
contentStackId String? @unique+contentStack SessionStack?toTripCitymodel inprisma/schema.prisma:1167-1168. Follows exact same pattern asTripandExperience. - Schema applied:
prisma db pushexecuted successfully. - Factory created:
lib/factories/cityContentFactory.tswith:CITY_DEFAULT_GROUPS: 3 city-specific groups (Welcome & Arrival, Local Essentials, Pre-Arrival Preparation)CITY_DEFAULTSmap: Córdoba, Granada, Málaga, Seville, Cádiz, Ronda, default fallbacknormalizeCityNameForLookup()+getCityDefaultsKey(): Smart city-name lookup (handles accented names like "Córdoba", "Málaga")createStandardTripCityContentStack(): Creates + links SessionStack →TripCity.contentStackIdensureTripCityContentStack(): Idempotent getter-or-creator pattern
- Factory wired to add-city actions:
addTripCityBlock.ts(builder, used byTripCityPicker): CallsensureTripCityContentStack()after creating TripCitycreateTripCity(trip-detail.commands.ts, used by legacyTripCommandSurface): Also wired
- Backfill action created:
lib/factories/backfillTripCityContentStacks.ts(actuallyapp/trips/builder/actions/backfillTripCityContentStacks.ts):backfillTripCityContentStacksForTrip(tripId): Backfills content stacks for a specific tripbackfillAllMyTripCityContentStacks(): Backfills ALL user's trips- Only processes TripCities where
contentStackId IS NULL
- Loader updated:
loadBuilderTrip.tsnow includes nestedcontentStack.sessionConceptGroups.conceptGroup(titles only, NO card content) - Types updated:
trip-builder.types.tsextended withTripCityBlockContentStackRef,TripCityBlockConceptGroupRef - Canvas rendering:
TripItineraryCanvas.tsxnow shows ConceptGroup TITLES ONLY (group-level only) in the live itinerary builder main view:- Shows after "About [city]" description, before days/experiences
- Builder mode: hover style, "Click block → sidebar" hint
- Canonical mode (
trips/[tripId]): shadow-sm transition, read-only presentation style - Displays: group title + optional summary
- CARD CONTENT NOT RENDERED: Card details reserved for post-booking delivery options, free included trip documentation sharing, etc.
- Sidebar wired:
TripDetailSidebar.tsxnow includes "City content stack" section for each city block:- Link: Browse concept cards →
/concept-cards - Link: + Generate with AI assistant →
/concept-cards/new - Shows
contentStackIdpreview if available
- Link: Browse concept cards →
- Toolsdock wired:
TripEditDock.tsxnow includes "Concept cards" button in public actions:- Direct navigation to
/concept-cardsroute - Available in the floating bottom dock panel
- Direct navigation to
- Schema change: Added
- content separation principle (architecture LOCKED):
- Pre-booking content (Trip builder view):
TripCity.contentStackwithConceptGroupTITLES ONLY- Visible in live itinerary builder main view
- Controlled from sidebar/toolsdock (navigation to concept-cards editor)
- Clicking a content group in the canvas → selects the block → opens sidebar with content links
- Post-booking content (for delivery):
ConceptCardfull content (steps, materials, assets)Experience.contentStackfor participant/staff delivery- Reserved for: post-commercial delivery options, free included trip documentation sharing, etc.
- NOT rendered in builder itinerary canvas
- Pre-booking content (Trip builder view):
- existing classification fields (no new fields needed, agnostic by design):
ConceptCard:domains[],data Json?,visibility,difficulty,durationMinConceptGroup:phase,visibility,cities[],data Json?,cadenceSessionStack:type,deliveryMode,data Json?,durationMin,locationSessionPath:cadence,visibility,data Json?data Json?: Open container exists on ALL models for AI assistant context, template source tracking, feature flags, parameters — completely optional
- architecture is AI-agnostic (AI is enhancement layer only):
- Trip core logic uses only deterministic factories (
contentStackFactory,cityContentFactory) /concept-cards/newAI assistant route is a COMPLETELY SEPARATE layer/trips/planner(legacy new trip route) works independently and triggers builder correctly- AI assistant never required for trip core flow
- Trip core logic uses only deterministic factories (
- remaining open issue:
- Migration tracking:
prisma db pushwas used instead ofprisma migrate dev— migration file20260509_add_stripe_session_idhas unrelated shadow DB issue - Existing TripCities (pre db-push) do NOT have content stacks — use
backfillTripCityContentStacksForTrip(tripId)orbackfillAllMyTripCityContentStacks()to populate - Trip-level aggregate container not yet linked to city-level containers
- Migration tracking:
- 🔮 Future Dev Tasks:
- Migration cleanup: Fix shadow DB issue or baseline migrations for proper
prisma migrate devworkflow - Admin UI for city default templates (planned):
- Route:
/cities/[citySlug]/content/(view/edit default city SessionStack templates) - Controls: "Use city default" vs "Override for this specific trip city"
- Features: Live preview, template versioning, fork-from-default pattern
- Purpose: Create city-wide default content stacks that get copied to
TripCityinstances
- Route:
- Migration cleanup: Fix shadow DB issue or baseline migrations for proper
- next action: Test builder to verify:
- Concept-group titles rendering in itinerary canvas
- Sidebar content navigation (
Browse concept cards,Generate with AI assistant) - Toolsdock "Concept cards" button
- Backfill action for existing trips (if needed)
H2Active — Public Trip Join CTA + Hotel Estimate Separation
- intended goal: move the public join form behind an early, prominent Trip CTA and make hotel cost language clear as an estimate/add-on rather than an included trip price.
- route or feature area:
/trips/[tripId],TripJoinPanel, trip pricing engine/line-item projection. - expected done condition: public users see a primary early
Join tripCTA that reveals the join form near the top of the page; hotels are not counted as included trip service pricing if the current engine includes them, and hotel estimate copy is friendly but clear. - current status before coding: public join form renders later inline on the page, and hotel estimate inclusion needs verification in the pricing engine.
- achieved goal: public hero now uses
Join this tripas the primary CTA and reveals the join form directly below the hero instead of rendering it open lower on the page; trip pricing engine no longer adds hotels topricing.totalor pricing rows, and exposes accommodation estimate metadata separately for the join panel. - checked route or behavior:
git diff --checkpassed for the touched Trip join/pricing files and this doc. - current follow-up: lower inline join section removed; join remains a top hero/header toggle only. Continue using
/trips/[tripId]as the gradual Builder merge surface while leaving/trips/builder/[tripId]available until parity is reached. - current correction: top join CTA must render for editors as well as public viewers, and canonical
/trips/[tripId]should reuse builder mechanics in a dedicated section above the schedule rather than leaving summary/route overview as the main itinerary surface. - latest achieved goal:
Join this tripnow renders in the hero for all viewers and opens the hidden top join form. The canonical trip page now renders a builder-style itinerary section above the schedule using the builder canvas, block stack, right panel, landing generation action, and hidden edit tools sidebar. The floating Trip dock now includesTrip toolsto jump to the canonical itinerary section and open the tools sidebar while keeping/trips/builder/[tripId]intact. - remaining open issue: browser verification and TypeScript/build checks are still needed because this environment cannot run Node/Next tooling.
- next action: browser-test public
/trips/[tripId]CTA reveal, join submission/login redirect, and hotel estimate copy.
H2Active — GAS↔Next.js Contract Alignment (Office Server)
- intended goal: align all Next.js routes (webhooks, catalog import, office-server bridge) with the GAS↔Next.js contract reference for FareHarbor integration.
- route or feature area:
/api/webhooks/fareharbor,/api/fareharbor/catalog/import,/api/office-server, Prisma models,lib/fareharbor-gas-client.ts. - expected done condition:
- Prisma schema has
CustomTripRequest,FareHarborProduct,AffiliateCommissionmodels + expandedExternalBookingfields (phone, affiliateCode, customFields, customTripRef). - Webhook handler authenticates via
x-sync-secret/APP_SYNC_SECRETand mapsbooking.created|.cancelled|.modifiedto contract payloads. - Catalog import handler authenticates via
x-sync-secret/CATALOG_IMPORT_SECRETand upsertsFareHarborProduct. lib/fareharbor-gas-client.tsprovides typed functions for all Group C actions (customTrip.create, link.generate, availability.get, manager.action).- Office server route accepts Group C action patterns with proper response convention.
- Prisma schema has
- current status before coding: existing code uses
ExternalBookingfor FH webhooks, maps catalog toExperience(notFareHarborProduct), has no typed GAS client, and office-server route handles only bundle types (not Group C action patterns). - achieved goal: all routes updated to contract spec — webhook uses x-sync-secret auth with booking.created|cancelled|modified; catalog import upserts FareHarborProduct; lib/fareharbor-gas-client.ts provides typed functions (createCustomTrip, generateBookingLink, getAvailability, managerAction); office-server route handles all Group C actions with standard response envelope. Prisma schema extended with CustomTripRequest, FareHarborProduct, AffiliateCommission models + expanded ExternalBooking fields.
- remaining open issue: need to run
prisma generateandprisma migrate devto apply schema changes to the database and regenerate the zod types. Build not verified (node not available in this environment). - next action: run prisma generate + migrate, then test webhook and catalog import endpoints against staging.
H2In Progress — Trip Builder + Landing Page (Parallel System)
- intended goal: build Trip Builder at
app/trips/builder/as a parallel feature surface — city-night blocks stacked into a priced, scheduled trip, rendered as a Spaces-like marketing landing page. Dual-mode: Builder (3-panel CRUD) and Landing Page (section-based preview with toggle/reorder/edit). - route:
app/trips/builder/[tripId]/page.tsx— Builder mode is now itinerary-first (left: city route stack, center: live itinerary canvas, right: inspector tabs) and Landing Page mode remains an output/projection surface (left: sections sidebar, center: live preview, right: section editor). - expected done condition:
- Builder mode: trip details form, city block stack, pricing estimate, itinerary preview
- Landing mode: section list sidebar with toggle/reorder/add/remove, live rendered page, per-section property editor
- 7 section types: hero, at-a-glance, itinerary, city highlight, pricing, faq, cta
- Auto-generated default sections from trip data (hero, at-a-glance, itinerary, pricing + one city section per TripCity)
- Sections stored in
Trip.projectionMeta.landing.sectionsas JSON — no schema changes - Auto-generated sections (itinerary, pricing, at-a-glance) derive content from trip state at render time
- Zero TypeScript errors
- current status before coding: builder MLV had 3 panels (city blocks + details/estimate/itinerary tabs). No landing page preview surface.
- achieved goal: Landing mode shipped —
TripLandingSectionList(sidebar with toggle/reorder/add/remove),TripLandingPreview(renders enabled sections stacked),TripLandingSectionEditor(right panel per-section form), 7 section components (Hero, AtGlance, Itinerary, City, Pricing, FAQ, CTA),updateTripLandingSectionsaction, mode toggle in builder header. Sections auto-generated on first load, persist to projectionMeta. Zero TS errors. - latest achieved goal: promoted
TripItineraryCanvasinto the primary Builder mode center surface. City blocks now render as polished traveller-facing itinerary cards with images, generated days, arrival notes for the first city, linked experiences, pricing summary in the overview, and selected-card highlighting.TripCityBlockViewModelnow includes the relatedCityrow for images and richer city content. Header now distinguishes nights from days. - latest achieved goal: added
trip-route-logic.tsas a non-Sheets route logic layer inspired by the legacy TravelPlan/AppScript model. It derives BASE/MODS/MAROC city type, total/base/extension/Morocco nights, arrival/departure identity, core Córdoba+Granada completeness/adjacency, per-city block choices, responsibility split, hotel choice placeholders, and next-step hints. The itinerary canvas now surfaces these route stats and per-block choice summaries. - remaining open issue: city section auto-linking (cityId in content), section types gallery/testimonials unimplemented, drag-and-drop via dnd-kit pending, public trip page generation from landing config, city autocomplete selector, map-aware route view, hotel support inspector, operations statuses.
- next action: browser test both Builder and Landing modes on staging, then add city selector autocomplete.
H2Active Check — Trips Landing Edit Persistence
- intended goal: restore
/tripsfront-page editable section persistence after replacing registry rendering with direct section composition. - route or feature area: public
/tripslanding sections and side/front edit mode commits. - expected done condition: editable trip landing components keep optimistic local updates and commit blur/save patches to
ProjectSection.contentJsonthrough the trips landing server action. - current status before coding: implementation bug; direct composition can render synthetic section ids, so commit callbacks update local state but skip the database save.
- achieved goal:
getTripsLandingSections()now always materializes missing registry-backed section rows before rendering/trips, restoring numeric section ids for direct-composed components. - checked route or behavior: user verified the hero section now saves correctly; targeted ESLint passed for trips landing edit files.
- remaining open issue: many direct-rendered content sections read
contentJsonbut still have no front-page edit inputs; they are not persistence failures until edit controls are added. - next action: add edit controls to any additional
/tripslanding sections that should be front-page editable.
H2G1 — Trip Draft Identity
authority: client (document layer)
- ensure
tripData.idexists at creation - use deterministic or uuid
done:
- no draft exists without id
H2G2 — Trip Sync Action (UPSERT)
authority: trips/actions
file:
app/(trips)/actions/commitTripFromDocument.ts
"use server"
import { prisma } from "@/lib/prisma"
export async function commitTripFromDocument(input: {
draftId: string
documentId?: number
data: any
pricing?: any
}) {
return prisma.trip.upsert({
where: { draftId: input.draftId },
create: {
draftId: input.draftId,
documentId: input.documentId,
data: input.data,
pricing: input.pricing
},
update: {
data: input.data,
pricing: input.pricing
}
})
}
done:
* idempotent persistence
* no duplicate rows
⸻
G3 — DB ID Back-Propagation
authority: document
* store tripId (NOT _dbId) inside document JSON
done:
* downstream actions use stable tripId
⸻
G4 — Pricing Integrity
authority: compute
* sanitize before save
export function normalizePricing(input: any) {
return JSON.parse(JSON.stringify(input ?? {}))
}
done:
* no undefined / functions
* reload-safe JSON
⸻
G5 — Generate LineItems (Document Scope)
authority: compute + actions
file:
* app/(lineItems)/actions/generateFromTrip.ts
```ts
"use server"
import { prisma } from "@/lib/prisma"
export async function generateLineItemsFromTrip({
documentId,
tripId,
items
}: {
documentId: number
tripId: number
items: any[]
}) {
return prisma.lineItem.createMany({
data: items.map(item => ({
parentId: documentId,
parentType: "document",
sourceType: "trip",
sourceId: tripId,
documentId,
title: item.title,
qty: item.qty,
unitPrice: item.unitPrice,
total: item.qty * item.unitPrice
}))
}
done:
- document-lineItems link established
⸻
🧩 EXPORT ROUTES — GOOGLE WORKSPACE / APPS SCRIPT (PARALLEL LAYER)
priority: HIGH (direct revenue + delivery)
mode: parallel to Trip → Offer → Order (non-blocking)
authority: projection layer (NOT domain truth)
⚡ ONE-GLANCE EXECUTION
- Add
computeBundle()for Trip (already defined) - Add
projectEntity()trigger from Trip / Offer / Order - Implement Apps Script endpoints:
- docs.create
- docs.exportPdf
- calendar.create
- drive.share
- gmail.send
- Persist
projectionMeta(docId, pdfUrl, eventIds) - Add UI buttons:
- "Export Offer"
- "Export Trip"
- "Send Package"
- Enforce:
- idempotency (
externalKey) - no logic in Apps Script
- idempotency (
- Test: Trip → Doc + PDF + Calendar → rerun safe
31 — EXPORT ROUTES (PARALLEL SYSTEM)
This layer extends the existing flow without modifying it.
It operates AFTER:
Trip / Offer / Order → LineItems → Snapshot
It NEVER feeds back into truth.
31.1 POSITION IN ARCHITECTURE
From core model: Truth → Compute → UI → Actions → DB → Projection Export routes live in: Projection → Publish → Deliver Extended pipeline: Compute → Bundle → Render → Project → Publish → Deliver
31.2 EXPORT ENTRYPOINT (UNIFIED)
file:
app/(projection)/actions/exportEntity.ts
"use server"
import { projectEntity } from "../projector/projectEntity"
import { computeTripBundle } from "@/app/(trips)/compute/computeTripBundle"
import { prisma } from "@/lib/prisma"
export async function exportTrip(tripId: number) {
const trip = await prisma.trip.findUnique({
where: { id: tripId }
})
if (!trip) throw new Error("Trip not found")
const bundle = computeTripBundle(trip)
const result = await projectEntity(bundle)
await prisma.trip.update({
where: { id: tripId },
data: {
projectionMeta: result
}
})
return result
}
⸻
31.3 STRUCTURED DOCUMENT EXPORT (CRITICAL)
Payload
export type DocumentRenderPayload = {
title: string
blocks: RenderBlock[]
}
Apps Script contract
function create(payload) {
const doc = DocumentApp.create(payload.title);
const body = doc.getBody();
payload.blocks.forEach(block => {
if (block.type === "heading") {
body.appendParagraph(block.text)
.setHeading(DocumentApp.ParagraphHeading["HEADING" + block.level]);
}
if (block.type === "paragraph") {
body.appendParagraph(block.text);
}
if (block.type === "table") {
body.appendTable(block.rows);
}
if (block.type === "spacer") {
body.appendParagraph("");
}
});
return {
documentId: doc.getId(),
fileUrl: doc.getUrl()
};
}
Rule:
- Next.js owns structure
- Apps Script only renders
⸻
31.4 PDF EXPORT
Atomic extension:
await runGoogleTool({
action: "docs.exportPdf",
payload: { documentId }
})
done:
- deterministic document → pdf
⸻
31.5 CALENDAR EXPORT
Compute:
tripToCalendarEvents(trip)
Idempotency (MANDATORY):
externalKey = `${trip.id}-${dayIndex}`
Apps Script rule:
- check existing by externalKey
- skip duplicates
Execution:
for (const event of bundle.calendar) {
await runGoogleTool({
action: "calendar.create",
payload: event
})
}
⸻
31.6 OFFER PACKAGE EXPORT (DRIVE)
Folder structure:
/root/
refExp/
internal/
shared/
Publisher:
async function publishOfferPackage(bundle) {
return runGoogleTool({
action: "drive.share",
payload: {
folderKey: bundle.drive.folderKey,
email: bundle.drive.clientEmail
}
})
}
done:
- deterministic delivery surface
⸻
31.7 EMAIL DELIVERY (OPTIONAL BUT CRITICAL)
Apps Script primitive:
await runGoogleTool({
action: "gmail.send",
payload: {
to: clientEmail,
subject: "Your Trip Offer",
attachments: [pdfUrl]
}
})
⸻
31.8 FULL EXPORT FLOW (FINAL)
Trip / Offer / Order → computeBundle() → OutputBundle → RenderBlocks → projectEntity()
→ docs.create
→ docs.exportPdf
→ calendar.create
→ drive.share
→ gmail.send
→ persist metadata → revalidate
⸻
31.9 UI TRIGGERS (MINIMAL)
Trip / Offer UI:
<button onClick={() => exportTrip(tripId)}>
Export Trip
</button>
Offer UI:
<button onClick={() => exportOffer(offerId)}>
Send Offer Package
</button>
⸻
31.10 IDENTITY + IDEMPOTENCY RULES
MANDATORY:
- document → no duplication (always new version OK)
- calendar → MUST use externalKey
- drive → deterministic folderKey
- email → optional dedup (log-based)
⸻
31.11 HARD RULES (ENFORCED)
From system contract:
- NO Prisma in Apps Script
- NO business logic in Apps Script
- compute ONLY in Next.js
- Apps Script = stateless executor
- exports NEVER mutate domain truth
⸻
31.12 RELATION TO TRIPS MODULE
From Trips system: Exports are:
- Trip → itinerary doc
- Offer → commercial proposal
- Order → confirmation package
They are:
- projections
- not truth
- not required for Trip existence
⸻
31.13 MINIMUM SHIPPABLE EXPORT SET
Implement ONLY:
- docs.create
- docs.exportPdf
- calendar.create (with externalKey)
Skip initially:
- sheets
- advanced drive structuring
- email automation
⸻
31.14 CRITICAL PRIORITY
priority: HIGH reason:
- closes commercial loop (offer delivery)
- enables real client interaction
- validates projection architecture
urgency vs core flow:
| Layer | Priority |
|---|---|
| Trip → Offer → Order | CRITICAL |
| Export Routes | CRITICAL (parallel) |
⸻
31.15 NEXT ACTION (STRICT)
Implement:
- tripToRenderBlocks.ts
- tripToCalendarEvents.ts
- exportTrip() action
- Apps Script docs.create
Test: → Trip → Doc + PDF + Calendar→ rerun (no duplicates)
STOP
⸻
RESULT
You now have:
- internal truth pipeline (Trip → Offer → Order)
- external projection pipeline (Docs / PDF / Calendar / Drive)
- clean separation of concerns
- deterministic, idempotent exports
- Google Workspace as execution layer, not logic layer
🟡 2. Offer Bridge — Trip → Offer → Order ✅ Done
Issue: Code has pricing preview, line-item projection, and add-to-offer controls, but create/attach offer behavior needs functional testing with real project/offer data.
Expected behavior: A planned trip can generate an offer preview, line items flow through, offer can be committed, and checkout creates Order with manual payment.
What exists: createOfferFromTrip(), checkoutOffer(), pricing engine, LineItem preview surface, manual payment flow.
Done when: Trip generates offer with complete line items; offer commit uses same pricing rule as quote; checkout creates Order (manual payment: bank transfer/PayPal/cash).
Status (2026-05-02): ✅ Complete flow implemented + aligned with Unified Execution Plan:
createOfferFromTrip()intrip-offer.actions.ts— creates offer from trip with line itemscheckoutOffer()inapp/orders/actions/checkoutOffer.ts— converts Offer → Order (manual payment)- Fixed bug: checkout now uses
trip.offerIdinstead oftrip.projectId - Server component fetches
associatedOfferand passesofferIdto client - Build passes ✅
- NEW (2026-05-02): Aligned with "Molino Critical Viability Sprint — Final Unified Plan":
- Core loop locked: Trip → Offer → Order → manual payment → confirmation
- 3 lanes: Trips (primary, 70%), Studio (cash support, 20%), PDFs (entry, 10%)
- No new systems: extract from existing work only
- Target (7-10 days): 1 real client, 1 confirmed order, 1 reusable proof
HERE ARE MORE NOTES ON THIS BRIDGE AND FURTHER OUTPUT NEEDS
Trips → Offer → Order — Micro Execution Map (Refined Atomic Version)
version: 2026.1
status: execution-ready
scope: trips / offers / orders / lineItems / document-sync
CORE FLOW (LOCKED)
TripDraft (Document) → Trip (DB snapshot) → LineItems (Document scope) → Offer (snapshot clone) → Order (final immutable snapshot) → Projection (PDF / Space / External)
🟡 Partial/In-Progress (Built But Not Locked)
Offer Bridge — Trip → Offer → LineItems ✅ Partial
quoteTripJoin()exists, pricing engine worksmapTripPricingToLineItems()includes hotel rows (when >40 days)- Missing: Canonical quote→Offer→Order path not locked;
createOfferFromTripnot functionally verified - Next: Browser verification of offer preview with line items
Hotel Policy Closure ✅ Partial
computeHotelPolicy()implemented inmapTripPricingToLineItems.ts- Policy embedded in line item meta
- Missing: UI wiring to show/hide hotel controls based on policy
- Next: Wire UI to show/hide hotel controls
Execution Closure — BookingBundle ✅ Partial
- Prisma models added (
BookingBundle,BookingItem) requestBooking()action creates bundle with pricing snapshotBookingBundlePanelUI component createdconfirmBookingItem()andupdateBookingBundleStatus()actions ready- Missing: DB migration deploy and browser verification
- Next: Deploy migration, integrate panel, verify flow
Apps Script Export Closure ✅ Partial
- ✅
/trips/[tripId]/export/pdf— PDF itinerary with day-by-day itinerary, line items, pricing. Added "Download PDF" button in trip detail UI. - ✅
/api/trips/[tripId]/export/etsy-pdf?type=itinerary|planner|pricing— Etsy-ready PDFs with upgrade link to/trips/new. - ✅ Etsy PDF buttons added to TripDetailClient (itinerary, planner, pricing templates).
- ⏳
/export/email,/export/calendar,/export/booking-doc— not yet built.
Aligned with Unified Execution Plan (2026-05-02):
- Lane C — PDF/Product Extraction (Entry Layer): 1-3 products max, extraction only (not new build), Etsy as distribution layer (not main business).
- Time allocation: 70% core pipeline / 20% extraction / 10% outreach.
- Target (7-10 days): 1 real client, 1 confirmed order, 1 reusable proof.
- Products ready: Granada 3-Day Itinerary, Andalusia Route Planner, Travel Pricing Template.
🔄 Dual Booking Architecture (Locked 2026-05-03)
Core Principle
You are NOT replacing FareHarbor. You are:
- Mirroring + extending it with your own system
- Building independence through dual paths
Two Booking Layers
| Layer | Role | When to Use |
|---|---|---|
| FareHarbor | Distribution + standardized booking | Featured, ready-to-book, standardized products |
| Molino internal | Flexibility + control + expansion | Custom trips, alternative booking, direct clients, bundles, PDFs, services |
Product Classification
Every commercial item (Trip / Experience / Product) must be tagged:
BookingMode:
fareharbor_only— Partner-run, FH handles allinternal_only— Custom trips, PDFs, servicesdual— Featured trips (both paths visible)
Examples:
- Featured Trip:
dual→ Shows "Book Instantly" (FH) + "Customise" (Molino) - Custom Trip:
internal_only→ Only Molino flow - Experience (partner):
fareharbor_onlyORdual - PDF / Digital Product:
internal_only
🔄 Trip ↔ Session Architecture (Final 2026-05-03)
Two-Stage Trip Management
Stage 1 — Trip Creation (always first, via /trips/new):
- Trip creates via existing stable pipeline → itinerary generated as side effect
- Content push: Itinerary →
/content-cards/new→ generatescontent-groups x N(caution: don't skip relation levels or create loops) - Trip already related to:
TripCity+TripCityExperience(orTripCityPlan)
Key Correction: It's TripCityExperience that hosts the weekly agenda experience truth for:
- Content layer (itineraries, PDFs, blueprints → separate channels)
- Scheduling layer (sessions, tours, city plans)
Stage 2 — Trip picks up experiences for:
- Logistics
- Deliverables
- All trip needs beyond logistics
Commercial Flow (Trip Override)
TripCityExperience (individual pricing)
↓ (bundled by Trip commercial flow)
Trip Package Price (per person/group booking)
↓
Featured Trip → FH Lightframe + Molino internal
Custom Trip → Molino internal only
Weekly City Agenda Content
Must list:
- Featured experience sessions (from
TripCityExperiencelinked to weekly agenda) - Trip arrival/departure options (exclusive to specific trips)
- Trip-exclusive sessions (not in regular featured city experiences list)
NOT listed:
- Duplicate sessions already present in featured city experiences
- Sessions that conflict with trip exclusivity
📘 Reference Material (Locked, Archived)
Remaining Phases (Reference)
Phase 1 — Public /trips ✅ Done
- Trips landing is deployable and commercially coherent
- Four lanes visible, CTAs route, builder-backed sections
Phase 2 — Read-only discovery routes ✅ Done
/trips/featured,/trips/cities,/trips/plans,/trips/network,/trips/updates,/trips/[tripId],/trips/[tripId]/schedule- Users can browse trips, cities, plans, and schedules without planner complexity
Phase 3 — Inline planner 🟡 Partial
- TripPlannerContext + normalizeStops in place
- Trip command surface implemented with server actions
- Missing: DB migration deployed to remote, authenticated browser verification of edit flows
Phase 4 — Pricing and offer bridge 🟡 Partial
- LineItem preview, quote engine exist
- Missing: hotel row handling, canonical quote→Offer→Order path, functional testing
Phase 5 — BookingBundle ⏳ Open
- Model defined in plan, not implemented in code
- Create BookingBundle, requestBooking, status UI, confirmation UI
Phase 6 — Apps Script exports ⏳ Open
/export/pdf,/export/email,/export/calendar,/export/booking-doc
Phase 7 — FareHarbor validation ⏳ Open
- Calendar availability adapter, external reference storage, manual confirmation bridge
Trips Locked — Reference Material
The following sections remain as implementation reference. They are not the active priority list.
Top Actionable Summary (archive)
If you only do the minimum useful pass in staging, do these in order:
- Open
/tripsand confirm the public front door renders cleanly. ✅ - Check that the four commercial lanes are visible near the top. ✅
- Click one CTA from each lane and verify every route lands somewhere real. ✅
- Open one trip detail and one schedule page to confirm read-only separation. ✅
- Open the planner surface and verify it stays private and separate from public browsing. ✅
- Click one booking button and confirm FareHarbor behaves as button/modal execution only. 🟡
- Scroll to the footer and confirm there is a visible bridge back to
/studio. ✅
Product Truth
Trips is the public commercial surface of Molino. Externally: Al-Andalus Experience → trips → city experiences → shared departures → local sessions → travel planning → partner network. Internally: Trip = bundled commercial plan, SessionCard/Experience/GroupSession = underlying sellable units, Planner = deterministic authoring surface, Booking = execution, not trip truth.
Dual Narrative
Narrative A — Traveller / group organizer: "I want to plan, join, price, share, or book a trip." Routes: /trips, /trips/plan, /trips/featured, /trips/[tripId], /trips/[tripId]/schedule, /trips/planner/[draftId]
Narrative B — local expert / travel professional / provider: "I want to collaborate, provide services, list plans, or work with your local network." Routes: /trips/network, /trips/cities, /trips/plans, /trips/updates
Shared bridge: Experience / Plan → GroupSession → Trip → LineItems → Offer → Order → Projection
Final Route Structure
app/(pages)/trips/
├── page.tsx // public Al-Andalus Experience landing
├── plan/page.tsx // plan your private trip CTA surface
├── featured/page.tsx // shared departures / featured group trips
├── cities/page.tsx // city discovery
├── cities/[citySlug]/page.tsx // city detail + city plans
├── plans/page.tsx // daytime/local plans
├── plans/[planSlug]/page.tsx // plan detail / add to trip / book button
├── network/page.tsx // travel pros + local providers
├── updates/page.tsx // articles, news, shout-outs
├── [tripId]/page.tsx // public trip detail
├── [tripId]/schedule/page.tsx // public schedule / calendar / agenda
└── planner/
├── page.tsx // create planner draft
└── [draftId]/page.tsx // inline trip planner editor
Final Module Boundary
app/(pages)/trips/
├── page.tsx // read-only orchestration
├── featured/page.tsx
├── plan/page.tsx
├── planner/
├── cities/
├── plans/
├── network/
├── updates/
├── [tripId]/
├── actions/ // Prisma + mutation authority
├── adapters/ // legacy/FareHarbor payloads into Molino view models
├── components/ // dumb UI only
├── context/ // ephemeral planner UI state only
├── types/
└── api/ // external integrations only
Rules
page.tsx→ read-only orchestrationactions/→ Prisma + mutation authoritycomponents/→ dumb UI onlycontext/→ ephemeral planner UI state onlyapi/→ external integrations onlyadapters/→ legacy/FareHarbor payloads into Molino view models
FareHarbor Rules
- Buttons only
- No calendar embeds
- No item-grid embeds
- No widget rendering as React children
- Use FH booking URLs or button targets only
Authority Model
page.tsxreads and orchestrates onlyapp/trips/actions/owns mutation logicapp/trips/api/stays for external access only- TripPricing and downstream line items remain explicit economic layers
Current Implementation Surface
app/trips/actions/trips.read.actions.tsapp/trips/actions/tripsLanding.actions.tsapp/trips/actions/trips.planner.read.actions.tsapp/trips/actions/trips.planner.edit.read.actions.tsapp/trips/actions/trip-join.create.tsapp/trips/actions/trip-join.recompute.tsapp/trips/actions/trip.update.actions.tsapp/trips/actions/createTripDraft.tsapp/trips/components/app/trips/new/app/trips/[tripId]/
Acceptance Criteria
- Public trip routes render without runtime errors
- Draft and public views stay separated
- Planner and join flows remain explicit
- Trip pricing continues to feed line items through a controlled bridge
- Trips shows the four commercial lanes clearly
- Trips uses native booking cards with FH button execution only
- Trips footer bridges back to Studio
- Featured experiences remain visible as support input, not as a second primary hub
Locked Summary
Trips is the stable Molino module for trip discovery, planning, join flows, and commercial projection. It reads canonical trip data, keeps mutation authority in server actions, preserves the booking and pricing boundary, uses FareHarbor only as a button-driven execution layer, and treats experiences as supporting ingredients rather than the core execution domain.
📋 Spaces Feature — Final Build Spec for a New AI Coding Assistant
One-glance execution Build a new Spaces domain. Goal: A route-safe, registry-rendered page builder/projection layer.
Build first:
- Prisma models
- Route alias resolver
- Public renderer
- Publish validation gate
- Minimal editor
- Native slider
- Admin-only imported HTML slider
Do not build first:
- full CMS
- arbitrary JS/CSS editor
- drag-anywhere layout engine
- scheduled publishing logic
- rollback UI
- multi-site routing
Core rule: Spaces = editable page authority + public route aliases + registry-rendered sections.
Spaces must not replace Trips, Offers, Orders, Documents, or LineItems. It is a projection/landing/proposal layer.
⸻
1. Existing codebase assumptions
This codebase follows strict vertical-slice architecture: page.tsx = read-only server orchestrator actions/ = sole Prisma / mutation authority components/ = dumb UI, calls server actions api/route.ts = external integrations only types/ = domain truth registry/ = render map / section definitions
Hard rules:
- No Prisma outside actions.
- No mutations in pages.
- No internal plumbing through API routes.
- No business logic in Apps Script.
- Compute in Next.js.
- External systems are projections, not truth.
This matches the existing app architecture where Prisma is truth, Actions own mutation, and projection layers produce external/public outputs.
⸻
2. What Spaces is
Definition: A Space is an editable page built from registry sections and exposed through one or more route aliases.
Examples: Internal edit route: /spaces/abc123/edit
Public aliases: /ramadan-granada /trips/granada-heritage-week /partners/cordoba-guides /studio/spring-garden-blueprint
Role: Spaces can power:
- marketing landing pages
- trip campaign pages
- partner pages
- offer/proposal pages
- product/PDF sales pages
- user/account pages
- future projected entity pages
Non-role: Spaces must not become:
- Trip truth
- Offer truth
- Order truth
- payment truth
- pricing authority
- document/Folio replacement
- unrestricted CMS
Trips, Offers, Orders, and LineItems already have a commercial flow where LineItems become the transferable pricing backbone and Offer/Order snapshots preserve commercial state.
⸻
3. Folder structure
Create a new route group:
app/
(spaces)/
spaces/
page.tsx
new/
page.tsx
[spaceId]/
page.tsx
edit/
page.tsx
actions/
createSpace.ts
getSpaces.ts
getSpaceById.ts
updateSpace.ts
updateSpaceSection.ts
reorderSpaceSections.ts
updateSpaceRoute.ts
publishSpace.ts
unpublishSpace.ts
resolveSpaceRoute.ts
components/
SpaceCanvas.tsx
SpaceEditor.tsx
SpacePublishPanel.tsx
SpaceRouteEditor.tsx
SpaceSectionPicker.tsx
SpaceSectionRenderer.tsx
sections/
HeroSection.tsx
RichTextSection.tsx
CardsSection.tsx
CtaSection.tsx
NativeSliderSection.tsx
ImportedHtmlSliderSection.tsx
registry/
spaceSectionRegistry.ts
types/
space.ts
sections.ts
validation/
routeValidation.ts
sectionValidation.ts
validateSpaceForPublish.ts
[...slug]/
page.tsx
Important: app/(spaces)/spaces/... = management/editor routes app/(spaces)/[...slug]/page.tsx = final public alias resolver
The catch-all route is not the router. It is only the final alias resolver.
⸻
4. Prisma schema
Add these enums and models.
enum SpaceStatus {
draft
published
scheduled
archived
broken
}
enum SpaceVisibility {
private
unlisted
public
}
enum SpaceRouteKind {
root
domain
private_preview
}
enum SpaceSectionValidationStatus {
valid
invalid
warning
}
model Space {
id String @id @default(cuid())
ownerId String?
createdById String?
title String
slug String
description String?
status SpaceStatus @default(draft)
visibility SpaceVisibility @default(private)
version Int @default(1)
locale String? @default("en")
data Json?
lastValidationError Json?
publishedAt DateTime?
archivedAt DateTime?
scheduledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sections SpaceSection[]
routes SpaceRouteAlias[]
@@index([ownerId])
@@index([createdById])
@@index([status])
@@index([visibility])
@@unique([ownerId, slug])
}
model SpaceSection {
id String @id @default(cuid())
spaceId String
key String
type String
order Int @default(0)
enabled Boolean @default(true)
content Json
settings Json?
validationStatus SpaceSectionValidationStatus @default(valid)
validationErrors Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
@@index([spaceId])
@@index([type])
@@index([order])
@@unique([spaceId, key])
}
model SpaceRouteAlias {
id String @id @default(cuid())
spaceId String
path String @unique
pathFingerprint String @unique
kind SpaceRouteKind @default(root)
enabled Boolean @default(true)
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
@@index([spaceId])
@@index([enabled])
@@index([kind])
}
Notes:
- path = human-readable normalized public path.
- pathFingerprint = deterministic lookup key.
- key on SpaceSection must be unique per Space.
- version exists now, rollback UI later.
- scheduledAt exists now, scheduling engine later.
⸻
5. Route alias policy
Route kinds: root: Example: /ramadan-granada /spring-garden-blueprint
domain: Example: /trips/granada-heritage-week /partners/cordoba-guides
private_preview: Example: /preview/space/abc123
Capability policy:
export const SPACE_ROUTE_POLICY = {
root: {
requiredRole: "user",
publicIndexable: true,
},
domain: {
requiredRole: "operator",
publicIndexable: true,
},
private_preview: {
requiredRole: "operator",
publicIndexable: false,
},
} as const;
Use the project's existing role/capability system if present. If not, implement role checks minimally and keep them isolated.
⸻
6. Route validation
Create:
app/(spaces)/spaces/validation/routeValidation.ts
export const RESERVED_EXACT_PATHS = [
"/",
"/trips",
"/trips/new",
"/trips/featured",
"/trips/professionals",
"/studio",
"/md",
"/spaces",
"/offers",
"/orders",
];
export const HARD_RESERVED_ROOTS = [
"api",
"auth",
"admin",
"settings",
"documents",
"md",
"spaces",
"offers",
"orders",
];
export const DOMAIN_ALIAS_ROOTS = [
"trips",
"experiences",
"studio",
"partners",
"cities",
];
export function normalizeSpacePath(input: string): string {
const raw = input.startsWith("/") ? input : `/${input}`;
const normalized = raw
.replace(/\/+/g, "/")
.replace(/\/$/, "")
.toLowerCase();
return normalized === "" ? "/" : normalized;
}
export function createSpacePathFingerprint(path: string): string {
return normalizeSpacePath(path);
}
export function getPathRoot(path: string): string {
return normalizeSpacePath(path).split("/").filter(Boolean)[0] ?? "";
}
export function assertValidSpacePath(pathInput: string) {
const path = normalizeSpacePath(pathInput);
if (!path.startsWith("/")) {
throw new Error("Path must start with /");
}
if (path === "/") {
throw new Error("Root path cannot be assigned to a Space");
}
if (path.length > 180) {
throw new Error("Path is too long");
}
if (path.includes(" ")) {
throw new Error("Path cannot contain spaces");
}
if (path.includes("//")) {
throw new Error("Path cannot contain double slashes");
}
if (path !== path.toLowerCase()) {
throw new Error("Path must be lowercase");
}
if (RESERVED_EXACT_PATHS.includes(path)) {
throw new Error(`Path is reserved: ${path}`);
}
const root = getPathRoot(path);
if (HARD_RESERVED_ROOTS.includes(root)) {
throw new Error(`Path root is reserved: ${root}`);
}
return path;
}
export function inferSpaceRouteKind(pathInput: string): "root" | "domain" {
const path = normalizeSpacePath(pathInput);
const root = getPathRoot(path);
return DOMAIN_ALIAS_ROOTS.includes(root) ? "domain" : "root";
}
⸻
7. Route resolver
Create:
app/(spaces)/spaces/actions/resolveSpaceRoute.ts
Rules:
- Normalize requested path.
- Reject invalid/reserved paths.
- Check exact route collisions.
- Check domain route policy.
- Look up SpaceRouteAlias by pathFingerprint.
- Require alias.enabled = true.
- Require Space.status = published.
- Require visibility = public or unlisted.
- Return render payload.
- Otherwise return null / notFound.
Implementation shape:
"use server";
import { prisma } from "@/lib/prisma";
import {
assertValidSpacePath,
createSpacePathFingerprint,
inferSpaceRouteKind,
normalizeSpacePath,
} from "../validation/routeValidation";
export async function resolveSpaceRoute(pathInput: string) {
const path = normalizeSpacePath(pathInput);
try {
assertValidSpacePath(path);
} catch {
return null;
}
const pathFingerprint = createSpacePathFingerprint(path);
const kind = inferSpaceRouteKind(path);
const alias = await prisma.spaceRouteAlias.findUnique({
where: { pathFingerprint },
include: {
space: {
include: {
sections: {
where: { enabled: true },
orderBy: { order: "asc" },
},
routes: true,
},
},
},
});
if (!alias) return null;
if (!alias.enabled) return null;
if (alias.kind !== kind) return null;
const space = alias.space;
if (space.status !== "published") return null;
if (!["public", "unlisted"].includes(space.visibility)) return null;
return {
space,
alias,
};
}
⸻
8. Public catch-all page
Create:
app/(spaces)/[...slug]/page.tsx
import { notFound } from "next/navigation";
import { resolveSpaceRoute } from "../spaces/actions/resolveSpaceRoute";
import { SpaceCanvas } from "../spaces/components/SpaceCanvas";
export default async function PublicSpaceAliasPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug = [] } = await params;
const path = `/${slug.join("/")}`;
const result = await resolveSpaceRoute(path);
if (!result) {
notFound();
}
return <SpaceCanvas space={result.space} mode="public" />;
}
Important: This page must not mutate. This page must not import Prisma directly. This page must not contain section logic.
⸻
9. Section registry
Create:
app/(spaces)/spaces/registry/spaceSectionRegistry.ts
Each registry entry must define: type, label, description, Component, defaultContent, contentSchema, settingsSchema
Example:
import { z } from "zod";
import { HeroSection } from "../components/sections/HeroSection";
import { RichTextSection } from "../components/sections/RichTextSection";
import { CardsSection } from "../components/sections/CardsSection";
import { CtaSection } from "../components/sections/CtaSection";
import { NativeSliderSection } from "../components/sections/NativeSliderSection";
import { ImportedHtmlSliderSection } from "../components/sections/ImportedHtmlSliderSection";
const ctaSchema = z.object({
label: z.string().min(1).max(80),
href: z.string().min(1).max(240),
});
export const heroContentSchema = z.object({
eyebrow: z.string().max(120).optional(),
title: z.string().min(1).max(180),
subtitle: z.string().max(600).optional(),
primaryCtaLabel: z.string().max(80).optional(),
primaryCtaHref: z.string().max(240).optional(),
secondaryCtaLabel: z.string().max(80).optional(),
secondaryCtaHref: z.string().max(240).optional(),
backgroundImage: z.string().url().optional(),
});
export const richTextContentSchema = z.object({
title: z.string().max(180).optional(),
body: z.string().min(1).max(5000),
});
export const cardsContentSchema = z.object({
title: z.string().max(180).optional(),
subtitle: z.string().max(600).optional(),
cards: z
.array(
z.object({
id: z.string().min(1),
title: z.string().min(1).max(160),
body: z.string().max(800).optional(),
ctaLabel: z.string().max(80).optional(),
href: z.string().max(240).optional(),
imageUrl: z.string().url().optional(),
})
)
.max(12),
});
export const ctaContentSchema = z.object({
title: z.string().min(1).max(180),
body: z.string().max(800).optional(),
ctas: z.array(ctaSchema).max(4),
});
export const nativeSliderContentSchema = z.object({
slides: z
.array(
z.object({
id: z.string().min(1),
eyebrow: z.string().max(120).optional(),
title: z.string().min(1).max(160),
subtitle: z.string().max(500).optional(),
imageUrl: z.string().url().optional(),
videoUrl: z.string().url().optional(),
primaryCtaLabel: z.string().max(80).optional(),
primaryCtaHref: z.string().max(240).optional(),
secondaryCtaLabel: z.string().max(80).optional(),
secondaryCtaHref: z.string().max(240).optional(),
})
)
.max(12),
autoplay: z.boolean().default(true),
intervalMs: z.number().int().min(1500).max(20000).default(5000),
});
export const importedHtmlSliderContentSchema = z.object({
html: z.string().min(1).max(100_000),
originalHtml: z.string().max(100_000).optional(),
assets: z
.array(
z.object({
url: z.string().url(),
kind: z.enum(["image", "css", "video", "other"]),
})
)
.max(50)
.optional(),
});
export const emptySettingsSchema = z.object({}).passthrough();
export const spaceSectionRegistry = {
hero: {
type: "hero",
label: "Hero",
description: "Primary landing section",
Component: HeroSection,
contentSchema: heroContentSchema,
settingsSchema: emptySettingsSchema,
defaultContent: {
eyebrow: "Molino",
title: "Build a focused landing page",
subtitle: "A fast page for one offer, product, event, trip, or user space.",
primaryCtaLabel: "Start",
primaryCtaHref: "#start",
},
},
rich_text: {
type: "rich_text",
label: "Text",
description: "Simple text section",
Component: RichTextSection,
contentSchema: richTextContentSchema,
settingsSchema: emptySettingsSchema,
defaultContent: {
title: "Section title",
body: "Write your section body here.",
},
},
cards: {
type: "cards",
label: "Cards",
description: "Card grid section",
Component: CardsSection,
contentSchema: cardsContentSchema,
settingsSchema: emptySettingsSchema,
defaultContent: {
title: "Choose your path",
cards: [],
},
},
cta: {
type: "cta",
label: "CTA",
description: "Call-to-action section",
Component: CtaSection,
contentSchema: ctaContentSchema,
settingsSchema: emptySettingsSchema,
defaultContent: {
title: "Ready to continue?",
body: "Choose the next step.",
ctas: [],
},
},
slider_native: {
type: "slider_native",
label: "Native Slider",
description: "Safe internal slider",
Component: NativeSliderSection,
contentSchema: nativeSliderContentSchema,
settingsSchema: emptySettingsSchema,
defaultContent: {
slides: [],
autoplay: true,
intervalMs: 5000,
},
},
slider_html: {
type: "slider_html",
label: "Imported HTML Slider",
description: "Admin-only sanitized HTML slider import",
Component: ImportedHtmlSliderSection,
contentSchema: importedHtmlSliderContentSchema,
settingsSchema: emptySettingsSchema,
defaultContent: {
html: "",
assets: [],
},
},
} as const;
export type SpaceSectionType = keyof typeof spaceSectionRegistry;
⸻
10. Section renderer
Create:
app/(spaces)/spaces/components/SpaceSectionRenderer.tsx
import { spaceSectionRegistry } from "../registry/spaceSectionRegistry";
type Props = {
section: {
id: string;
key: string;
type: string;
content: unknown;
settings?: unknown;
};
};
export function SpaceSectionRenderer({ section }: Props) {
const definition =
spaceSectionRegistry[section.type as keyof typeof spaceSectionRegistry];
if (!definition) {
return null;
}
const parsedContent = definition.contentSchema.safeParse(section.content);
const parsedSettings = definition.settingsSchema.safeParse(
section.settings ?? {}
);
if (!parsedContent.success || !parsedSettings.success) {
return null;
}
const Component = definition.Component;
return (
<Component
content={parsedContent.data}
settings={parsedSettings.data}
/>
);
}
Renderer rule: Content never chooses components. The registry chooses components. Invalid section payloads render nothing publicly. Publish validation should normally prevent this from happening.
⸻
11. Space canvas
Create:
app/(spaces)/spaces/components/SpaceCanvas.tsx
import { SpaceSectionRenderer } from "./SpaceSectionRenderer";
type SpaceCanvasProps = {
space: {
title: string;
sections: Array<{
id: string;
key: string;
type: string;
content: unknown;
settings?: unknown;
}>;
};
mode?: "public" | "preview" | "edit";
};
export function SpaceCanvas({ space }: SpaceCanvasProps) {
return (
<main className="min-h-screen w-full">
{space.sections.map((section) => (
<SpaceSectionRenderer key={section.id} section={section} />
))}
</main>
);
}
⸻
12. Publish validation
Create:
app/(spaces)/spaces/validation/validateSpaceForPublish.ts
Publish must validate:
- Space has a title.
- Space has at least one enabled route.
- Space has one primary enabled route.
- Each enabled route path is valid.
- Each route fingerprint is normalized.
- No route conflicts with hard reserved paths.
- Domain aliases require operator/admin capability.
- Space has at least one enabled section.
- Every enabled section type exists in registry.
- Every enabled section content passes contentSchema.
- Every enabled section settings passes settingsSchema.
Example:
import { spaceSectionRegistry } from "../registry/spaceSectionRegistry";
import {
assertValidSpacePath,
createSpacePathFingerprint,
} from "./routeValidation";
type PublishValidationResult =
| { ok: true }
| { ok: false; message: string; errors: unknown[] };
export function validateSpaceForPublish(space: any): PublishValidationResult {
const errors: unknown[] = [];
if (!space.title || String(space.title).trim().length < 1) {
errors.push({ field: "title", message: "Title is required" });
}
const enabledRoutes = (space.routes ?? []).filter((r: any) => r.enabled);
if (enabledRoutes.length < 1) {
errors.push({ field: "routes", message: "At least one route is required" });
}
if (!enabledRoutes.some((r: any) => r.isPrimary)) {
errors.push({
field: "routes",
message: "One enabled route must be primary",
});
}
for (const route of enabledRoutes) {
try {
const normalized = assertValidSpacePath(route.path);
const fingerprint = createSpacePathFingerprint(normalized);
if (fingerprint !== route.pathFingerprint) {
errors.push({
field: "pathFingerprint",
path: route.path,
message: "Route fingerprint does not match normalized path",
});
}
} catch (error) {
errors.push({
field: "route",
path: route.path,
message: error instanceof Error ? error.message : "Invalid route",
});
}
}
const enabledSections = (space.sections ?? []).filter((s: any) => s.enabled);
if (enabledSections.length < 1) {
errors.push({
field: "sections",
message: "At least one enabled section is required",
});
}
for (const section of enabledSections) {
const definition =
spaceSectionRegistry[
section.type as keyof typeof spaceSectionRegistry
];
if (!definition) {
errors.push({
field: "section.type",
sectionKey: section.key,
type: section.type,
message: "Unknown section type",
});
continue;
}
const content = definition.contentSchema.safeParse(section.content);
const settings = definition.settingsSchema.safeParse(
section.settings ?? {}
);
if (!content.success) {
errors.push({
field: "section.content",
sectionKey: section.key,
message: content.error.flatten(),
});
}
if (!settings.success) {
errors.push({
field: "section.settings",
sectionKey: section.key,
message: settings.error.flatten(),
});
}
}
if (errors.length > 0) {
return {
ok: false,
message: "Space failed publish validation",
errors,
};
}
return { ok: true };
}
⸻
13. Publish action
Create:
app/(spaces)/spaces/actions/publishSpace.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { validateSpaceForPublish } from "../validation/validateSpaceForPublish";
export async function publishSpace(spaceId: string) {
const space = await prisma.space.findUnique({
where: { id: spaceId },
include: {
sections: { orderBy: { order: "asc" } },
routes: true,
},
});
if (!space) {
throw new Error("Space not found");
}
const result = validateSpaceForPublish(space);
if (!result.ok) {
await prisma.space.update({
where: { id: spaceId },
data: {
status: "broken",
lastValidationError: result.errors as any,
},
});
throw new Error(result.message);
}
const updated = await prisma.space.update({
where: { id: spaceId },
data: {
status: "published",
visibility: "public",
publishedAt: new Date(),
lastValidationError: null as any,
version: { increment: 1 },
},
include: {
routes: true,
sections: { orderBy: { order: "asc" } },
},
});
for (const route of updated.routes) {
if (route.enabled) {
revalidatePath(route.path);
}
}
return updated;
}
⸻
14. Update route action
Create:
app/(spaces)/spaces/actions/updateSpaceRoute.ts
"use server";
import { prisma } from "@/lib/prisma";
import {
assertValidSpacePath,
createSpacePathFingerprint,
inferSpaceRouteKind,
normalizeSpacePath,
} from "../validation/routeValidation";
export async function updateSpaceRoute(input: {
routeId?: string;
spaceId: string;
path: string;
isPrimary?: boolean;
enabled?: boolean;
}) {
const normalizedPath = assertValidSpacePath(input.path);
const pathFingerprint = createSpacePathFingerprint(normalizedPath);
const kind = inferSpaceRouteKind(normalizedPath);
if (input.isPrimary) {
await prisma.spaceRouteAlias.updateMany({
where: { spaceId: input.spaceId },
data: { isPrimary: false },
});
}
if (input.routeId) {
return prisma.spaceRouteAlias.update({
where: { id: input.routeId },
data: {
path: normalizedPath,
pathFingerprint,
kind,
isPrimary: input.isPrimary ?? false,
enabled: input.enabled ?? true,
},
});
}
return prisma.spaceRouteAlias.create({
data: {
spaceId: input.spaceId,
path: normalizedPath,
pathFingerprint,
kind,
isPrimary: input.isPrimary ?? false,
enabled: input.enabled ?? true,
},
});
}
⸻
15. Section save action
Create:
app/(spaces)/spaces/actions/updateSpaceSection.ts
"use server";
import { prisma } from "@/lib/prisma";
import { spaceSectionRegistry } from "../registry/spaceSectionRegistry";
export async function updateSpaceSection(input: {
sectionId: string;
type: string;
content: unknown;
settings?: unknown;
}) {
const definition =
spaceSectionRegistry[input.type as keyof typeof spaceSectionRegistry];
if (!definition) {
throw new Error(`Unknown section type: ${input.type}`);
}
const content = definition.contentSchema.parse(input.content);
const settings = definition.settingsSchema.parse(input.settings ?? {});
return prisma.spaceSection.update({
where: { id: input.sectionId },
data: {
type: input.type,
content: content as any,
settings: settings as any,
validationStatus: "valid",
validationErrors: null as any,
},
});
}
⸻
16. Minimal editor scope
Build only enough to manage Spaces.
Routes: /spaces /spaces/new /spaces/[spaceId] /spaces/[spaceId]/edit
Editor v1 features:
- create Space
- edit title/description
- add section from registry
- edit section JSON/form fields
- reorder sections
- enable/disable section
- add route alias
- set primary route
- publish/unpublish
- preview public rendering
Do not build:
- drag-anywhere layout
- arbitrary CSS editor
- animation timeline builder
- user JS injection
- full template marketplace
⸻
17. Native slider
Build this before imported HTML.
Section type:
slider_native
Purpose: safe internal Slider Revolution-style section.
Content:
export type NativeSliderSectionContent = {
slides: Array<{
id: string;
eyebrow?: string;
title: string;
subtitle?: string;
imageUrl?: string;
videoUrl?: string;
primaryCtaLabel?: string;
primaryCtaHref?: string;
secondaryCtaLabel?: string;
secondaryCtaHref?: string;
}>;
autoplay?: boolean;
intervalMs?: number;
};
Use client-side component only if needed for interaction.
⸻
18. Imported HTML slider
Build later, admin-only.
Section type:
slider_html
Rules:
- admin/operator only
- sanitize on save
- sanitize on render
- strip scripts
- strip event handlers
- reject unsafe embeds
- record original HTML separately if needed
- do not allow general users to create/edit this section
Component shape:
"use client";
import DOMPurify from "isomorphic-dompurify";
export function ImportedHtmlSliderSection({
content,
}: {
content: { html: string };
}) {
const cleanHtml = DOMPurify.sanitize(content.html ?? "", {
USE_PROFILES: { html: true },
ADD_ATTR: ["target", "rel", "loading"],
});
return (
<section className="w-full overflow-hidden">
<div
className="space-slider-html"
dangerouslySetInnerHTML={{ __html: cleanHtml }}
/>
</section>
);
}
⸻
19. Future projection bridge
Do not build first, but keep the structure ready.
Future generators: Trip → Space Experience → Space Offer → private Space Order → confirmation Space Partner → Space Product/PDF → Space
This should follow the existing projection model: canonical entity → compute bundle / section stack → create Space → attach route alias → publish after validation
This aligns with the current project rule that Trips and other entities compute projections without giving projection targets domain authority.
Trips are already defined as the public/commercial surface with landing, featured trips, planner, details, schedules, partners, cities, and experiences discovery. Spaces should eventually help generate these pages, not replace the Trips module.
⸻
20. Acceptance checklist
Phase 1 accepted when:
- Prisma migration applies.
- Space can be created.
- SpaceSection can be created.
- SpaceRouteAlias can be created.
- Route path is normalized.
- pathFingerprint is stored.
- reserved routes are rejected.
- duplicate aliases are rejected.
Phase 2 accepted when:
- /some-space renders published Space.
- unpublished Space returns notFound.
- broken Space returns notFound.
- disabled alias returns notFound.
- invalid section type does not crash public render.
Phase 3 accepted when:
- publishSpace validates all sections.
- publishSpace validates all routes.
- failed publish sets status = broken.
- failed publish stores lastValidationError.
- successful publish sets status = published.
- successful publish revalidates aliases.
Phase 4 accepted when:
- /spaces lists Spaces.
- /spaces/new creates Space.
- /spaces/[spaceId]/edit edits metadata.
- editor can add/reorder/disable sections.
- editor can add/update route aliases.
- editor can publish.
Phase 5 accepted when:
- slider_native renders slides.
- slider_native passes Zod validation.
- invalid slider content cannot publish.
Phase 6 accepted when:
- slider_html is admin-only.
- imported HTML is sanitized.
- scripts/event handlers are stripped.
- public render does not execute arbitrary JS.
⸻
21. Critical warnings for the building assistant
Do not import Prisma into page.tsx or components. Do not mutate from page.tsx. Do not route internal app logic through api/route.ts. Do not let Space content choose React components. Do not allow arbitrary scripts. Do not let catch-all aliases hijack app routes. Do not make Spaces the source of truth for Trips, Offers, Orders, or payments. Do not build a full CMS before the resolver and renderer are proven.
⸻
22. Final instruction to implement
Start with this exact first slice:
- Add Prisma enums/models.
- Generate Prisma client.
- Create routeValidation.ts.
- Create spaceSectionRegistry.ts with hero/rich_text/cards/cta/slider_native.
- Create resolveSpaceRoute.ts.
- Create SpaceCanvas.tsx.
- Create SpaceSectionRenderer.tsx.
- Create app/(spaces)/[...slug]/page.tsx.
- Create publishSpace.ts with validation.
- Add minimal create/update actions.
Then test with seeded Space: Space: title = "Test Space" status = published visibility = public
Route: path = "/test-space" pathFingerprint = "/test-space" kind = root enabled = true isPrimary = true
Sections: hero rich_text cta
Expected result: /test-space renders the Space. Invalid aliases fail. Unpublished Spaces do not render. Broken Spaces do not render. Invalid sections do not crash. Publish validation blocks broken content.
Final priority: Criticality: Medium-high Urgency: Medium Build size: Controlled if limited to resolver + renderer + publish gate Commercial value: High once connected to Trips / Offers / Experiences
Recommended now: Build foundation only, not full editor polish
🟡 Next Steps (from Legacy Port)
This Week
-
Clone Trip Action
- Create
app/trips/actions/cloneTrip.ts - Deep copy: Trip → Cities → Plans → Settings
- Create
-
City Plan Binding
- Create
app/trips/actions/addCityPlan.ts - Bind Experience to TripCity
- Create
-
Booking with Stripe
- Adapt
createBookingActionfrom legacy - Create BookingBundle → Stripe checkout
- Adapt
Next Week
-
Trip Sharing via Email
- Invite flow with email
- Invitee joins → recalculates
-
Hotels Lane Decision
- Estimate vs FH vs External links
- UI toggle based on policy
AI-Assisted Sidebar Pattern — Structured Function Pipeline
Added: 2026-04-30. This documents the standardized pattern for LLM-assisted form filling and entity creation across sections (documents, trips, mlv, experiences, etc.). The LLM only fills forms; actual work is done by Next.js actions and Prisma.
Core Pattern (Same Across All Sections)
| Step | What Happens | Who's Responsible |
|---|---|---|
| 1. User prompt | Natural language request | Human user |
| 2. Parse + structure | LLM converts prompt → form data | useAIEngine() context |
| 3. Form fill | Merge structured data into form state | Section-specific mapper function |
| 4. Validate | Run form validation rules | Section form logic |
| 5. Execute action | Create/update entity in database | Prisma + Next.js action |
| 6. Redirect | Navigate to entity surface | Router |
Existing Implementations
documents/ — ChatPanel
- File:
app/documents/components/doc-sidebar/features/ChatPannel.tsx - Handler:
applyAssistantToDocument(documentId, userId, persona, mode, prompt) - Context:
useDocument()— provides document state andemitIntent() - Form:
TripPlannerSidebarForm(in documents, not trips) - Flow: prompt →
applyAssistantToDocument→ document update → redirect
trips/new/ — TripPlannerAssistantPanel
- File:
app/trips/new/components/TripPlannerAssistantPanel.tsx - Handler:
planTripWithAssistant(prompt)→mergeAIIntentIntoForm→createTripFromPlanner - Context:
useTripPlanner()— provides cities, form, step, mode - Form: Multi-step stepper (Cities → Options → Hotels → Summary)
- Flow: prompt → LLM parses → form merge → validation → create action → redirect to
/trips/[tripId]
Standardization Targets
| Component | Current | Target |
|---|---|---|
ChatPanel | documents only | Reusable as AIFormPanel |
| Handler hook | Per-section | Extract shared useAIFormHandler |
| Context | useDocument(), useTripPlanner() | Shared useRouteContext() with routeScope |
| Persona selector | Hardcoded per panel | Canonical single source |
| Action functions | Per-section | Generic entity action factory |
Standardized Interface Shape
// Proposed shared hook
function useAIFormHandler({
routeScope, // "trips" | "documents" | "mlv" | "experiences"
routeSurface, // "planner" | "editor" | "browse" | "detail"
entityType, // "Trip" | "Document" | "MLV" | "Experience"
parsePrompt, // LLM → form data
createEntity, // Prisma create action
updateEntity, // Prisma update action
}) {
// Returns: handleSubmit, isPending, error, success
}
Key Files to Standardize
-
Shared:
/app/components/SidebarShell.tsx✅ Extracted/app/components/AIFormPanel.tsx✅ Extracted/app/hooks/useRouteContext.ts— unify route context (not done)/app/assistant/context/AssistantContext.ts— addrouteScope,routeSurface(not done)
-
Per-section (update to use shared):
app/documents/components/doc-sidebar/features/ChatPannel.tsxapp/trips/new/components/TripPlannerAssistantPanel.tsxapp/mlv/*/components/*AssistantPanel.tsx(if exists)app/experiences/*/components/*AssistantPanel.tsx(if exists)
CSS + Styling Standardization
- Sidebar shell: Apply
EditorialThemestyles from documents to all sections - Card patterns: One page per entity, consistent paper-tints
- Chat panel: Same bottom-dock pattern across routes
- Toolbar: Shared tools registry structure
Silk Route Engine — Audit & Integration Map
Added: 2026-04-30. The Silk Route Engine is the thematisation/gamification layer that connects backend operations to client tour sessions, cities, and routes. It's the living calendar where Trips + FIT merge into shared SessionInstances.
Trip Sharing & Joining Logic
Trip Types & Join Behavior
| Trip Type | Recalculates on Join? | Join Button | Sharing |
|---|---|---|---|
| Featured (fixed price) | ❌ No — fixed price | FH modal | Public link |
| Private owned by you | ✅ Yes — recalculates | TripJoinPanel recalc | Email + user |
| Private shared to you | ✅ Yes — recalculates | View + join | Email invitation |
TripJoinPanel (app/trips/[tripId]/TripJoinPanel.tsx)
- Already has:
numParticipants, room selection, pricing inputs - Recalculates totals when pax/rooms change
- To implement: Wire
pricing.totalWithMarkuprecalculation on form change
Join Flow Recalculation
// On numParticipants/room change:
const recalculated = await tripEngine({
...tripInput,
numPax: numParticipants,
numDoubleRooms,
numSingleRooms,
});
// Returns new totalWithMarkup
Private Trip Sharing (planned)
- Owner shares via email → invitee gets access link
- Invitee signs in (or creates account)
- Invitee sees trip with "Join" button
- When joined → totals recalculate for new group size
What Already Exists in Schema (✅ Built)
| Model | Status | Role in Silk Route Engine |
|---|---|---|
CityAgendaWeek | ✅ Schema + seed | Per-city weekly template (Granada, Córdoba, etc.) |
CityAgendaSlot | ✅ Schema + seed | Weekday/time slot linked to Experience |
GroupSession | ✅ Schema + seed | Scheduled instance (date, time, capacity, guide) |
GroupSessionAttendee | ✅ Schema | Trip pax + FIT pax merged into same session |
Experience | ✅ Schema | Acts as ExperienceTemplate (title, price, duration, city) |
SessionStack | ✅ Schema + seed | Multi-session journey container |
SessionPath | ✅ Schema | Sequence of sessions in a stack |
SessionPathItem | ✅ Schema | Individual step in a session path |
BookingBundle + BookingItem | ✅ Just added | Execution separation from Trip planning |
City | ✅ Schema | Cities have agendaWeeks relation |
What's Missing (⏳ Not Built)
| Layer | Status | What It Should Do |
|---|---|---|
| SessionInstance generation engine | ⏳ | Convert CityAgendaSlot → real-date GroupSession instances |
| Session P&L view | ⏳ | Min cost, break-even point, margin curve per session |
| FIT routing into existing sessions | ⏳ | FIT booking UI preferentially offers existing SessionInstances |
| Guide/apprentice/merchant fields | ⏳ | GroupSession needs guidePrimary, guideApprentice, affiliateStops[], consignedProducts[] |
| Season mode logic | ⏳ | CityAgendaWeek.seasonMode exists but no code uses it |
| Content hooks layer | ⏳ | Post-session reflection → blog/template/consulting pipeline |
| Weekly agenda UI | ⏳ | No page to view/edit CityAgendaWeek per city |
| Operational calendar | ⏳ | No calendar view showing SessionInstances across cities |
| Maestro de Ritmos persona integration | ⏳ | No AI context prompt or brand voice layer yet |
| 7 Cash-Flow Flows tagging | ⏳ | No enum/category on models linking to the 7 flows |
The 4 Nested Gears — Current State
- Year Wheel (Macro Seasonality) —
seasonModefield exists onCityAgendaWeekbut unused in code - Season Mode — No code logic for Spring–Autumn vs Autumn–Winter switching
- Weekly Silk Route Agenda —
CityAgendaWeek+CityAgendaSlotexist in schema + seed, zero UI - Single SessionInstance Lifecycle —
GroupSessionexists, generation engine now built ✅
Recommended Next Build Order
- SessionInstance generation engine — Server function that takes
CityAgendaSlot+ date range → createsGroupSessionrows - Guide/merchant fields on GroupSession — Add
guideId(exists),apprenticeId,affiliateStops(JSON),consignedProducts(JSON),contentHooks(JSON) - Session P&L view — Compute min cost coverage, margin after break-even, affiliate + product income per session
- Weekly agenda UI — Simple page at
/studio/agenda/[citySlug]showing slots + generated sessions - FIT routing — When FIT user books an experience, show existing SessionInstances first
- Maestro de Ritmos AI context — Add persona prompt to assistant for seasonal planning decisions
How Silk Route Engine Connects to Existing Trips Work
Trip (planning truth)
└─ TripCity rows → each city → CityAgendaWeek → CityAgendaSlot
└─ Generates GroupSession instances
└─ TripAttendee (from TripJoin) + FIT Attendee (from FH booking)
└─ GroupSessionAttendee records
└─ Order → money flows
└─ BookingBundle → execution tracking
The atomic unit remains: SessionInstance = GroupSession. Everything feeds into or out of those.
📘 Reference Material (Locked, Archived)
Remaining Phases (Reference)
Phase 1 — Public /trips ✅ Done
- Trips landing is deployable and commercially coherent
- Four lanes visible, CTAs route, builder-backed sections
Phase 2 — Read-only discovery routes ✅ Done
/trips/featured,/trips/cities,/trips/plans,/trips/network,/trips/updates,/trips/[tripId],/trips/[tripId]/schedule- Users can browse trips, cities, plans, and schedules without planner complexity
Phase 3 — Inline planner 🟡 Partial
- TripPlannerContext + normalizeStops in place
- Trip command surface implemented with server actions
- Missing: DB migration deployed to remote, authenticated browser verification of edit flows
Phase 4 — Pricing and offer bridge 🟡 Partial
- LineItem preview, quote engine exist
- Missing: hotel row handling, canonical quote→Offer→Order path, functional testing
Phase 5 — BookingBundle ⏳ Open
- Model defined in plan, not implemented in code
- Create BookingBundle, requestBooking, status UI, confirmation UI
Phase 6 — Apps Script exports ⏳ Open
/export/pdf,/export/email,/export/calendar,/export/booking-doc
Phase 7 — FareHarbor validation ⏳ Open
- Calendar availability adapter, external reference storage, manual confirmation bridge
🔮 Future Scope (Not Part of Current Critical Shipping)
External Channel Tracking (Post-Revenue)
Future development (NOT urgent, NOT blocking first sales):
-
FareHarbor Sales Tracking
- Track FH bookings in Molino (manual entry or API webhook)
- Reconcile FH payments with Molino Orders
- Status: ⚪ Future (after 60-90 day validation)
-
Etsy Sales Dashboard
- Track Etsy orders manually or via API
- Link Etsy sales to Molino PDF products
- Status: ⚪ Future (after 30-50 consistent sales)
-
Shopify Integration
- Centralized storefront for all products
- Replace manual Etsy listings
- Status: ⚪ Future (after Etsy validates product-market fit)
Current Priority (DO NOT DISTRACT):
- ✅ Trip → Offer → Order (manual payment) - SHIPPING
- ✅ Etsy PDF generation (3 products) - SHIPPING
- ✅ First €500+ transaction - SHIPPING
- 🔄 Continue development until ALL features ship - IN PROGRESS
Rule: External channel tracking is NOT part of current shipping features. Build after revenue validates the system.
📋 Spaces Feature — Final Build Spec for a New AI Coding Assistant
(Full spec already included above in Section 3-22)
🛠️ Development Backlog (Non-Priority, Stable Reference)
Port Legacy NestJS Services → Next.js Server Actions
Priority: High. Reference: legacy-version-reference/actions/*.ts
Porting Pattern
Use modern Next.js pattern:
// app/trips/actions/tripClone.ts
"use server";
export async function cloneTrip(sourceTripId: number, newData: TripInput) {
// 1. Load source
// 2. Create copy with new data
// 3. Return new trip
}
Legacy Services to Adapt (from legacy-version-reference/actions/)
| Legacy File | What It Does | Molino Equivalent Needed |
|---|---|---|
trips.ts | cloneTrip, CRUD | cloneTrip action |
bookings.ts | createBooking with Stripe | createBooking with Stripe |
tripCities.ts | getTripCity, updateTripCity | TripCity update action |
tripCityPlans.ts | addTripCityPlan, getCityPlans | Bind Experience to TripCity |
plans.ts | getPlans, getPlanById | Experience listing |
Key Legacy Patterns to Preserve
-
Stripe Checkout Flow (
bookings.ts:11-50)- Creates booking → returns Stripe checkout URL
- User redirected → payment → webhook confirms
-
Trip Cloning (
trips.ts:36-58)- Clone trip with new dates/owner
- Deep copy: cities, plans, settings
-
City Plan Binding (
tripCityPlans.ts:8-26)- Bind Experience (plan) to TripCity
- Creates TripCityPlan relation
Priority Actions to Port
| # | Legacy Action | Target File | Status |
|---|---|---|---|
| 1 | cloneTrip | app/trips/actions/cloneTrip.ts | ⏳ Not started |
| 2 | createBookingAction (Stripe) | app/trips/actions/createBooking.ts | ⏳ Not started |
| 3 | getCityPlans (available plans) | app/trips/actions/getCityPlans.ts | ⏳ Not started |
| 4 | addTripCityPlan (bind plan to city) | app/trips/actions/addCityPlan.ts | ⏳ Not started |
| 5 | getTripCityById | app/trips/actions/getTripCity.ts | ⏳ Not started |
| 6 | updateTripCity | app/trips/actions/updateTripCity.ts | ⏳ Not started |
| 7 | getBookings | app/trips/actions/listBookings.ts | ⏳ Not started |
Each Port Follows
// [Legacy]
export const cloneTrip = async (sourceTripId, newTripData, token) => { ... }
// [Molino - target]
"use server";
export async function cloneTrip(sourceTripId: number, input: CloneTripInput) {
const source = await prisma.trip.findUnique({ where: { id: sourceTripId } });
// ... clone logic
return newTrip;
}
📋 Spaces Feature — Final Build Spec (repeated for quick reference)
(See full spec in Section 3-22 above)
🔄 Current Sprint (Active)
FareHarbor Booking Sync — Two-Hook Operational Architecture
Status: ✅ Completed (moved to closure)
Scope: Close the loop between FareHarbor Lightframe bookings and Molino's internal state
Two distinct concerns:
| Concern | Name | Type | Responsibility |
|---|---|---|---|
| Booking Initiation | useBookingModal | Client hook | Open Lightframe, pass params, handle user intent |
| Post-Booking Sync | useBookingSync + syncBooking | Client polling / server webhook | Know when FH confirms, update UI and DB |
Shipped:
hooks/useBookingModal.ts— Opens FH Lightframe modalhooks/useBookingSync.ts— PollsGET /api/bookings/pendingevery 3sPOST /api/webhooks/fareharbor— Receives webhooks, writes toExternalBooking, forwards to GASGET /api/bookings/pending— Poll endpoint for confirmed bookings by emailExternalBookingmodel in Prisma schemaFareHarborBookingCard.tsx— Optional sync state UI (spinner, confirmed, error)
Moved to closure: See human-layer-closure-doc.md Phase 7 for full spec.
Stripe Integration
Status: ❌ Not started
Depends on: None (schema ready: Payment model + Stripe fields in Order)
Next: Translate NestJS pattern to Next.js server actions
Active — Live Itinerary Builder Enrichment + Sidebar Consolidation
- intended goal: absorb TripScheduleSection content into per-city blocks, enrich city blocks with description/experiences/editor controls, consolidate all builder tools (route, details, estimate, itinerary) into a tabbed sidebar, fix sidebar toggle logic, add admin feature/public toggles.
- route or feature area:
TripItineraryCanvas.tsx,TripCanonicalBuilderShell.tsx,TripBlockStack.tsx,EditModeContext.tsx,updateBuilderTrip.ts,trip-builder.types.ts. - achieved goal:
- Removed redundant flat "Full schedule" section from TripItineraryCanvas (per-city days already displayed inside each block).
- Added city description (
block.city.description) into each city block, truncated at 250 chars. - Upgraded linked experience badges → rich cards showing summary, duration, base price, and location.
- Added per-city editor controls (intercity transport, city transport, guide, tours, meals, package type) in each city block for editors.
- Added
canEditTrip+onUpdateBlockprops to TripItineraryCanvas, wired from TripCanonicalBuilderShell. - Fixed sidebar toggle: now uses
closeSidebar()instead ofopenSidebar(null), scoped toactiveTool === "trip-planner". - Set EditModeContext
sidebarOpendefault fromtruetofalse(hidden by default). - Widened sidebar from 320px → 420px, added tabbed panel (Route | Details | Estimate | Itinerary) hosting TripBlockStack + TripDetailsForm + TripEstimatePanel + TripItineraryPreview.
- Added
featuredandshareForOtherstoupdateBuilderTripaction,BuilderTripEssentialstype, and admin toggle UI in shell header. - Added
featured+shareForOthersfields toupdateBuilderTripserver action andBuilderTripEssentialstype.
- remaining open issue: public join/book/clone interface not yet integrated into city blocks; experience pricing loader still stubbed; per-city schedule days use generic titles from
buildTripDays. - next action: add public-facing choice controls (join trip, adjust options) into city blocks; wire experience pricing from DB; enhance day title/description from real experience data.
🟢 Stable (Completed Systems — Current Architecture)
Updated: 2026-05-03 — Systems ready for revenue generation
1. Trip → Offer → Order Pipeline ✅ LIVE
Mechanics: Trip planning truth → pricing engine → offer preview → checkout → Order (manual payment)
Dependencies:
Tripmodel (schema.prisma:883+)Offermodel (schema.prisma:1163+)Ordermodel (schema.prisma:1189+)LineItemmodel (schema.prisma:1128+)- Server actions:
createOfferFromTrip(),checkoutOffer()
Schematic Flow:
Trip (planning)
↓ mapTripPricingToLineItems()
Offer (draft, preview)
↓ checkoutOffer()
Order (manual payment: bank/PayPal/cash)
↓ confirmation
Revenue ✅
Status: ✅ Code complete, build passes, ready for browser testing
2. Dual Booking Architecture ✅ LOCKED
Mechanics: Two independent booking layers — FareHarbor (external) + Molino (internal fallback)
Dependencies:
- FareHarbor Lightframe API:
https://fareharbor.com/embeds/api/v1/?autolightframe=yes - FH config:
app/lib/fareharbor/config.ts - Hook:
hook-useFareHarborBookButton.tsx - Components:
FareHarborBookingCard.tsx,TripFeaturedExperiencesSection.tsx - Molino:
checkoutOffer()→Order(manual payment)
Schematic:
Commercial Item (Trip/Experience/Product)
├─ Path A: FareHarbor
│ └─ Lightframe modal → external booking
│
└─ Path B: Molino Internal
└─ Offer → Order → Manual Payment → Confirmation
Status: ✅ Architecture locked, both paths functional
3. Etsy PDF Generation ✅ READY
Mechanics: Extract structured PDFs from existing trips/docs → Etsy listing → app upgrade link
Dependencies:
- Server action:
generate-etsy-pdf.ts - API route:
/api/trips/[tripId]/export/etsy-pdf?type=itinerary|planner|pricing - jsPDF library (already in package.json)
- UI buttons:
TripDetailClient.tsx
Schematic:
Existing Trip/Doc
↓ generateEtsyPdf()
PDF (itinerary/planner/pricing)
↓ manual listing
Etsy Shop (external)
↓ footer link
App Upgrade Path: `/trips/new` or `/products/<slug>`
Status: ✅ Code ready, 3 products prepared, awaiting Etsy seller account
4. BookingBundle Execution Separation ✅ PARTIAL
Mechanics: Trip planning ≠ booking execution. Bundle = fulfillment container.
Dependencies:
BookingBundlemodel (schema.prisma:1213+)BookingItemmodel (schema.prisma:1231+)- Actions:
requestBooking(),confirmBookingItem(),updateBookingBundleStatus() - UI:
BookingBundlePanel.tsx - Migration: NOT deployed (needs
npx prisma migrate deploy)
Schematic:
Trip (planning truth — unchanged)
↓ requestBooking()
BookingBundle (draft → pending → confirmed)
├─ BookingItem: hotel
├─ BookingItem: transport
├─ BookingItem: guide
└─ BookingItem: experience
↓ confirmBookingItem()
Trip unchanged ✅
Status: ✅ Models + actions + UI ready, needs migration + browser verification
5. Hotel Policy + Line Items ✅ PARTIAL
Mechanics: Auto-compute hotel policy based on trip start date (>40 days = inventory, ≤40 = external links)
Dependencies:
- Function:
computeHotelPolicy()inmapTripPricingToLineItems.ts - Types:
HotelPolicytype ("managed_blocked_inventory" | "external_links_only") - Hotel rows included in line items when policy allows
- UI wiring: NOT done (needs show/hide hotel controls)
Schematic:
Trip.startDate
↓ computeHotelPolicy(startDate)
├─ >40 days → "managed_blocked_inventory"
│ └─ Hotel rows in LineItems ✅
│
└─ ≤40 days → "external_links_only"
└─ External booking links only ✅
Status: ✅ Policy logic implemented, hotel rows in line items, UI wiring pending
6. FareHarbor Lightframe Integration ✅ LIVE
Mechanics: Booking buttons open FH modal in-page (no redirect)
Dependencies:
- Script:
https://fareharbor.com/embeds/api/v1/?autolightframe=yes - Components:
TripFeaturedExperiencesSection.tsx,TripFeaturedCitiesSection.tsx - Data:
CityCard,PublicTripCardtypes withaccountShortname,itemId,flowId - FH Account:
alandalus-experience - Flow IDs: Main
1361158, Group1362489, City tours1610878-8779
Schematic:
User clicks "Book Now"
↓ data-fh-account + data-fh-item-id
↓ autolightframe intercepts
FareHarbor Lightframe Modal (stays on /trips)
↓ external booking
Confirmation (FH handles)
Status: ✅ Integrated, modal opens in-page, browser verification pending
7. Seed System ✅ LIVE
Mechanics: Recreatable demo environment with admin user, trips, experiences, sessions
Dependencies:
- Seed file:
prisma/seed.ts - Seed libs:
prisma/seed-lib/* - Commands:
npm run seed:studio,npx prisma db seed - Output: Admin user, 3 demo projects, cities, experiences, session stacks/paths
Schematic:
npx prisma db seed
↓
Admin: zaruqsummers@gmail.com
Projects: demo-coop-project, andalusian-trips-lab, experiences-shop-demo
├─ Trips + Cities (Madrid, Córdoba, Granada, Seville, Málaga, Ronda)
├─ Experiences (29 sessions from SessionStacks)
├─ SessionPaths + SessionStacks
└─ Builder sections (studio surfaces)
Status: ✅ Seed runs clean, 0 type errors, all demo data ready
8. Trips Commercial Surface ✅ LOCKED
Mechanics: Public trips front door with 4 commercial lanes, read-only browsing + private planning separation
Dependencies:
- Routes:
/trips,/trips/[tripId],/trips/[tripId]/schedule,/trips/planner/[draftId] - Components:
TripsLandingPage.tsx,TripDetailClient.tsx,TripPlanner.tsx - Server action:
getTripsLandingData(),createTripFromPlanner(),updateTripFromPlanner() - Registry:
TripsSectionRenderer.tsx, builder projectandalusian-trips-lab
Schematic:
Public /trips
├─ Lane 1: Ready-made city tours
├─ Lane 2: Featured guided trips
├─ Lane 3: Flexible trip planning (private)
└─ Lane 4: Collaborations/provider entry
↓ click CTA
Trip Detail (public: hero/route/cities/pricing/schedule)
↓ private planner
Trip Planner (owner-only, inline editing)
↓ create offer
Offer → Order → Revenue
Status: ✅ Public surface locked, 4 lanes visible, CTAs route correctly, planner separated
9. Weekly Sessions / Silk Route ✅ DONE
Mechanics: Real session slots from DB, cards link to Studio entities (experiences/sessions)
Dependencies:
CityAgendaWeek+CityAgendaSlotmodels (schema.prisma:1012+)- Server action:
getWeeklySessionSlots()(app/trips/actions/getWeeklySessionSlots.ts) - Component:
TripWeeklySessionsSection.tsx(uses real data, not hardcoded)
Schematic:
CityAgendaWeek (per city)
↓ active agenda
CityAgendaSlot (weekday, time, experienceId)
↓ getWeeklySessionSlots()
TripWeeklySessionsSection cards
├─ Card → /studio/travel/[experienceId] (if experience)
├─ Card → /studio/craft/[sessionStackId] (if session stack)
└─ Card → static title (fallback)
Status: ✅ Server action built, component uses real DB data, cards link to Studio entities.
Closure Note (moved from TODO):
- Originally: "TODO: integrate sessions here not dummy content"
- Now: ✅ Completed —
getWeeklySessionSlots()fetches fromCityAgendaSlotwith experiences - Cards dynamically link to
/studio/travel/[id]or/studio/craft/[id]
10. Content Stack Factory ✅ LIVE
Mechanics: Every new Trip or Experience auto-generates an editable content structure (SessionStack + ConceptGroups + ConceptCards) linked to the commercial entity.
Dependencies:
SessionStack,ConceptGroup,ConceptCard,SessionConceptGroup,ConceptGroupItemmodels- Factory:
lib/factories/contentStackFactory.ts - Schema:
contentStackIdonTripandExperience
Schematic:
Trip / Experience created
↓ createStandardContentStack()
SessionStack (title: "Content: {name}")
├─ ConceptGroup: Overview
│ ├─ Card: Welcome & Introduction
│ └─ Card: What to Expect
├─ ConceptGroup: Logistics
│ ├─ Card: Meeting Point & Directions
│ └─ Card: What to Bring
└─ ConceptGroup: Experience Flow
└─ Card: Step-by-Step Journey
↓ linked via contentStackId
Trip / Experience detail page shows groups + cards
↓ "Edit content" → /session-stacks/{id}
↓ "Manage groups" → /concept-groups
Status: ✅ Factory built, schema pushed, integrated into all creation flows, UI rendering on detail pages.
Moved to closure: See human-layer-closure-doc.md Phase 4 for full spec.
🔄 System Interconnections (Big Picture)
┌─────────────────────────────────┐
│ Dual Booking Architecture │
│ (FH + Molino Internal) │
└──────────┬──────────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Trip→Offer │ │ Etsy PDFs │ │ FH Lightframe│
│ →Order │ │ Extraction │ │ Integration │
│ ✅ LIVE │ │ ✅ READY │ │ ✅ LIVE │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ BookingBundle │ │ Etsy Sales Dashboard│ │ FareHarbor Sales │
│ Execution │ │ (manual/API) │ │ Tracking │
│ ✅ PARTIAL (migrate)│ │ ⚪ Future │ │ ⚪ Future │
└─────────────────────┘ └─────────────────────┘ └─────────────────┘
│
▼
┌─────────────────────┐
│ Revenue Generation │
│ Target: €500+ │
│ (7-30 days) │
└─────────────────────┘
Reward Feeling: 8 major systems built. Core pipeline LIVE. Dual booking LOCKED. Ready for first revenue.
🛠️ Development Backlog (Non-Priority, Stable Reference)
Port Legacy NestJS Services → Next.js Server Actions
Priority: High. Reference: legacy-version-reference/actions/*.ts
Porting Pattern
Use modern Next.js pattern:
// app/trips/actions/tripClone.ts
"use server";
export async function cloneTrip(sourceTripId: number, newData: TripInput) {
// 1. Load source
// 2. Create copy with new data
// 3. Return new trip
}
Legacy Services to Adapt (from legacy-version-reference/actions/)
| Legacy File | What It Does | Molino Equivalent Needed |
|---|---|---|
trips.ts | cloneTrip, CRUD | cloneTrip action |
bookings.ts | createBooking with Stripe | createBooking with Stripe |
tripCities.ts | getTripCity, updateTripCity | TripCity update action |
tripCityPlans.ts | addTripCityPlan, getCityPlans | Bind Experience to TripCity |
plans.ts | getPlans, getPlanById | Experience listing |
Key Legacy Patterns to Preserve
-
Stripe Checkout Flow (
bookings.ts:11-50)- Creates booking → returns Stripe checkout URL
- User redirected → payment → webhook confirms
-
Trip Cloning (
trips.ts:36-58)- Clone trip with new dates/owner
- Deep copy: cities, plans, settings
-
City Plan Binding (
tripCityPlans.ts:8-26)- Bind Experience (plan) to TripCity
- Creates TripCityPlan relation
Priority Actions to Port
| # | Legacy Action | Target File | Status |
|---|---|---|---|
| 1 | cloneTrip | app/trips/actions/cloneTrip.ts | ⏳ Not started |
| 2 | createBookingAction (Stripe) | app/trips/actions/createBooking.ts | ⏳ Not started |
| 3 | getCityPlans (available plans) | app/trips/actions/getCityPlans.ts | ⏳ Not started |
| 4 | addTripCityPlan (bind plan to city) | app/trips/actions/addCityPlan.ts | ⏳ Not started |
| 5 | getTripCityById | app/trips/actions/getTripCity.ts | ⏳ Not started |
| 6 | updateTripCity | app/trips/actions/updateTripCity.ts | ⏳ Not started |
| 7 | getBookings | app/trips/actions/listBookings.ts | ⏳ Not started |
Each Port Follows
// [Legacy]
export const cloneTrip = async (sourceTripId, newTripData, token) => { ... }
// [Molino - target]
"use server";
export async function cloneTrip(sourceTripId: number, input: CloneTripInput) {
const source = await prisma.trip.findUnique({ where: { id: sourceTripId } });
// ... clone logic
return newTrip;
}
📊 Production Readiness Summary
| Component | Status | Blocks Shipping? |
|---|---|---|
| Trip → Offer → Order (manual payment) | ✅ Shipping | No |
| Etsy PDF generation | ✅ Shipping | No |
| Public Trips surface | ✅ Shipping | No |
| FareHarbor Lightframe | ✅ Shipping | No |
| BookingBundle execution | ⚠️ Pending verify | No (optional) |
| Hotel policy UI | ⚠️ Pending verify | No (optional) |
| Export Routes (Apps Script) | ⏳ In progress | YES |
| Stripe integration | ❌ Not started | No (manual OK) |
| Spaces feature | ⏳ Spec ready | No (optional) |
🔮 Future Scope (Not Part of Current Critical Shipping)
External Channel Tracking (Post-Revenue)
Future development (NOT urgent, NOT blocking first sales):
-
FareHarbor Sales Tracking
- Track FH bookings in Molino (manual entry or API webhook)
- Reconcile FH payments with Molino Orders
- Status: ⚪ Future (after 60-90 day validation)
-
Etsy Sales Dashboard
- Track Etsy orders manually or via API
- Link Etsy sales to Molino PDF products
- Status: ⚪ Future (after 30-50 consistent sales)
-
Shopify Integration
- Centralized storefront for all products
- Replace manual Etsy listings
- Status: ⚪ Future (after Etsy validates product-market fit)
Current Priority (DO NOT DISTRACT):
- ✅ Trip → Offer → Order (manual payment) - SHIPPING
- ✅ Etsy PDF generation (3 products) - SHIPPING
- ✅ First €500+ transaction - SHIPPING
- 🔄 Continue development until ALL features ship - IN PROGRESS
Rule: External channel tracking is NOT part of current shipping features. Build after revenue validates the system.
🟢 Stable (Completed Systems — Current Architecture)
Updated: 2026-05-03 — Systems ready for revenue generation
1. Trip → Offer → Order Pipeline ✅ LIVE
2. Dual Booking Architecture ✅ LOCKED
3. Etsy PDF Generation ✅ READY
4. BookingBundle Execution Separation ✅ PARTIAL
5. Hotel Policy + Line Items ✅ PARTIAL
6. FareHarbor Lightframe Integration ✅ LIVE
7. Seed System ✅ LIVE
8. Trips Commercial Surface ✅ LOCKED
9. Weekly Sessions / Silk Route ✅ DONE
📋 Spaces Feature — Final Build Spec for a New AI Coding Assistant
(Full spec included in Section 3-22 above — ready to build after Export Routes)
✅ Closure Log
Completed items have been moved to human-layer-closure-doc.md.
See that document for shipped features and production readiness status.
For current in-progress work, see the sections below.
🟡 Next Steps (Immediate — Actionable)
-
Stripe Integration (Phase 4 — Translate from NestJS)
- Status: ❌ Not started (schema ready)
- Install:
npm install stripe @stripe/stripe-js - Add env vars:
STRIPE_SECRET_KEY,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY - Create
app/orders/actions/createPaymentIntent.ts - Create
app/api/stripe/webhook/route.ts - Create
app/orders/components/StripeCheckout.tsx
-
Spaces Feature (Phase 5 — Per Spec)
- Status: ❌ Not started (schema ready)
- Create folder structure:
app/(spaces)/ - Implement types, validation, registry
- Build resolver, renderer, canvas
- Add publish action + minimal editor
🔄 Current Sprint (Active — Remaining Work)
Stripe Integration
Status: ❌ Not started
Dependencies: None (schema ready: Payment model + Stripe fields in Order)
Next: Translate NestJS pattern from _PRD/framework/16DecTuesdayWeekGoals.md:670-732
Spaces Feature
Status: ❌ Not started
Dependencies: None (schema ready: Space, Section, SpaceRouteAlias models)
Next: Create app/(spaces)/ folder structure, implement types per spec in Sections 3-22
📘 Reference Material (Locked, Archived)
Sheet Section Indexes
- SECTION SHEETS — NextJS DocsAPI Web Builder System
- Headless Google Doc Based - Web Content Sections 🌱 Molino⺢Practice
- Production Apps Script API:
https://script.google.com/macros/s/AKfycbwVZ_iZYachR-Yy5O2dKvD7eExkPZR1Bpg-_YE7ceIjkK8zV5a-i4VFaj4fpdwq4vvDZg/exec - Staging Apps Script API:
https://script.google.com/macros/s/AKfycbxfUsPGiRHwM5n3gnnWYEdVZ2xw4OIcnaqmWPmpiqjqzpzv0IXFFfjQctr9cOJFkCKm/exec
📋 Spaces Feature — Final Build Spec for a New AI Coding Assistant
(Full spec ready — see Sections 3-22 above)
🔮 Future Scope (Not Part of Current Critical Shipping)
External Channel Tracking (Post-Revenue)
(See Section 🔮 above — NOT blocking first sales)
🟢 Stable (Completed Systems — Current Architecture)
Updated: 2026-05-06 — Systems ready for revenue generation
1. Trip → Offer → Order Pipeline ✅ LIVE
2. Dual Booking Architecture ✅ LOCKED
3. Etsy PDF Generation ✅ READY
4. BookingBundle Execution Separation ✅ PARTIAL
5. Hotel Policy + Line Items ✅ PARTIAL
6. FareHarbor Lightframe Integration ✅ LIVE
7. Seed System ✅ LIVE
8. Trips Commercial Surface ✅ LOCKED
9. Weekly Sessions / Silk Route ✅ DONE
10. Content Stack Factory ✅ LIVE
11. FareHarbor Booking Sync ✅ LIVE
📊 Production Readiness Summary
| Component | Status | Blocks Shipping? |
|---|---|---|
| Trip → Offer → Order (manual payment) | ✅ Shipping | No |
| Etsy PDF generation | ✅ Shipping | No |
| Public Trips surface | ✅ Shipping | No |
| Experiences commercial surface | ✅ Shipping | No |
| FareHarbor Lightframe | ✅ Shipping | No |
| Content Stack Factory (auto-generated editable content) | ✅ Shipping | No |
| Export Routes (Apps Script) | ✅ Shipping | No |
| BookingBundle execution | ⚠️ Pending verify | No (optional) |
| Hotel policy UI | ⚠️ Pending verify | No (optional) |
| FareHarbor Booking Sync (webhook + polling) | ✅ Shipping | No |
| Stripe integration | ❌ Not started | No (manual OK) |
| Spaces feature | ⏳ Spec ready | No (optional) |
🔄 System Interconnections (Big Picture)
(See diagram above — 8 major systems built, core pipeline LIVE)
🛠️ Development Backlog (Non-Priority, Stable Reference)
(See Section: Port Legacy NestJS Services → Next.js Server Actions above)
📋 Next Actions (Immediate — Start Here)
- Stripe Integration →
app/orders/actions/,app/api/stripe/ - FareHarbor catalogue sheet → run
FareHarborCatalog_refresh()to normalize, thenFareHarborCatalog_postToNext()to import into Next - FareHarbor ICS Calendar → verify
/api/fareharbor/calendaron staging - Spaces Feature →
app/(spaces)/ - TypeScript cleanup → Fix ~189 remaining errors (background task, not blocking)
- Browser verification → Trip → Offer → Order → Manual Payment with real data
- Etsy launch → Generate 3 PDFs, create seller account, list products*
Recommended start: Stripe Integration — enables automated payment on top of existing manual flow.
Build Status - Updated (May 6, 2026)
- ✅ Next.js build succeeds (22.1s compilation)
- ✅ FareHarbor Booking Sync shipped (
useBookingModal,useBookingSync, webhook + polling routes,ExternalBookingmodel) - ✅ ICS calendar integration —
GET /api/fareharbor/calendarparses FH ICS feed into JSON events; token required viaFAREHARBOR_ICS_TOKEN - ✅ FareHarbor catalogue sheet helper — Apps Script can normalize the horizontal catalogue into one product per row
- ✅ FareHarbor catalogue import endpoint —
POST /api/fareharbor/catalog/importupserts normalized products intoExperiencerecords and attaches content stacks - ✅ Content Stack Factory shipped (
lib/factories/contentStackFactory.ts, schema pushed) - ✅ Trip/Experience detail pages enhanced (content stack display; edit links)
- ✅ Commercial content rewritten (Experiences page, Trips registry)
- ✅ Prisma schema updated (
contentStackIdon Trip + Experience,ExternalBookingmodel) - ✅ Prisma client regenerated (v7.7.0)
- ⚠️ Remaining TypeScript errors: ~189 (many pre-existing in non-ported modules)
Trip Page Header Deduplication — Hero Consolidation
- intended goal: deduplicate the trip title across the page and merge route/price stats into the hero area, removing repeated headers from the shell/canvas
- route or feature area:
app/trips/[tripId]/TripDetailClient.tsx(hero),TripCanonicalBuilderShell.tsx,TripItineraryCanvas.tsx - expected done condition: trip name appears exactly once (in hero), all stats (base nights, extensions, total days, price per person, core journey indicator) consolidated in hero, editor buttons move alongside stats
- achieved goal: shell and canvas headers removed; hero enriched with 4-column route+pricing stats grid, core journey badge (Córdoba→Granada + Morocco indicator), "Generate landing page" button (edit mode only),
computeRouteStatsintegrated with type-safe cast,handleGenerateLandingcallback added - remaining open issue: browser/TypeScript verification pending (no local Node/npm)
- next action: browser-test the full page to verify single title, consolidated stats, and editor buttons render correctly in hero
City Data Enrichment + Builder Audit
- intended goal: populate cities with real images/data, build city admin interface, enhance city detail pages, audit trip builder for full-width layout and production readiness
- route or feature area:
prisma/seed-lib/cities.ts,app/cities/,app/trips/cities/,app/trips/builder/components/ - achieved goal:
- Cities seed enriched with 14 cities including real Unsplash images, city codes (AGP/GRX/ODB/SVQ etc), lat/lng coordinates, map links
- City admin area at
/cities/adminwith list, edit form, and create form (admin-guarded) - City detail page at
/trips/cities/[slug]rewritten with hero image, narrative sections (description1-4, content), coordinates card, airport code card, linked experiences - Cities list at
/trips/citiesnow shows thumbnail images - Builder canvas made full-width (
max-w-5xl→w-full) - AI placeholder text removed from 6 components — "curated experiences" → city description fallback, "Generated with Trip Builder" → "Al-Andalus Experience", "No estimate/itinerary generated yet" → "No estimate/itinerary yet", "generate days" → "display days"
getFeaturedCitiesreturn includesmainImagegetPlannerCitiesselect includesmainImage
- remaining open issue: need to run
npm run seed-citiesto populate images; browser/TS verification pending - next action: run seed script, browser-test city admin + detail pages + builder full-width layout
Next Steps
- Run
npx tsx prisma/seed-cities.tsto populate city images - Browser test city admin CRUD, city detail pages, builder full-width
- Stripe Integration — translate NestJS pattern to Next.js server actions
- Browser test Trip → Offer → Order → Manual Payment flow
- Test Hotelbeds API (search by geo, search by city, check rates)
- Deploy to staging and verify all routes