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.
Purpose
Section titled “Purpose”- 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
Data Model
Section titled “Data Model”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.) |
| tcai_profile_id | FK (nullable) | Optional AI personality profile |
Source: backend/app/Models/ProductTemplate.php
Itinerary Schema
Section titled “Itinerary Schema”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]
Schema Format
Section titled “Schema Format”[ { "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 }]Per-Day Content
Section titled “Per-Day Content”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.
Item Types
Section titled “Item Types”| Type | Fields | Purpose |
|---|---|---|
| Arrival | type, arrival_poi_id | Entry point to the trip (first item) |
| Day | type, locations, nights, routes, days[] | Journey stop with per-day content |
| Departure | type, departure_poi_id | 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
Route Modes
Section titled “Route Modes”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
Schema Helper Class
Section titled “Schema Helper Class”ItinerarySchema is the single source of truth for itinerary structure. Use it when working with itinerary data programmatically.
use App\Support\ItinerarySchema;
// Create itemsItinerarySchema::createArrivalItem($poiId);ItinerarySchema::createDepartureItem($poiId);ItinerarySchema::createDayItem($locationIds, $nights, $routes, $title, $details);ItinerarySchema::createRoute($from, $to, RouteMode::Flight, $fromId, $toId);
// Per-day contentItinerarySchema::createDayEntry($dayLabel, $title, $details, $dayImage);ItinerarySchema::buildDaysArray($nights, $existingDays); // Resize days to match nightsItinerarySchema::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 displayItinerarySchema::mergeDayImagesIntoItinerary($itinerary, $rows); // Write images back
// Check item typesItinerarySchema::isEndpointItem($item); // true for arrival OR departure (use for skip/filter)ItinerarySchema::isArrival($item); // true for arrival onlyItinerarySchema::isDeparture($item); // true for departure onlyItinerarySchema::isFlightRoute($route);Source: backend/app/Support/ItinerarySchema.php
Duration Calculation
Section titled “Duration Calculation”Duration is automatically calculated from itinerary day items:
duration = total_nights + 1Endpoint 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
Route Continuity
Section titled “Route Continuity”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.
Itinerary Calculator
Section titled “Itinerary Calculator”Helper class for itinerary computations:
use App\Filament\Resources\ProductTemplates\Support\ItineraryCalculator;
ItineraryCalculator::calculateDuration($itinerary); // Total daysItineraryCalculator::getArrivalLocation($itinerary); // Entry city nameItineraryCalculator::getAllLocations($itinerary); // All unique location namesItineraryCalculator::hasFlightSegments($itinerary); // Has any flights?ItineraryCalculator::generateHotelAssignments($itinerary); // Hotel assignment arraySource: backend/app/Filament/Resources/ProductTemplates/Support/ItineraryCalculator.php
Admin Form Component
Section titled “Admin Form Component”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_idfield (same IATA PoiSelect widget as arrival’sarrival_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 optionalday_image - Days array auto-resizes when nights value changes (preserves existing content by index)
- On hydration, old itineraries without
days[]are normalized viaensureDaysArray(), 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.
Source: backend/app/Filament/Resources/ProductTemplates/Schemas/Components/ItineraryRepeater.php
AI Content Generation
Section titled “AI Content Generation”Templates support AI-powered content generation with automatic POI resolution:
- 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.
- 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.
- 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-separatedN City)
See AI System for full agent architecture and configuration.
Source: backend/app/Services/ProductTemplateAIService.php
TCAI Profile Integration
Section titled “TCAI Profile Integration”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.
Per-Field AI Refinement
Section titled “Per-Field AI Refinement”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
Flight Route Extraction
Section titled “Flight Route Extraction”The FlightRouteConfigGenerator analyzes itineraries to extract flight segments for booking configuration:
- Reads explicit
departure_poi_idfrom 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: flightfor 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
Business Rules
Section titled “Business Rules”- 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
Related
Section titled “Related”- POI System - Points of Interest database
- Products by Market - Market-specific configuration
- Admin Panel - Filament management
- AI System - Agent architecture and configuration
- Database Relationships - Entity diagram and FK references