Skip to content

Product Templates

ProductTemplate defines what a trip IS - its route, structure, and identity. Content is written in a source locale and can be translated when creating market-specific versions.

  • Define trip itinerary with POI-based locations and multi-segment routes
  • Store base marketing content
  • Calculate trip duration automatically
  • Provide foundation for market-specific products

Table: product_templates

Field Type Purpose
title varchar Product name
subtitle varchar Marketing tagline
sku varchar Auto-generated: <ID>-<DAYS>
source_locale varchar Content language (e.g., en_US)
itinerary jsonb Array of arrival + day + departure items with POIs and routes
duration int Trip length in days (auto-calculated)
highlights jsonb Key selling points
categories jsonb Product categories (beach, luxury, etc.)
included_in_price jsonb (nullable) Source-locale “What’s included” defaults keyed by category
not_included text (nullable) Source-locale free-text “What’s not included” block
tcai_profile_id FK (nullable) Optional AI personality profile

Source: backend/app/Models/ProductTemplate.php

included_in_price is a fixed-key JSON map used as the source-locale baseline for both the PDP and checkout conditions UI:

{
"flights": "International flights from Madrid",
"transfers": "Airport-hotel-airport transfers",
"accommodation": "4-star boutique hotels",
"gastronomy": "Daily breakfast and selected lunches",
"activities": "Guided excursions and entry tickets",
"cancellation_insurance": "Basic cancellation coverage"
}

All six keys are optional. Empty or missing categories are hidden on the frontend.

The legacy meal_plans and accommodation_details columns are still present in the database for rollback safety, but the admin UI now writes the structured included_in_price map instead. The migration backfills:

  • meal_plans -> included_in_price.gastronomy
  • accommodation_details -> included_in_price.accommodation

The itinerary uses a structured format: arrival item (entry point), followed by stop items (day content), followed by a departure item (exit point).

Shape: [arrival, day1, day2, ..., dayN, departure]

[
{
"type": "arrival",
"arrival_poi_id": 123
},
{
"type": "day",
"locations": [456, 789],
"nights": 2,
"routes": [
{"from": "Nairobi", "to": "Amboseli", "from_id": 456, "to_id": 789, "mode": "arnk"}
],
"days": [
{"day_label": "Day 1 - Amboseli", "title": "Safari Adventure", "details": "Game drives...", "day_image": "path/to/image.jpg"},
{"day_label": "Day 2 - Amboseli", "title": "Morning Safari", "details": "Early drive...", "day_image": null}
]
},
{
"type": "day",
"locations": [789, 456, 321],
"nights": 0,
"routes": [
{"from": "Amboseli", "to": "Nairobi", "from_id": 789, "to_id": 456, "mode": "arnk"},
{"from": "Nairobi", "to": "Lake Naivasha", "from_id": 456, "to_id": 321, "mode": "flight"}
],
"days": [
{"day_label": "Day 3", "title": "Transfer & Flight", "details": "Morning drive to Nairobi...", "day_image": null}
]
},
{
"type": "departure",
"departure_poi_id": 456
}
]

Each stop contains a days[] array with per-day content. The array length follows the rule: days.length = max(nights, 1) – departure stops (0 nights) still get 1 day entry.

Field Type Purpose
day_label string Display label (e.g., “Day 1 - Medellín”)
title string Day title
details string Day description/activities
day_image string or null Image path (stored on template only, not in translations)

When the nights value changes in the admin form, the days array auto-resizes: growing appends empty entries, shrinking truncates from the end. Existing day content is preserved by index.

Legacy support: Old itineraries without days[] are normalized on load. Stop-level title, details, and day_image fields seed the first day entry.

Type Fields Purpose
Arrival type, arrival_poi_id, flight_days Entry point to the trip (first item)
Day type, locations, nights, routes, days[] Journey stop with per-day content
Departure type, departure_poi_id, flight_days Exit point of the trip (last item)

Arrival and Departure are “endpoint” items – they hold airport metadata, not day stops. Use isEndpointItem() to skip them when iterating day stops.

Source: backend/app/Enums/ItineraryItemType.php

Endpoint items carry an integer flight_days (0–ItinerarySchema::MAX_FLIGHT_DAYS, currently 2) counting the nights the traveler sleeps aloft: on the arrival item it is the outbound flight, on the departure item the return flight. It generalizes the legacy boolean has_overnight_flight; the cap matches the count-keyed copy sets (1 and 2) that exist and is not backfilled by a migration.

Always read the count through ItinerarySchema::resolveFlightDays($endpointItem), the single read path. Precedence:

  1. A present, numeric flight_days wins (clamped to 0–MAX_FLIGHT_DAYS) — an explicit 0 therefore overrides a stale has_overnight_flight = true.
  2. A missing/non-numeric flight_days falls through to the legacy boolean: has_overnight_flight === true yields 1.
  3. Otherwise 0.

The legacy has_overnight_flight boolean is retained as a fallback (no data migration). The admin control is a 0MAX_FLIGHT_DAYS number input on the arrival/departure repeater items (structurally locked when a market product is active); on form load, endpoints missing flight_days are seeded from the resolver. Consumers: Products by Market API exposes flight_days plus a derived has_overnight_flight (count > 0); the trip page (Booking Details API) renders virtual flight-day entries; checkout shifts the return-flight search date by the outbound count.

The standard narrative is always the first overnight night: Mientras duermes {ciudad} te espera on the outbound (dynamic city) and El final de un gran viaje on the return, with fixed descriptions. When a leg has 2 overnight nights, the second night is a distinct slot with a fixed title — A una película de distancia (outbound) / Próximamente en los mejores destinos (return) — and a per-market editable description (see below). These strings are hardcoded Spanish, mirrored between the PDP mapper (productToViajeProps.ts) and the trip page (BookingDetailsResource).

The second overnight night’s description resolves through three tiers, mirroring how arrival_description / departure_description work:

  1. Per-market override — flat columns arrival_day2_description / departure_day2_description on product_by_market_translations, edited on the Products by Market form.
  2. Tour source defaultarrival_day2_description / departure_day2_description keys on the template endpoint items, edited on the tour ItineraryRepeater (shown only when that leg’s flight_days is 2). Used when the market override is blank.
  3. Hardcoded agnostic fallback — a place-agnostic default in the render layer, used when both above are blank.

The resolved value is surfaced on the API endpoint items under the same keys (mirroring the day-2-image asymmetry). Both consumers — the PDP resource (ProductByMarketResource) and the trip page (BookingDetailsResource) — apply the same override→template→fallback chain.

Each endpoint carries a primary image (arrival_image / departure_image) plus an optional second image (arrival_day2_image / departure_day2_image). A 1-night flight renders one day using the primary image. A 2-night flight renders two days: the first (standard-copy) day uses the primary image and the second (editable) day uses the day-2 image. When the day-2 image is unset it falls back to the primary, so a flight day is never blank. Both consumers apply this identically — the PDP mapper (productToViajeProps.ts) and the trip page (BookingDetailsResource::flightDayImages()).

Source: backend/app/Support/ItinerarySchema.php

Routes use the RouteMode enum to distinguish transport types:

Mode Value Description
Flight flight Air travel between locations
Surface arnk Ground transport (road, rail, etc.)

The arnk value follows airline terminology for “Arrival Not Known” - used for surface segments in multi-city itineraries.

Source: backend/app/Enums/RouteMode.php

ItinerarySchema is the single source of truth for itinerary structure. Use it when working with itinerary data programmatically.

use App\Support\ItinerarySchema;
// Create items
ItinerarySchema::createArrivalItem($poiId);
ItinerarySchema::createDepartureItem($poiId);
ItinerarySchema::createDayItem($locationIds, $nights, $routes, $title, $details);
ItinerarySchema::createRoute($from, $to, RouteMode::Flight, $fromId, $toId);
// Per-day content
ItinerarySchema::createDayEntry($dayLabel, $title, $details, $dayImage);
ItinerarySchema::buildDaysArray($nights, $existingDays); // Resize days to match nights
ItinerarySchema::ensureDaysArray($item); // Normalize stop (add days if missing)
// Translation merge (text from translation, images from template)
ItinerarySchema::buildTranslationDays($templateStop, $translatedStop);
// Day image management (flat view for admin form)
ItinerarySchema::flattenDaysWithPointers($itinerary); // Flatten for display
ItinerarySchema::mergeDayImagesIntoItinerary($itinerary, $rows); // Write images back
// Check item types
ItinerarySchema::isEndpointItem($item); // true for arrival OR departure (use for skip/filter)
ItinerarySchema::isArrival($item); // true for arrival only
ItinerarySchema::isDeparture($item); // true for departure only
ItinerarySchema::isFlightRoute($route);
// Overnight flight days on an endpoint (int > legacy boolean > 0)
ItinerarySchema::resolveFlightDays($endpointItem);

Source: backend/app/Support/ItinerarySchema.php

Duration is automatically calculated from itinerary day items:

duration = total_nights + 1

Endpoint items (arrival and departure) are skipped (no nights). Example: Day 1 (2 nights) + Day 2 (1 night) = 3 nights + 1 = 4 days.

Source: backend/app/Filament/Resources/ProductTemplates/Support/ItineraryCalculator.php

The itinerary enforces route continuity: each day must start where the previous day ended. When using AI generation, the service automatically inserts connecting segments to maintain a continuous route graph.

Example: If Day 1 ends in Amboseli and Day 2 starts with Nairobi, a connecting segment is added automatically.

Helper class for itinerary computations:

use App\Filament\Resources\ProductTemplates\Support\ItineraryCalculator;
ItineraryCalculator::calculateDuration($itinerary); // Total days
ItineraryCalculator::getArrivalLocation($itinerary); // Entry city name
ItineraryCalculator::getAllLocations($itinerary); // All unique location names
ItineraryCalculator::hasFlightSegments($itinerary); // Has any flights?
ItineraryCalculator::generateHotelAssignments($itinerary); // Hotel assignment array

Source: backend/app/Filament/Resources/ProductTemplates/Support/ItineraryCalculator.php

The ItineraryRepeater provides the Filament form interface:

  • First item is always arrival type, last item is always departure type (neither can be deleted)
  • Departure item has a departure_poi_id field (same IATA PoiSelect widget as arrival’s arrival_poi_id)
  • Legacy tours without a departure item get one auto-appended on form hydration
  • POI multi-select for day locations with Google Places search
  • Auto-generated routes from location sequence
  • Route mode selection (flight/surface)
  • Nested days repeater within each stop for per-day day_label, title, details, and optional day_image
  • Days array auto-resizes when nights value changes (preserves existing content by index)
  • On hydration, old itineraries without days[] are normalized via ensureDaysArray(), and legacy tours without a departure item get one auto-appended
  • Duration auto-updates on change

Day images: The SupplierTour form presents a derived flat repeater (_day_images) that reads from the itinerary structure using flattenDaysWithPointers(). On save, images are merged back into itinerary[stop].days[day].day_image via mergeDayImagesIntoItinerary(). This keeps image management in the media step while storing images within the itinerary JSON. The same projection is used on the dedicated Edit Tour Media page, which provides an isolated Livewire component for the media fields.

When a structural change (nights/stops) rebuilds the _day_images projection, each rebuilt row takes its day_image from the existing projection state keyed by (stop_index, day_index) — never from the value flattenDaysWithPointers() reads off the itinerary state. The projection holds the already-hydrated FileUpload state (an array, or null), whereas the itinerary state carries day_image as a bare string (the itinerary repeater is configured with includeImages: false, leaving it an unmapped key). Writing a bare string back via a reactive $set() would bypass the FileUpload’s hydration and crash the FileUpload validation on save (it expects an array), so the projection value is always preferred. See #2025.

Source: backend/app/Filament/Resources/ProductTemplates/Schemas/Components/ItineraryRepeater.php

Templates support AI-powered content generation with automatic POI resolution:

  1. TCAI Chat Modal (primary flow) – Paste raw trip info in the SupplierTour form, click “Generate with TCAI”. An interactive chat opens where the agent confirms destinations, airports, and city/nights distribution before generating. Content is returned to the form without persisting to DB. See AI System - TCAI Chatbox Modal.
  2. Itinerary-only Generation – “Generate Itinerary with TCAI” button generates just the itinerary from raw input (no marketing content), useful when only the route structure is needed.
  3. Per-field Refinement – Sparkles icons on form fields for targeted AI edits using FieldContentAgent.

The AI service:

  • Generates itinerary in the structured schema format
  • Resolves location names to POI IDs via database and Google Places
  • Ensures route continuity between days
  • Handles multi-segment days (e.g., drive + flight)
  • Includes fallback parsing for shorthand formats (5N, 7D, dash-separated N City)

See AI System for full agent architecture and configuration.

Source: backend/app/Services/ProductTemplateAIService.php

ProductTemplate has an optional tcai_profile_id FK to TcaiProfile. TCAI profiles provide custom style instructions that influence AI content generation tone and structure. The AI service uses the template’s assigned profile when available, falling back to the default profile otherwise.

Individual fields (subtitle, descriptions, highlights, etc.) can be refined using AI without regenerating all content. Sparkles icon buttons on form fields open a modal where the user provides instructions and selects a TCAI profile. The refinement is handled by ProductTemplateAIService::refineField() via FieldContentAgent.

Source: backend/app/Filament/Resources/ProductTemplates/Actions/RefineFieldAction.php

The FlightRouteConfigGenerator analyzes itineraries to extract flight segments for booking configuration:

  • Reads explicit departure_poi_id from the departure item for the return leg; falls back to inference from last stop for legacy tours
  • Identifies arrival location (first international flight destination)
  • Extracts routes with mode: flight for domestic segments
  • Deduplicates consecutive stops sharing the same airport
  • Generates multi-city and separate flight options

FlightRouteValidationService uses this generator to validate that international legs have actual flight availability by performing a test Aerticket API search. See AerTicket - Validate Flight Route for details.

Source: backend/app/Services/Flights/FlightRouteConfigGenerator.php, backend/app/Services/Flights/FlightRouteValidationService.php

  • SKU is auto-generated on save (cannot be manually edited)
  • Duration auto-calculates from itinerary nights
  • First itinerary item must be arrival type, last must be departure type
  • Route continuity is enforced (no gaps in route graph)
  • Templates with an active ProductByMarket are “locked” for structural edits; draft-only links leave the tour editable for admins (see Tour Lock Behavior)
  • Itinerary changes propagate to market products; translation itineraries are realigned automatically by ProductTemplateObserver