MolinoPro

human-layer-post-commercial-archive

Master Codebase Guidebook
Markdown + HTML Dev-Docs Renderer - Frontend Client Module

Default Index
Open README.md
Root: README.md_PRD
Milestones
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/Image instead of raw internal anchors or img tags, leaving the root layout as the only HTML shell owner.
  • update: /trips/public now has a JSX poster/grid gallery toggle using trip mainImage fallbacks; 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 domains
    • app/(content-layer)/session-paths/components/PathBinderView.tsx — audit bars at all 3 levels + split/combine + recompose
    • app/(content-layer)/session-paths/components/RecomposePanel.tsx — unbound items viewer
    • app/(content-layer)/concept-groups/actions/splitConceptGroup.ts — split group server action
    • app/(content-layer)/concept-groups/actions/combineConceptGroups.ts — combine groups server action
    • app/(content-layer)/session-stacks/actions/splitSessionStack.ts — split stack server action
    • app/(content-layer)/session-stacks/actions/combineSessionStacks.ts — combine stacks server action
  • expected done condition:
    1. All core utilities available: countWords, estimateDuration, estimateFromStrings, formatDuration, DURATION_THRESHOLDS
    2. Audit bars render at group (max 180), stack (max 480), and path (cadence-based) levels
    3. Card-level estimated duration shown inline when diff from actual
    4. Split button on groups ≥120 min, combine buttons on groups <60 min (edit mode)
    5. Split button on stacks ≥360 min, combine buttons on stacks <120 min (edit mode)
    6. Recompose panel shows unbound cards/groups/stacks (edit mode)
    7. 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.tsderiveName(items, fallback) uses first item title + count for dynamic naming
    • programName() — 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: uses programName() 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
      • deleteSessionPath action reused from list page forms (redirects to /session-paths on 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() from smartName.ts for 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 — exports getCardSize(index) using the 7-element coprime pattern, layout prop for masonry vs dashboard
      • 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) to masonry-grid + stepped card sizes, removed inline aspectRatio: 1/1
      • Text-only cards get card-text class for min-height variants (140/220/300px)
      • Image-based cards use the existing card-image aspect-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 proper border rounded p-4 bg-white card with full steps, materials, promise, and metadata
    • Filter inputs on all listing pages: Shared FilterInput component + useItemFilter hook at app/components/FilterInput.tsx — live client-side filtering by title/promise (cards), title/summary (groups, stacks), title/cadence (paths)
    • Inline session stack title title edit: InlineSessionTitle at 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 FilterablePathGrid client component (supports live filter + preserves delete actions)
    • getCardSize(): removed inline duplication in session-paths page (delegates to FilterablePathGrid)
    • npm run lint passes clean for all changed files (656 pre-existing errors elsewhere in the codebase)
  • remaining open issue:
    • Browser verification needed for all routes
  • next action:
    1. Browser-test filter inputs on all 4 listing pages
    2. Browser-test InlineSessionTitle click-to-edit on session stacks detail
    3. 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 (revalidatePath updates)
  • expected done condition:
    1. /trips/builder → redirects to /trips/public
    2. /trips/builder/dashboard → redirects to /trips/public/dashboard
    3. /trips/builder/[tripId] → functional with deprecation banner linking to /trips/[tripId]
    4. All builder actions revalidate both legacy (/trips/builder/*) and canonical (/trips/*, /trips/public/*) paths
    5. Publishing a trip auto-creates/updates its promo marketing space
    6. /trips/t/[slug] landing page shows "View full marketing page" link if a published promo space exists
    7. "Magic fix" button validates 6-point link anatomy and regenerates if broken/archived
  • achieved goal:
    • Routing restructure complete:
      • /trips/builder/page.tsxredirect("/trips/public")
      • /trips/builder/dashboard/page.tsxredirect("/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/dashboard
      • createBuilderTrip.ts: Added /trips/public
      • fixAllTrips.ts: Added /trips/public, /trips/public/dashboard
      • generateTripLanding.ts: Added /trips/${tripId}, /trips/public, /trips/public/dashboard
      • dashboardTripActions.ts: Added /trips/public/dashboard to all functions
      • publishBuilderTrip.ts: Added /trips/public (both publish/unpublish)
    • Trip ↔ Promo Space auto-sync architecture complete:
      • New file: ensureTripPromotionalSpaces.ts with:
        • ensureTripPromotionalSpace(tripId, options?): Single-trip ensure
        • ensureAllUserTripPromotionalSpaces(options?): Batch ensure for all user's trips
        • forceRegenerateTripPromotionalSpace(tripId): Force fresh rebuild
        • getTripPromotionalSpaceInfo(tripId): Lookup helper with validation report
      • 6-point link anatomy validation (validateSpaceLinkAnatomy()):
        1. hasValidSourceType: sourceType === "trip_builder"?
        2. hasValidSourceId: sourceId === String(tripId)?
        3. hasPrimaryRoute: At least one route with path?
        4. hasSections: Any sections exist?
        5. hasPosterSection: Has critical poster section type?
        6. 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.ts now calls ensureTripPromotionalSpace(tripId) after setting shareForOthers=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"
      • Dashboard buttons in /trips/public/dashboard:
        1. 🎨 Sync Trip Promo Spaces — Smart sync (stale/broken/archived only)
        2. 🔄 Force Regenerate ALL Trip Promo Spaces — Fresh rebuild for every trip
  • content-sync principle (architecture LOCKED):
    • Trip is source of truth: Promo Space content is derived from Trip data
    • Sync triggers:
      1. Automatic: On publishBuilderTrip() call
      2. Manual: Dashboard sync buttons
      3. Smart: ensureTripPromotionalSpace() compares trip.updatedAt > space.updatedAt
    • 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 mainImage not yet wired to Space poster image sync
  • 🔮 Future Dev Tasks (Noted from Session):
    1. 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
    2. Trip.mainImage unified architecture:
      • Use in: /trips/public gallery cards
      • Use in: /trips/public/dashboard gallery 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)
    3. Poster Mini-Card Gallery View (toggleable):
      • Optional alternate view in /trips/public gallery
      • Uses Spaces poster-section component CSS with mini modifier
      • Toggle: "Posters View" / "Grid View"
      • Preserves stepped grid masonry aspect ratios
  • next action:
    1. Browser-test: /trips/builder redirect → /trips/public
    2. Browser-test: /trips/builder/[tripId] deprecation notice
    3. Browser-test: Publish button triggers promo space creation
    4. Browser-test: /trips/t/[slug] shows "View full marketing page" link
    5. Browser-test: Dashboard sync buttons work

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 ConceptMolino EquivalentRole
Document tabTripCity / SessionStackContainer for prompt + output
Header as promptIdeas form panelStructured input that describes what user wants
[AI Context Summary] footerSessionStack.data / TripCity.contentStack.dataContext carry-over between containers
tab.getChildTabs()Persona planners / "Historical character" influencersSub-models that influence choices
_processTabRecursive()New: trip planner engineSequential processor with context
CacheServiceReact Query / Prisma / custom cacheAvoid re-processing identical prompts
mergeAIGeneratorTabsToResult()Trip entity finalizationConsolidate into single Prisma write
LanguageApp.translate() / callAiModel()Your existing Assistant + Ideas moduleLLM 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 IdeasForm auto-fillerPrisma 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
ComponentAlready Exists?How It's Used
Ideas module (chat → form)✅ YesCore pattern reused
Assistant (LLM API)✅ YesLLM provider
Persona system✅ YesPersona planners / historical characters
data Json? on all models✅ YesContext carry-over mechanism
Trip / TripCity / Experience✅ YesContainer hierarchy
SessionStack / ConceptGroup / ConceptCard✅ YesContent + context storage
Google Maps API⚠️ PartialNeeds Places/Directions/Distance Matrix
Promotional Space generation✅ YesFinal 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/planner or 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.contentStack and Experience.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 action
  • app/assistant/ — LLM API access
  • app/spaces/ — persona system
  • lib/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? @unique to 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 (with stripeSessionId).
      • stripePaymentIntentId is only set later by webhook (not at session creation).
    • Created app/api/webhooks/stripe/route.ts:
      • Verifies signature via stripe.webhooks.constructEvent() if STRIPE_WEBHOOK_SECRET exists.
      • Handles checkout.session.completed: updates Payment (stripePaymentIntentId, succeeded), computes Order status (confirmed if fully paid, partially_paid otherwise).
      • Handles checkout.session.expired: marks Payment as failed, Order as cancelled if no other payments.
    • Created app/bookings/success/page.tsx (client component): reads session_id from URL, calls findBookingBySessionId() 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 call createBookingWithStripe() 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 apiVersion from 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 via stripe listen.
    • Browser-tested: deposit payment flow works end-to-end (Order + TripJoin created, Checkout Session redirect, webhook processes completion, success page shows correct amounts).
  • remaining open issue: no STRIPE_WEBHOOK_SECRET in Vercel env for production webhook signature verification.
  • next action: verify remaining balance payment flow, set STRIPE_WEBHOOK_SECRET in 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, produces TravelPlanDay[] with in/out dates.
    • Added buildCitySequence() + insertDefaultRoute(): smart merging of user-specified blocks with default route rules from legacy.
    • Added computeNAndD(): exact port of legacy getNaNd() with 2-night max for pointA≠pointD, remaining night fallback to pointD.
    • Added LEGACY_CITY_CODES map for all known cities (AGP, GRX, ODB, SVQ, MAD, BCN, LIS, CMN, etc) with type classification.
    • Engine upgrade trip-engine.ts: interpretMealsCost() parses choiceMeals string to meals/day × 15€ × nights × pax; per-city TripCityPricingSnapshot returned in meta.cityPricing; experienceCostPerPax optional input supported.
    • Mapper upgrade trip-mappers.ts: both mapDraftToTripInput and mapEntityToTripInput now pass choiceMeals.
    • TripBlockStack upgrade: route stats badges (BASE/MODS/MAROC/ARR/DEP + total days) shown above blocks.
    • TripCityPicker upgrade: "Suggested" tab using getSuggestedNextBlocks(), auto-skips already-added cities, arrival-only list when empty, connection-based filtering.
    • Type system: added TripCityPricingSnapshot type with all cost categories.
    • Build passes clean.
  • 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 --check passed for app/trips/[tripId]/page.tsx and 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 TripEditDock into 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. TripBlockStack removed from the shell entirely. Public view stays canvas-only.
  • latest sidebar changes: TripDetailSidebar stripped 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 TripDetailSidebar width from w-[420px] to w-[320px] to match documents sidebar width; updated layout.tsx content push margin from xl:ml-[420px] to xl: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: TripCity model supports contentStackId relation; cityContentFactory.ts creates standard city-level content stacks with city-aware defaults (Córdoba, Granada, Málaga, Seville, etc.); pattern follows existing contentStackFactory.ts used 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? to TripCity model in prisma/schema.prisma:1167-1168. Follows exact same pattern as Trip and Experience.
    • Schema applied: prisma db push executed successfully.
    • Factory created: lib/factories/cityContentFactory.ts with:
      • CITY_DEFAULT_GROUPS: 3 city-specific groups (Welcome & Arrival, Local Essentials, Pre-Arrival Preparation)
      • CITY_DEFAULTS map: Córdoba, Granada, Málaga, Seville, Cádiz, Ronda, default fallback
      • normalizeCityNameForLookup() + getCityDefaultsKey(): Smart city-name lookup (handles accented names like "Córdoba", "Málaga")
      • createStandardTripCityContentStack(): Creates + links SessionStack → TripCity.contentStackId
      • ensureTripCityContentStack(): Idempotent getter-or-creator pattern
    • Factory wired to add-city actions:
      • addTripCityBlock.ts (builder, used by TripCityPicker): Calls ensureTripCityContentStack() after creating TripCity
      • createTripCity (trip-detail.commands.ts, used by legacy TripCommandSurface): Also wired
    • Backfill action created: lib/factories/backfillTripCityContentStacks.ts (actually app/trips/builder/actions/backfillTripCityContentStacks.ts):
      • backfillTripCityContentStacksForTrip(tripId): Backfills content stacks for a specific trip
      • backfillAllMyTripCityContentStacks(): Backfills ALL user's trips
      • Only processes TripCities where contentStackId IS NULL
    • Loader updated: loadBuilderTrip.ts now includes nested contentStack.sessionConceptGroups.conceptGroup (titles only, NO card content)
    • Types updated: trip-builder.types.ts extended with TripCityBlockContentStackRef, TripCityBlockConceptGroupRef
    • Canvas rendering: TripItineraryCanvas.tsx now 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.tsx now includes "City content stack" section for each city block:
      • Link: Browse concept cards/concept-cards
      • Link: + Generate with AI assistant/concept-cards/new
      • Shows contentStackId preview if available
    • Toolsdock wired: TripEditDock.tsx now includes "Concept cards" button in public actions:
      • Direct navigation to /concept-cards route
      • Available in the floating bottom dock panel
  • content separation principle (architecture LOCKED):
    • Pre-booking content (Trip builder view):
      • TripCity.contentStack with ConceptGroup TITLES 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):
      • ConceptCard full content (steps, materials, assets)
      • Experience.contentStack for participant/staff delivery
      • Reserved for: post-commercial delivery options, free included trip documentation sharing, etc.
      • NOT rendered in builder itinerary canvas
  • existing classification fields (no new fields needed, agnostic by design):
    • ConceptCard: domains[], data Json?, visibility, difficulty, durationMin
    • ConceptGroup: phase, visibility, cities[], data Json?, cadence
    • SessionStack: type, deliveryMode, data Json?, durationMin, location
    • SessionPath: 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/new AI 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
  • remaining open issue:
    • Migration tracking: prisma db push was used instead of prisma migrate dev — migration file 20260509_add_stripe_session_id has unrelated shadow DB issue
    • Existing TripCities (pre db-push) do NOT have content stacks — use backfillTripCityContentStacksForTrip(tripId) or backfillAllMyTripCityContentStacks() to populate
    • Trip-level aggregate container not yet linked to city-level containers
  • 🔮 Future Dev Tasks:
    1. Migration cleanup: Fix shadow DB issue or baseline migrations for proper prisma migrate dev workflow
    2. 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 TripCity instances
  • next action: Test builder to verify:
    1. Concept-group titles rendering in itinerary canvas
    2. Sidebar content navigation (Browse concept cards, Generate with AI assistant)
    3. Toolsdock "Concept cards" button
    4. 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 trip CTA 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 trip as 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 to pricing.total or pricing rows, and exposes accommodation estimate metadata separately for the join panel.
  • checked route or behavior: git diff --check passed 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 trip now 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 includes Trip tools to 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, AffiliateCommission models + expanded ExternalBooking fields (phone, affiliateCode, customFields, customTripRef).
    • Webhook handler authenticates via x-sync-secret / APP_SYNC_SECRET and maps booking.created|.cancelled|.modified to contract payloads.
    • Catalog import handler authenticates via x-sync-secret / CATALOG_IMPORT_SECRET and upserts FareHarborProduct.
    • lib/fareharbor-gas-client.ts provides 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.
  • current status before coding: existing code uses ExternalBooking for FH webhooks, maps catalog to Experience (not FareHarborProduct), 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 generate and prisma migrate dev to 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.sections as 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), updateTripLandingSections action, mode toggle in builder header. Sections auto-generated on first load, persist to projectionMeta. Zero TS errors.
  • latest achieved goal: promoted TripItineraryCanvas into 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. TripCityBlockViewModel now includes the related City row for images and richer city content. Header now distinguishes nights from days.
  • latest achieved goal: added trip-route-logic.ts as 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 /trips front-page editable section persistence after replacing registry rendering with direct section composition.
  • route or feature area: public /trips landing 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.contentJson through 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 contentJson but 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 /trips landing sections that should be front-page editable.

H2G1 — Trip Draft Identity

authority: client (document layer)

  • ensure tripData.id exists 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

  1. Add computeBundle() for Trip (already defined)
  2. Add projectEntity() trigger from Trip / Offer / Order
  3. Implement Apps Script endpoints:
    • docs.create
    • docs.exportPdf
    • calendar.create
    • drive.share
    • gmail.send
  4. Persist projectionMeta (docId, pdfUrl, eventIds)
  5. Add UI buttons:
    • "Export Offer"
    • "Export Trip"
    • "Send Package"
  6. Enforce:
    • idempotency (externalKey)
    • no logic in Apps Script
  7. 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:

  1. docs.create
  2. docs.exportPdf
  3. 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:

LayerPriority
Trip → Offer → OrderCRITICAL
Export RoutesCRITICAL (parallel)

31.15 NEXT ACTION (STRICT)

Implement:

  1. tripToRenderBlocks.ts
  2. tripToCalendarEvents.ts
  3. exportTrip() action
  4. 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() in trip-offer.actions.ts — creates offer from trip with line items
  • checkoutOffer() in app/orders/actions/checkoutOffer.ts — converts Offer → Order (manual payment)
  • Fixed bug: checkout now uses trip.offerId instead of trip.projectId
  • Server component fetches associatedOffer and passes offerId to 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 works
  • mapTripPricingToLineItems() includes hotel rows (when >40 days)
  • Missing: Canonical quote→Offer→Order path not locked; createOfferFromTrip not functionally verified
  • Next: Browser verification of offer preview with line items

Hotel Policy Closure ✅ Partial

  • computeHotelPolicy() implemented in mapTripPricingToLineItems.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 snapshot
  • BookingBundlePanel UI component created
  • confirmBookingItem() and updateBookingBundleStatus() 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

LayerRoleWhen to Use
FareHarborDistribution + standardized bookingFeatured, ready-to-book, standardized products
Molino internalFlexibility + control + expansionCustom 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 all
  • internal_only — Custom trips, PDFs, services
  • dual — 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_only OR dual
  • 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 → generates content-groups x N (caution: don't skip relation levels or create loops)
  • Trip already related to: TripCity + TripCityExperience (or TripCityPlan)

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:

  1. Featured experience sessions (from TripCityExperience linked to weekly agenda)
  2. Trip arrival/departure options (exclusive to specific trips)
  3. 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:

  1. Open /trips and confirm the public front door renders cleanly. ✅
  2. Check that the four commercial lanes are visible near the top. ✅
  3. Click one CTA from each lane and verify every route lands somewhere real. ✅
  4. Open one trip detail and one schedule page to confirm read-only separation. ✅
  5. Open the planner surface and verify it stays private and separate from public browsing. ✅
  6. Click one booking button and confirm FareHarbor behaves as button/modal execution only. 🟡
  7. 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 orchestration
  • actions/ → Prisma + mutation authority
  • components/ → dumb UI only
  • context/ → ephemeral planner UI state only
  • api/ → external integrations only
  • adapters/ → 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.tsx reads and orchestrates only
  • app/trips/actions/ owns mutation logic
  • app/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.ts
  • app/trips/actions/tripsLanding.actions.ts
  • app/trips/actions/trips.planner.read.actions.ts
  • app/trips/actions/trips.planner.edit.read.actions.ts
  • app/trips/actions/trip-join.create.ts
  • app/trips/actions/trip-join.recompute.ts
  • app/trips/actions/trip.update.actions.ts
  • app/trips/actions/createTripDraft.ts
  • app/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:

  1. Prisma models
  2. Route alias resolver
  3. Public renderer
  4. Publish validation gate
  5. Minimal editor
  6. Native slider
  7. 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:

  1. Normalize requested path.
  2. Reject invalid/reserved paths.
  3. Check exact route collisions.
  4. Check domain route policy.
  5. Look up SpaceRouteAlias by pathFingerprint.
  6. Require alias.enabled = true.
  7. Require Space.status = published.
  8. Require visibility = public or unlisted.
  9. Return render payload.
  10. 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:

  1. Add Prisma enums/models.
  2. Generate Prisma client.
  3. Create routeValidation.ts.
  4. Create spaceSectionRegistry.ts with hero/rich_text/cards/cta/slider_native.
  5. Create resolveSpaceRoute.ts.
  6. Create SpaceCanvas.tsx.
  7. Create SpaceSectionRenderer.tsx.
  8. Create app/(spaces)/[...slug]/page.tsx.
  9. Create publishSpace.ts with validation.
  10. 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

  1. Clone Trip Action

    • Create app/trips/actions/cloneTrip.ts
    • Deep copy: Trip → Cities → Plans → Settings
  2. City Plan Binding

    • Create app/trips/actions/addCityPlan.ts
    • Bind Experience to TripCity
  3. Booking with Stripe

    • Adapt createBookingAction from legacy
    • Create BookingBundle → Stripe checkout

Next Week

  1. Trip Sharing via Email

    • Invite flow with email
    • Invitee joins → recalculates
  2. 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)

StepWhat HappensWho's Responsible
1. User promptNatural language requestHuman user
2. Parse + structureLLM converts prompt → form datauseAIEngine() context
3. Form fillMerge structured data into form stateSection-specific mapper function
4. ValidateRun form validation rulesSection form logic
5. Execute actionCreate/update entity in databasePrisma + Next.js action
6. RedirectNavigate to entity surfaceRouter

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 and emitIntent()
  • 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)mergeAIIntentIntoFormcreateTripFromPlanner
  • 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

ComponentCurrentTarget
ChatPaneldocuments onlyReusable as AIFormPanel
Handler hookPer-sectionExtract shared useAIFormHandler
ContextuseDocument(), useTripPlanner()Shared useRouteContext() with routeScope
Persona selectorHardcoded per panelCanonical single source
Action functionsPer-sectionGeneric 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

  1. 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 — add routeScope, routeSurface (not done)
  2. Per-section (update to use shared):

    • app/documents/components/doc-sidebar/features/ChatPannel.tsx
    • app/trips/new/components/TripPlannerAssistantPanel.tsx
    • app/mlv/*/components/*AssistantPanel.tsx (if exists)
    • app/experiences/*/components/*AssistantPanel.tsx (if exists)

CSS + Styling Standardization

  • Sidebar shell: Apply EditorialTheme styles 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 TypeRecalculates on Join?Join ButtonSharing
Featured (fixed price)❌ No — fixed priceFH modalPublic link
Private owned by you✅ Yes — recalculatesTripJoinPanel recalcEmail + user
Private shared to you✅ Yes — recalculatesView + joinEmail invitation

TripJoinPanel (app/trips/[tripId]/TripJoinPanel.tsx)

  • Already has: numParticipants, room selection, pricing inputs
  • Recalculates totals when pax/rooms change
  • To implement: Wire pricing.totalWithMarkup recalculation 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)

ModelStatusRole in Silk Route Engine
CityAgendaWeek✅ Schema + seedPer-city weekly template (Granada, Córdoba, etc.)
CityAgendaSlot✅ Schema + seedWeekday/time slot linked to Experience
GroupSession✅ Schema + seedScheduled instance (date, time, capacity, guide)
GroupSessionAttendee✅ SchemaTrip pax + FIT pax merged into same session
Experience✅ SchemaActs as ExperienceTemplate (title, price, duration, city)
SessionStack✅ Schema + seedMulti-session journey container
SessionPath✅ SchemaSequence of sessions in a stack
SessionPathItem✅ SchemaIndividual step in a session path
BookingBundle + BookingItem✅ Just addedExecution separation from Trip planning
City✅ SchemaCities have agendaWeeks relation

What's Missing (⏳ Not Built)

LayerStatusWhat It Should Do
SessionInstance generation engineConvert CityAgendaSlot → real-date GroupSession instances
Session P&L viewMin cost, break-even point, margin curve per session
FIT routing into existing sessionsFIT booking UI preferentially offers existing SessionInstances
Guide/apprentice/merchant fieldsGroupSession needs guidePrimary, guideApprentice, affiliateStops[], consignedProducts[]
Season mode logicCityAgendaWeek.seasonMode exists but no code uses it
Content hooks layerPost-session reflection → blog/template/consulting pipeline
Weekly agenda UINo page to view/edit CityAgendaWeek per city
Operational calendarNo calendar view showing SessionInstances across cities
Maestro de Ritmos persona integrationNo AI context prompt or brand voice layer yet
7 Cash-Flow Flows taggingNo enum/category on models linking to the 7 flows

The 4 Nested Gears — Current State

  1. Year Wheel (Macro Seasonality) — seasonMode field exists on CityAgendaWeek but unused in code
  2. Season Mode — No code logic for Spring–Autumn vs Autumn–Winter switching
  3. Weekly Silk Route AgendaCityAgendaWeek + CityAgendaSlot exist in schema + seed, zero UI
  4. Single SessionInstance LifecycleGroupSession exists, generation engine now built ✅

Recommended Next Build Order

  1. SessionInstance generation engine — Server function that takes CityAgendaSlot + date range → creates GroupSession rows
  2. Guide/merchant fields on GroupSession — Add guideId (exists), apprenticeId, affiliateStops (JSON), consignedProducts (JSON), contentHooks (JSON)
  3. Session P&L view — Compute min cost coverage, margin after break-even, affiliate + product income per session
  4. Weekly agenda UI — Simple page at /studio/agenda/[citySlug] showing slots + generated sessions
  5. FIT routing — When FIT user books an experience, show existing SessionInstances first
  6. 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):

  1. 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)
  2. Etsy Sales Dashboard

    • Track Etsy orders manually or via API
    • Link Etsy sales to Molino PDF products
    • Status: ⚪ Future (after 30-50 consistent sales)
  3. 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 FileWhat It DoesMolino Equivalent Needed
trips.tscloneTrip, CRUDcloneTrip action
bookings.tscreateBooking with StripecreateBooking with Stripe
tripCities.tsgetTripCity, updateTripCityTripCity update action
tripCityPlans.tsaddTripCityPlan, getCityPlansBind Experience to TripCity
plans.tsgetPlans, getPlanByIdExperience listing

Key Legacy Patterns to Preserve

  1. Stripe Checkout Flow (bookings.ts:11-50)

    • Creates booking → returns Stripe checkout URL
    • User redirected → payment → webhook confirms
  2. Trip Cloning (trips.ts:36-58)

    • Clone trip with new dates/owner
    • Deep copy: cities, plans, settings
  3. City Plan Binding (tripCityPlans.ts:8-26)

    • Bind Experience (plan) to TripCity
    • Creates TripCityPlan relation

Priority Actions to Port

#Legacy ActionTarget FileStatus
1cloneTripapp/trips/actions/cloneTrip.ts⏳ Not started
2createBookingAction (Stripe)app/trips/actions/createBooking.ts⏳ Not started
3getCityPlans (available plans)app/trips/actions/getCityPlans.ts⏳ Not started
4addTripCityPlan (bind plan to city)app/trips/actions/addCityPlan.ts⏳ Not started
5getTripCityByIdapp/trips/actions/getTripCity.ts⏳ Not started
6updateTripCityapp/trips/actions/updateTripCity.ts⏳ Not started
7getBookingsapp/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:

ConcernNameTypeResponsibility
Booking InitiationuseBookingModalClient hookOpen Lightframe, pass params, handle user intent
Post-Booking SyncuseBookingSync + syncBookingClient polling / server webhookKnow when FH confirms, update UI and DB

Shipped:

  • hooks/useBookingModal.ts — Opens FH Lightframe modal
  • hooks/useBookingSync.ts — Polls GET /api/bookings/pending every 3s
  • POST /api/webhooks/fareharbor — Receives webhooks, writes to ExternalBooking, forwards to GAS
  • GET /api/bookings/pending — Poll endpoint for confirmed bookings by email
  • ExternalBooking model in Prisma schema
  • FareHarborBookingCard.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 + onUpdateBlock props to TripItineraryCanvas, wired from TripCanonicalBuilderShell.
    • Fixed sidebar toggle: now uses closeSidebar() instead of openSidebar(null), scoped to activeTool === "trip-planner".
    • Set EditModeContext sidebarOpen default from true to false (hidden by default).
    • Widened sidebar from 320px → 420px, added tabbed panel (Route | Details | Estimate | Itinerary) hosting TripBlockStack + TripDetailsForm + TripEstimatePanel + TripItineraryPreview.
    • Added featured and shareForOthers to updateBuilderTrip action, BuilderTripEssentials type, and admin toggle UI in shell header.
    • Added featured + shareForOthers fields to updateBuilderTrip server action and BuilderTripEssentials type.
  • 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:

  • Trip model (schema.prisma:883+)
  • Offer model (schema.prisma:1163+)
  • Order model (schema.prisma:1189+)
  • LineItem model (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:

  • BookingBundle model (schema.prisma:1213+)
  • BookingItem model (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() in mapTripPricingToLineItems.ts
  • Types: HotelPolicy type ("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, PublicTripCard types with accountShortname, itemId, flowId
  • FH Account: alandalus-experience
  • Flow IDs: Main 1361158, Group 1362489, City tours 1610878-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 project andalusian-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 + CityAgendaSlot models (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 from CityAgendaSlot with 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, ConceptGroupItem models
  • Factory: lib/factories/contentStackFactory.ts
  • Schema: contentStackId on Trip and Experience

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 FileWhat It DoesMolino Equivalent Needed
trips.tscloneTrip, CRUDcloneTrip action
bookings.tscreateBooking with StripecreateBooking with Stripe
tripCities.tsgetTripCity, updateTripCityTripCity update action
tripCityPlans.tsaddTripCityPlan, getCityPlansBind Experience to TripCity
plans.tsgetPlans, getPlanByIdExperience listing

Key Legacy Patterns to Preserve

  1. Stripe Checkout Flow (bookings.ts:11-50)

    • Creates booking → returns Stripe checkout URL
    • User redirected → payment → webhook confirms
  2. Trip Cloning (trips.ts:36-58)

    • Clone trip with new dates/owner
    • Deep copy: cities, plans, settings
  3. City Plan Binding (tripCityPlans.ts:8-26)

    • Bind Experience (plan) to TripCity
    • Creates TripCityPlan relation

Priority Actions to Port

#Legacy ActionTarget FileStatus
1cloneTripapp/trips/actions/cloneTrip.ts⏳ Not started
2createBookingAction (Stripe)app/trips/actions/createBooking.ts⏳ Not started
3getCityPlans (available plans)app/trips/actions/getCityPlans.ts⏳ Not started
4addTripCityPlan (bind plan to city)app/trips/actions/addCityPlan.ts⏳ Not started
5getTripCityByIdapp/trips/actions/getTripCity.ts⏳ Not started
6updateTripCityapp/trips/actions/updateTripCity.ts⏳ Not started
7getBookingsapp/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

ComponentStatusBlocks Shipping?
Trip → Offer → Order (manual payment)✅ ShippingNo
Etsy PDF generation✅ ShippingNo
Public Trips surface✅ ShippingNo
FareHarbor Lightframe✅ ShippingNo
BookingBundle execution⚠️ Pending verifyNo (optional)
Hotel policy UI⚠️ Pending verifyNo (optional)
Export Routes (Apps Script)⏳ In progressYES
Stripe integration❌ Not startedNo (manual OK)
Spaces feature⏳ Spec readyNo (optional)

🔮 Future Scope (Not Part of Current Critical Shipping)

External Channel Tracking (Post-Revenue)

Future development (NOT urgent, NOT blocking first sales):

  1. 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)
  2. Etsy Sales Dashboard

    • Track Etsy orders manually or via API
    • Link Etsy sales to Molino PDF products
    • Status: ⚪ Future (after 30-50 consistent sales)
  3. 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)

  1. 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
  2. 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


📋 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

ComponentStatusBlocks Shipping?
Trip → Offer → Order (manual payment)✅ ShippingNo
Etsy PDF generation✅ ShippingNo
Public Trips surface✅ ShippingNo
Experiences commercial surface✅ ShippingNo
FareHarbor Lightframe✅ ShippingNo
Content Stack Factory (auto-generated editable content)✅ ShippingNo
Export Routes (Apps Script)✅ ShippingNo
BookingBundle execution⚠️ Pending verifyNo (optional)
Hotel policy UI⚠️ Pending verifyNo (optional)
FareHarbor Booking Sync (webhook + polling)✅ ShippingNo
Stripe integration❌ Not startedNo (manual OK)
Spaces feature⏳ Spec readyNo (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)

  1. Stripe Integrationapp/orders/actions/, app/api/stripe/
  2. FareHarbor catalogue sheet → run FareHarborCatalog_refresh() to normalize, then FareHarborCatalog_postToNext() to import into Next
  3. FareHarbor ICS Calendar → verify /api/fareharbor/calendar on staging
  4. Spaces Featureapp/(spaces)/
  5. TypeScript cleanup → Fix ~189 remaining errors (background task, not blocking)
  6. Browser verification → Trip → Offer → Order → Manual Payment with real data
  7. 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, ExternalBooking model)
  • ICS calendar integrationGET /api/fareharbor/calendar parses FH ICS feed into JSON events; token required via FAREHARBOR_ICS_TOKEN
  • FareHarbor catalogue sheet helper — Apps Script can normalize the horizontal catalogue into one product per row
  • FareHarbor catalogue import endpointPOST /api/fareharbor/catalog/import upserts normalized products into Experience records 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 (contentStackId on Trip + Experience, ExternalBooking model)
  • 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), computeRouteStats integrated with type-safe cast, handleGenerateLanding callback 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/admin with 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/cities now shows thumbnail images
    • Builder canvas made full-width (max-w-5xlw-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"
    • getFeaturedCities return includes mainImage
    • getPlannerCities select includes mainImage
  • remaining open issue: need to run npm run seed-cities to populate images; browser/TS verification pending
  • next action: run seed script, browser-test city admin + detail pages + builder full-width layout

Next Steps

  1. Run npx tsx prisma/seed-cities.ts to populate city images
  2. Browser test city admin CRUD, city detail pages, builder full-width
  3. Stripe Integration — translate NestJS pattern to Next.js server actions
  4. Browser test Trip → Offer → Order → Manual Payment flow
  5. Test Hotelbeds API (search by geo, search by city, check rates)
  6. Deploy to staging and verify all routes