Skip to content

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.

  • 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

Table: products_by_market

FieldTypePurpose
product_template_idFKReference to ProductTemplate
market_idFKReference to Market
localevarcharProduct locale (e.g., es_ES)
skuvarcharMarket SKU: ES-138-10-ES1
statusvarchardraft, active, inactive
trip_duration_daysintOverride template duration (API falls back to ProductTemplate.duration when null)
sort_orderintDisplay ordering (default: 0)
search_start_datedateFlight search period start
search_end_datedateFlight search period end
excluded_datesjsonbDates to skip in searches
celebrity_page_idFK (nullable)Optional link to CmsCelebrityPage — replaces gallery with celebrity section

Unique Constraint: product_template_id + market_id + locale

Source: backend/app/Models/ProductByMarket.php

<MARKET>-<TEMPLATE_ID>-<DAYS>-<LANG><VERSION>

Examples:

  • ES-138-10-ES1 - Spain, template 138, 10 days, Spanish, version 1
  • ES-138-10-CA1 - Spain, template 138, 10 days, Catalan, version 1
  • DE-138-10-DE1 - Germany, template 138, 10 days, German, version 1

Source: backend/app/Models/ProductByMarket.php:268-360

Translated content is stored in ProductByMarketTranslation:

FieldPurpose
title, subtitleLocalized product name
short_description, long_descriptionMarketing copy
itineraryTranslated stop content including per-day days[] (day_label, title, details)
url_slugSEO-friendly URL path
meta_title, meta_descriptionSEO metadata

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 both day_image and activities into 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.

Each ProductByMarket can have multiple departure airports, each with its own route configuration.

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)
TypeDescriptionAPI Calls
multi_citySingle booking for all legs1 per date
separateIndividual bookings per legN per date
TypeDescription
internationalCross-border flight (requires API search)
domesticSame-country flight (requires API search)
arnkGround transport (no flight search needed)

Source: backend/app/Models/ProductByMarketFlightConfig.php, backend/app/Models/ProductByMarketFlightLeg.php

When creating an offer, the wizard uses flight config legs to determine required selections:

  1. International legs are combined into a single multi-city search
  2. Domestic legs are searched separately (each creates an OfferFlight record)
  3. 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.

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 days

Source: backend/app/Models/ProductByMarket.php:219-265

// 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 configs

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

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.

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

GET /api/supplier-tours/{id}/preview

Protected 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

/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

SupplierTourPreviewPayloadBuilder synthesizes the Product payload by:

  • Loading the tour’s ProductTemplate and using its source_locale for all content
  • Resolving stored image paths to full URLs (itinerary, hero, gallery, activities, hotels)
  • Merging per-day activities from extraActivities and substitutionActivities into each itinerary day (matching the merge behavior of ProductByMarketResource)
  • 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) and images[] (all images in admin-configured order) for parity with ProductByMarketResource::buildTierHotels()
  • Stubbing market-only fields (url_slug, country_name, celebrity, departure_airports) as null / empty — the frontend tolerates these for the preview route
  • Using a synthetic SKU PREVIEW-ST-{id} and status: 'preview'

Source: backend/app/Services/SupplierTourPreviewPayloadBuilder.php

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

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)

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 null for the celebrity key and the gallery displays normally

Source: backend/app/Filament/Resources/ProductsByMarket/Schemas/ProductByMarketForm.php:783, backend/app/Http/Resources/ProductByMarketResource.php

  • 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_margin is inherited by new offers created for this product-market combination (see Offers: Margin Resolution)