Products by Market
ProductByMarket represents how a ProductTemplate is sold in a specific market. It connects the base product to a market, adds localized content, and configures flight search parameters.
Purpose
Section titled “Purpose”- Assign products to markets (ES, DE, FR)
- Store market-specific locale (es_ES, ca_ES for Spain)
- Configure departure airports and flight routes
- Define search date ranges for flight caching
- Hold translated content via ProductByMarketTranslation
Data Model
Section titled “Data Model”Table: products_by_market
| Field | Type | Purpose |
|---|---|---|
| product_template_id | FK | Reference to ProductTemplate |
| market_id | FK | Reference to Market |
| locale | varchar | Product locale (e.g., es_ES) |
| sku | varchar | Market SKU: ES-138-10-ES1 |
| status | varchar | draft, active, inactive |
| trip_duration_days | int | Override template duration (API falls back to ProductTemplate.duration when null) |
| sort_order | int | Display ordering (default: 0) |
| search_start_date | date | Flight search period start |
| search_end_date | date | Flight search period end |
| excluded_dates | jsonb | Dates to skip in searches |
| celebrity_page_id | FK (nullable) | Optional link to CmsCelebrityPage — replaces gallery with celebrity section |
Unique Constraint: product_template_id + market_id + locale
Source: backend/app/Models/ProductByMarket.php
SKU Format
Section titled “SKU Format”<MARKET>-<TEMPLATE_ID>-<DAYS>-<LANG><VERSION>Examples:
ES-138-10-ES1- Spain, template 138, 10 days, Spanish, version 1ES-138-10-CA1- Spain, template 138, 10 days, Catalan, version 1DE-138-10-DE1- Germany, template 138, 10 days, German, version 1
Source: backend/app/Models/ProductByMarket.php:268-360
Translations
Section titled “Translations”Translated content is stored in ProductByMarketTranslation:
| Field | Purpose |
|---|---|
| title, subtitle | Localized product name |
| short_description, long_description | Marketing copy |
| itinerary | Translated stop content including per-day days[] (day_label, title, details) |
| url_slug | SEO-friendly URL path |
| meta_title, meta_description | SEO metadata |
Itinerary Translation Merge
Section titled “Itinerary Translation Merge”When publishing to a market or editing translations, the itinerary is merged from the ProductTemplate structure and the translated content using ItinerarySchema::buildTranslationDays(). The merge follows these rules:
- Text (
day_label,title,details) comes from the translation, falling back to the template - Images (
day_image) are never stored in translations — they come from the ProductTemplate only - Activities (
activities[]) come from the SupplierTour itinerary day assignments, not from translations - The API resource (
ProductByMarketResource) merges bothday_imageandactivitiesinto the response at request time
Images and activities are only merged on detail endpoints (by ID, slug, SKU, preview) — the listing endpoint skips them to avoid N+1 queries.
This merge happens in 4 locations: PublishToMarketAction, EditProductByMarket, CreateProductByMarket, and ProductByMarketForm.
AI Translation: Use “Translate from Product” button in admin to auto-translate all fields. The AI prompt handles nested days[] with day_label, title, and details per day.
Source: backend/app/Services/ProductByMarketTranslationService.php, backend/app/Support/ItinerarySchema.php
Supplier Entity Translations in Detail View
Section titled “Supplier Entity Translations in Detail View”On detail endpoints (by ID, slug, SKU, preview), hotel and activity content from supplier entities is also returned in the market locale. ProductByMarketResource calls $hotel->translated('description', $locale) and $activity->translated('name', $locale) with fallback to the source language. Translations are eager-loaded to prevent N+1 queries.
See Supplier Entity Translations for the full translation architecture.
Flight Configuration
Section titled “Flight Configuration”Each ProductByMarket can have multiple departure airports, each with its own route configuration.
Flight Config Hierarchy
Section titled “Flight Config Hierarchy”ProductByMarket ├── search_start_date, search_end_date (shared) ├── excluded_dates (shared) │ └── ProductByMarketFlightConfig (per departure airport) ├── airport_id (MAD, BCN, etc.) ├── type (multi_city or separate) ├── is_active │ └── ProductByMarketFlightLeg[] (route segments) ├── leg_index ├── origin_airport_id ├── destination_airport_id ├── origin_city ├── destination_city ├── day_offset └── flight_type (international, domestic, arnk)Flight Types
Section titled “Flight Types”| Type | Description | API Calls |
|---|---|---|
| multi_city | Single booking for all legs | 1 per date |
| separate | Individual bookings per leg | N per date |
Leg Types
Section titled “Leg Types”| Type | Description |
|---|---|
| international | Cross-border flight (requires API search) |
| domestic | Same-country flight (requires API search) |
| arnk | Ground transport (no flight search needed) |
Source: backend/app/Models/ProductByMarketFlightConfig.php, backend/app/Models/ProductByMarketFlightLeg.php
Flight Config to Offer Mapping
Section titled “Flight Config to Offer Mapping”When creating an offer, the wizard uses flight config legs to determine required selections:
- International legs are combined into a single multi-city search
- Domestic legs are searched separately (each creates an OfferFlight record)
- ARNK legs are skipped (no flight selection needed)
The leg_index from ProductByMarketFlightLeg carries through to OfferFlight, maintaining the trip sequence.
See Offers for the selection workflow and OfferFlight model.
Search Date Configuration
Section titled “Search Date Configuration”Flight searches are configured with:
- search_start_date - First departure date to search
- search_end_date - Last departure date to search
- excluded_dates - Specific dates to skip (holidays, blackouts)
$product->shouldSearchDate($date); // Combined check$product->isDateInSearchRange($date); // Range check only$product->isDateExcluded($date); // Exclusion check only$product->getSearchableDaysCount(); // Total searchable daysSource: backend/app/Models/ProductByMarket.php:219-265
API Calls Estimation
Section titled “API Calls Estimation”// Per flight config$config->getApiCallsPerDate(); // 1 for multi_city, N for separate$config->getTotalEstimatedApiCalls(); // Days * calls per date
// Per product by market (all configs)$product->getTotalEstimatedApiCalls(); // Sum across all configsPreview URLs
Section titled “Preview URLs”ProductByMarket supports generating preview URLs for viewing draft/inactive products on the frontend.
// Generate signed API URL (expires in 60 minutes by default)$apiUrl = $product->generatePreviewUrl(60);
// Generate frontend URL with embedded preview token$frontendUrl = $product->getFrontendPreviewUrl(60);// Returns: https://frontend.com/es/circuito/slug?preview=<base64_signed_url>The preview URL uses Laravel’s signed URL feature (HMAC-SHA256) to authenticate access without requiring user login.
Source: backend/app/Models/ProductByMarket.php:455-519
Supplier Tour Preview
Section titled “Supplier Tour Preview”A SupplierTour can also be previewed on the frontend before it is published to any market — useful for content editors iterating on a draft tour while no ProductByMarket or translation exists yet. This is distinct from the ProductByMarket preview above: there is no market, no locale override, and no translation — the tour renders directly from its linked ProductTemplate in the template’s source_locale.
Admin Action
Section titled “Admin Action”A “Preview on Frontend” header button is available on the SupplierTour edit and view pages. Clicking it opens the frontend preview in a new tab using a signed URL that expires in 60 minutes.
Source: backend/app/Filament/Resources/Suppliers/SupplierTours/Actions/SupplierTourPreviewAction.php
API Endpoint
Section titled “API Endpoint”GET /api/supplier-tours/{id}/previewProtected by Laravel’s signed:relative middleware. Returns a Product-shaped JSON payload (same shape the frontend ProductPage component consumes for published products) or 404 when the tour has no linked ProductTemplate.
// Generate signed API URL (relative, host-agnostic)$apiUrl = $tour->generatePreviewUrl(60);
// Generate frontend URL with embedded preview token$frontendUrl = $tour->getFrontendPreviewUrl(60);// Returns: {FRONTEND_URL}/preview/tour/{id}?preview=<base64-signed-url>The signed URL is generated as relative (absolute: false) so validation succeeds regardless of which host the SSR reaches. In production the admin (arkana.byvolare.com) and the API (api.byvolare.com) are on different hostnames, and an absolute-signed URL would fail validation when called from the other host. The frontend URL embeds the relative signed path as a URL-safe base64 token; the Astro preview page decodes it and calls the API.
Source: backend/app/Models/SupplierTour.php:251-278, backend/app/Http/Controllers/Api/SupplierTourController.php, backend/routes/api.php:242-244
Frontend Route
Section titled “Frontend Route”/preview/tour/{id}?preview=<base64-signed-url>Renders the same ProductPage component used for published products, so the preview matches production layout. Redirects to /es/404 on invalid token / missing tour and /es/500 on other errors.
Source: frontend/src/pages/preview/tour/[id].astro
Payload Builder
Section titled “Payload Builder”SupplierTourPreviewPayloadBuilder synthesizes the Product payload by:
- Loading the tour’s
ProductTemplateand using itssource_localefor all content - Resolving stored image paths to full URLs (itinerary, hero, gallery, activities, hotels)
- Merging per-day activities from
extraActivitiesandsubstitutionActivitiesinto each itinerary day (matching the merge behavior ofProductByMarketResource) - Grouping hotels by tier (selection / luxury / grand_luxury) with day ranges and lowest-price-from EUR pricing
- Emitting the same accommodation shape as the public resource, including both the legacy
image(first image) andimages[](all images in admin-configured order) for parity withProductByMarketResource::buildTierHotels() - Stubbing market-only fields (
url_slug,country_name,celebrity,departure_airports) asnull/ empty — the frontend tolerates these for the preview route - Using a synthetic SKU
PREVIEW-ST-{id}andstatus: 'preview'
Source: backend/app/Services/SupplierTourPreviewPayloadBuilder.php
Itinerary Endpoint Stripping
Section titled “Itinerary Endpoint Stripping”The payload builder filters out arrival and departure endpoint items from the itinerary before returning it. The frontend’s mapTextItinerary treats every top-level itinerary entry as a day tab, and endpoint items have no days[], so leaving them in would produce empty leading / trailing tabs. Published ProductByMarketTranslation.itinerary arrives endpoint-free already; this filter keeps the preview payload shape consistent with the published shape.
Endpoints are detected via ItinerarySchema::isEndpointItem(). A regression test in backend/tests/Feature/Http/Controllers/Api/SupplierTourPreviewTest.php guards this behavior (issue #1536).
Source: backend/app/Services/SupplierTourPreviewPayloadBuilder.php:74-77, backend/app/Support/ItinerarySchema.php:260-263
Geographic Helpers (GA4)
Section titled “Geographic Helpers (GA4)”Three helper methods resolve the primary destination’s localized name and ISO code from the linked cmsCountries. They’re used by checkout endpoints to populate the offer summary so the frontend can emit consistent GA4 funnel events.
$pbm->getPrimaryCountryName(); // localized (PBM locale), e.g. "Costa Rica"$pbm->getPrimaryRegionName(); // localized via cmsCountries.first().region$pbm->getPrimaryCountryIsoCode(); // ISO-3166-1 alpha-2 (e.g. "CR")All three resolve from the first linked CmsCountry (collection order). They return null when no country is linked, no matching translation exists, or iso_code is unset. Eager-load cmsCountries.translations and cmsCountries.region.translations to avoid N+1 queries.
Source: backend/app/Models/ProductByMarket.php (getPrimaryCountryName, getPrimaryRegionName, getPrimaryCountryIsoCode)
Celebrity Page Link
Section titled “Celebrity Page Link”ProductByMarket can optionally link to a CmsCelebrityPage via celebrity_page_id (nullable FK, nullOnDelete). When linked, the product detail API returns a celebrity key containing eyebrow, title, description, poster, CTA, and trailers from the celebrity page. The frontend renders the CelebritySection component instead of the image gallery.
- In Filament admin, a “Celebrity Page” section shows a market-filtered Select dropdown (only active celebrity pages for the product’s market)
- Changing the market clears any stale celebrity page selection
- If the linked celebrity page is incomplete (missing title or poster image), the API returns
nullfor thecelebritykey and the gallery displays normally
Source: backend/app/Filament/Resources/ProductsByMarket/Schemas/ProductByMarketForm.php:783, backend/app/Http/Resources/ProductByMarketResource.php
Business Rules
Section titled “Business Rules”- One ProductByMarket per template + market + locale combination
- Templates lock structural edits when at least one linked market product is
active; while every linked product is draft/inactive, admins can still edit the tour (see Tour Lock Behavior) - Flight configs are generated based on itinerary when product is created
- Search dates are shared across all departure airports
- ARNK legs do not trigger flight searches
- The linked Market’s
default_marginis inherited by new offers created for this product-market combination (see Offers: Margin Resolution)
Related
Section titled “Related”- Product Templates - Base definitions
- Admin Panel - Filament management
- Flight Search - Search integration
- Database Relationships - Entity diagram and FK references