Skip to content

CMS Content API

Content management system for organizing travel destinations into regions and countries, thematic collections, celebrity/trailer pages, the About Us page, and the Legal page, managed via Filament admin and served to the frontend via API.

  • Display homepage sections (hero slides, switchable contact intro and accordion, celebrity, experience carousel, contact closing)
  • Show region detail pages with content sections (quote, experiences, CTA)
  • Show countries within a region with stats and image galleries
  • Display linked products on country/destination pages
  • Show curated thematic collection pages (e.g. “Fauna y vida salvaje”) with featured cards plus an auto-built dynamic experience list
  • Serve translated global labels for region page UI (hero buttons, newsletter)
  • Serve the About Us page: hero, press and “Qué nos diferencia” sections plus the legacy header, caption, celebrity and trailers, each section behind a show/hide switch
  • Serve the Legal page with translated structured sections (subtitle + body)
  • Serve celebrity/trailer pages with video backgrounds, posters, and CTAs
  • Serve the public-site footer (link columns, newsletter heading, copyright)
  • Manage destination content and ordering from the admin panel

Regions group countries. Countries link to products via a pivot table. Both have translations. Region labels provide global per-locale UI strings.

CmsRegion (slug, image, full_width_image, sort_order, status)
├── CmsRegionTranslation (locale, name, description, quote, experiences,
│ cta_subheading, cta_button_text)
└── CmsCountry (slug, image, hero_video, sort_order, status)
├── CmsCountryTranslation (locale, name, description, tagline,
│ population, area, currency, best_time, languages,
│ experiences JSON with product_by_market_id, video, label_color, section_title)
└── ProductByMarket (via cms_country_product_by_market pivot, with sort_order)
CmsRegionLabelTranslation (locale, hero_button_primary, hero_button_secondary,
newsletter_heading, newsletter_name_placeholder, newsletter_email_placeholder,
newsletter_button_text)
CmsAboutUsPage (video_url, status) — singleton
└── CmsAboutUsPageTranslation (locale, title, lead_caption, supporting_caption,
description, celebrity_eyebrow, celebrity_title, celebrity_description,
celebrity_cta_label, celebrity_modal_title,
featured_celebrity_page_id FK,
trailers JSON,
hero_visible, hero_title, hero_image,
press_visible, press_title, press_intro, press_items JSON,
values_visible, values_eyebrow, values_title, values_cards JSON,
header_visible, caption_visible, celebrity_visible)
└── CmsCelebrityPage (belongs-to via featured_celebrity_page_id)
CmsHomePage (hero_media_path, status) — singleton
└── CmsHomePageTranslation (locale, hero_title, hero_description, hero_slides JSON,
contact_intro_visible, contact_intro_caption, contact_intro_cta_label, contact_intro_cta_href,
accordion_visible, accordion_heading, accordion_description, accordion_items JSON,
celebrity_eyebrow, celebrity_title, celebrity_description,
celebrity_cta_label, celebrity_cta_href, celebrity_modal_title,
featured_celebrity_page_id FK,
experience_slides JSON,
contact_closing_caption, contact_closing_cta_label)
└── CmsCelebrityPage (belongs-to via featured_celebrity_page_id)
CmsCelebrityPage (market_id, slug, title, subtitle, description, tags JSON,
poster_image, background_media, trailer_youtube_url, play_cta_label,
destination_cta_label, destination_product_id FK, meta_year, meta_duration,
status, sort_order)
├── Market (belongs-to)
├── ProductByMarket (belongs-to via destination_product_id)
└── CmsCountry (many-to-many via cms_celebrity_page_cms_country — country pages that show its trailer scene)
CmsLegalPage (status) — singleton
└── CmsLegalPageTranslation (locale, title, sections JSON, seo_title, seo_description)
CmsFooter — singleton (no status column)
(social_links JSON, trust_logos JSON) — global, not localized
└── CmsFooterTranslation (locale, link_groups JSON, newsletter_heading, copyright_text,
address_text, disclaimer_text, legal_links JSON)
CmsCollection (slug, collection_color_id FK, image, sort_order, status)
├── CollectionColor (hex, label, sort_order) — seed-only palette
├── CmsCollectionTranslation (locale, label, description, title)
└── ProductTemplate (via cms_collection_product_template pivot, with sort_order)
└── ProductByMarket (per-market published rows of the template)

Key fields:

  • experiences – JSON array on translations. Each entry has editorial fields (image, title, tag, description), optional video and label_color for card styling, optional section_title for the featured position, and a product_by_market_id reference. The API resolves product IDs into trip data (title, itinerary, duration+price, href) filtered by market

  • hero_video – optional video file path on CmsCountry; when set, the country page hero plays video with image as poster

  • celebrityTrailer – the celebrity trailer scene shown between the experience grid and the trips section. Editors link a celebrity page to countries from the celebrity page form (not from the country form). The API returns the first linked CmsCelebrityPage that belongs to the request market, is active, and has a trailer_youtube_url, ordered by sort_order then id; null otherwise, in which case the frontend omits the scene. The payload uses the same field shapes as GET celebrity-pages/{slug}: background is {kind, src, alt} built from background_media (kind detected by file extension) or null, and playCta.label is the page’s play_cta_label (the frontend falls back to “Ver trailer” when empty). The selection rule is the CmsCelebrityPage forMarket(), active(), withTrailer() and ordered() scopes applied to the eager-loaded relation

  • Stat labels (Población, Superficie, etc.) are hardcoded on the frontend; i18n will be added later

  • trailers – JSON array on translations: each entry has poster_image (storage path or URL), poster_alt, href, and optional target (_self/_blank). Entries missing any required field are skipped in the API response.

Celebrity page key fields:

  • No translation table – each record is tied to one market and written in that market’s language
  • background_media – unified image/video upload; API auto-detects type from file extension (mp4/webm/ogg/mov = video, everything else = image)
  • destination_product_id – FK to products_by_market; the API builds the destination CTA href from the linked product via ProductByMarket::getFrontendPath() (the product’s localized path, e.g. /es/circuito/karibu-safari-kenia-completo)
  • tags – JSON array of free-text tag strings

Homepage key fields:

  • hero_slides – per-locale JSON array (max 6 slides) driving the multi-slide hero, built by HomeHeroSlideBuilder. Each entry has a type (celebrity, product, or generic) plus these optional shared keys as applicable: celebrity_page_id, product_by_market_id, eyebrow, title, subtitle, description, includes, price, media_path, cta_label, cta_href, duration. Override semantics: for Celebrity and Trip slides every stored text field is an override — a non-empty value wins, a blank/whitespace value is treated as absent and falls back to the value derived from the referenced entity. Generic slides have no referenced entity, so all fields are taken as-is and empty ones are simply omitted. Overridable vs derived by type:
    • Celebrity (falls back to the referenced CmsCelebrityPage): overridable = eyebrow, title, subtitle, description, media_path (→ background_media), cta_label, cta_href (→ linked destination product’s path). Not overridable: the secondary trailer play button (icon-only; its label is an aria-label, its href is the celebrity page’s trailer_youtube_url)
    • Trip / product (falls back to the referenced ProductByMarket + its offers): overridable = eyebrow, title, subtitle (→ itinerary cities), includes, price, media_path (→ productTemplate->hero_image), cta_label. Not overridable: the CTA href is locked to ProductByMarket::getFrontendPath() — selecting the product already defines the destination. A price override wins unconditionally (shown even when the product has no bookable offer)
    • Generic (fully custom): eyebrow, title, subtitle, description, includes, price, media_path, cta_label, cta_href all come straight from the config
  • hero_media_path – single image/video upload on the parent record; media type (image/video) is auto-detected from file extension (mp4/webm/mov/avi = video)
  • contact_intro_visible, accordion_visible – per-translation (per-market) show/hide switches for the contact intro and accordion sections, default false. Content is kept while hidden
  • accordion_items – JSON array of {title, content} objects
  • experience_slides – JSON array of {title, description, tag_label, cta_label, cta_href, image} objects; image paths resolved to full URLs, and a derived country_name is added to each slide (see API response below).
  • featured_celebrity_page_id – FK to cms_celebrity_pages per translation (market-specific celebrity spotlight); poster image is sourced from the linked celebrity page
  • Celebrity trailers are not stored on the homepage; they come from all active CmsCelebrityPage records for the request market

Legal page key fields:

  • sections – JSON array of {subtitle, body} objects on the translation. The API concatenates them into a single contentHtml string (<h2>subtitle</h2> + body). Subtitles are HTML-escaped; body is rendered as-is (managed via RichEditor in admin)
  • seo_title / seo_description – optional SEO overrides per translation

Footer key fields:

  • Singleton with no status column — if the one record exists it is served; otherwise the API returns { data: null }
  • link_groups – JSON array of {items: [...]} groups on the translation. Each group renders as a footer column; items carry label, href, and external (bool). When external is false the admin picks href from CmsLinkOptions::getStaticPathOptions() (canonical English paths like /about-us); when external is true the admin enters an absolute URL. Null or empty hrefs fall back to #
  • Country columns are auto-prepended – the API response always leads with up to three synthetic groups built from published CmsCountry records (sorted alphabetically by localized name with locale-aware Collator, sort_order as tiebreaker, split into 3 balanced columns; column sizes differ by at most 1 and extras go to the leftmost columns). Empty columns are omitted, so 1–2 countries collapse to fewer columns. These come from the Countries CMS, not from link_groups, so editors don’t need to mirror countries in the footer admin
  • newsletter_heading / copyright_text – plain strings; emitted as null in the API response when the admin hasn’t filled them in (frontend components treat missing values as “don’t render”)
  • social_links – global (not localized), stored on the parent CmsFooter. JSON array of {platform, href}. Supported platforms: tiktok, instagram, linkedin, youtube, pinterest. The resource pairs each entry with a hardcoded ariaLabel (the platform’s brand display name — not stored, not editable, not localized) and silently drops unsupported platforms or entries with missing/empty hrefs
  • trust_logos – global (not localized), stored on the parent CmsFooter. JSON list of identifiers from visa, mastercard, confianzaonline. The resource server-side filters to the supported enum; unknown values are silently dropped (defense-in-depth alongside the frontend mapper)
  • address_text / disclaimer_text – per-locale plain text on the translation; null when not set
  • legal_links – per-locale JSON, same shape and href semantics as link_groups[].items (internal wins over external, # fallback)

Storage rationale: Social URLs and trust logos are the same in every language, so they live on the parent. The social aria labels are platform brand names (TikTok, Instagram, LinkedIn, YouTube, Pinterest) — these don’t translate, so they’re not stored per-locale either.

Collection key fields:

  • Featured experience cards are NOT stored on the collection — they are aggregated at API read time from cms_country_translations.experiences whose tag matches the collection’s translated label for the active locale. The country experience editor is the single source of truth (see Country key fields above for the JSON shape)
  • cms_collection_product_template – pivot binding ProductTemplate (editorial tour) to a Collection. Templates tagged here drive the dynamic bottom-of-page experience list at API read time, fanned out into one row per active ProductByMarket for the request market
  • collection_color_id – FK to a seed-only CollectionColor palette row. Hex is read via $collection->color->hex; editing a palette row updates every collection that references it. Used as fallback label_color when an aggregated country experience does not specify one

Source: backend/app/Models/CmsRegion.php, backend/app/Models/CmsCountry.php, backend/app/Models/CmsRegionLabelTranslation.php, backend/app/Models/CmsAboutUsPage.php, backend/app/Models/CmsCelebrityPage.php, backend/app/Models/CmsHomePage.php, backend/app/Models/CmsHomePageTranslation.php, backend/app/Models/CmsLegalPage.php, backend/app/Models/CmsLegalPageTranslation.php, backend/app/Models/CmsFooter.php, backend/app/Models/CmsFooterTranslation.php, backend/app/Models/CmsCollection.php, backend/app/Models/CmsCollectionTranslation.php, backend/app/Models/CollectionColor.php

All endpoints live inside the /{market}/{lang}/cms/ route group and use the market middleware for locale resolution.

Source: backend/routes/api.php

All active regions with their active countries, translated to the resolved locale.

The index serves a deliberately slim payload (CmsRegionListResource): only the region summary fields plus each country’s slug and name — everything the home region strip actually renders. The editorial experiences are the reason for the split: serializing them ran one ProductByMarket::getLeadingPrice() query per product (~620 queries, 9–16 s on a live catalogue), and the home page SSR awaits this endpoint before rendering. The slim index costs ~5 queries / ~0.1 s (home /es drops from 10–11 s to ~1 s). Consumers that need quote, experiences, the CTA overrides or full country data fetch the detail endpoint below.

Response shape:

{
"data": [
{
"slug": "asia",
"name": "Asia",
"description": "Discover the wonders of Asia",
"image": "https://cdn.example.com/regions/asia.jpg",
"fullWidthImage": "https://cdn.example.com/regions/asia-wide.jpg",
"icon": "https://cdn.example.com/regions/asia-icon.png",
"countries": [
{ "slug": "sri-lanka", "name": "Sri Lanka" }
]
}
]
}

Source: backend/app/Http/Controllers/Api/CmsController.php (regions()), backend/app/Http/Resources/CmsRegionListResource.php

GET /api/{market}/{lang}/cms/regions/{slug}

Section titled “GET /api/{market}/{lang}/cms/regions/{slug}”

Single region by slug with its active countries. Returns 404 if the slug does not match an active region.

Response: The full region resource (CmsRegionResource) — every field from the slim list above, plus the editorial payload (quote, experiences), the CTA overrides (ctaSubheading, ctaButtonText) and each country serialized in full through CmsCountryResource (stats, hero video, image fields) — plus an additional top-level contactClosing block sourced from the home page singleton’s translation for the resolved locale:

{
"data": {
"slug": "asia",
"...": "(summary, quote, experiences, ctaSubheading, ctaButtonText and full country objects)",
"ctaSubheading": null,
"ctaButtonText": null,
"contactClosing": {
"caption": "Viaja diferente con Volare.",
"cta": { "label": "Hablemos" }
}
}
}

Override semantics: ctaSubheading and ctaButtonText are optional per-region overrides for the home contactClosing.caption and contactClosing.cta.label respectively. Leave them null/empty and the region page inherits from home. No href is exposed: the frontend points the button at the “Agendar cita” page itself, like the home closing CTA.

Fallback: contactClosing is null when no active CmsHomePage translation exists for the resolved locale. Only the detail endpoint includes this block; the list endpoint does not.

Source: backend/app/Http/Controllers/Api/CmsController.php (showRegion()), backend/app/Http/Resources/CmsRegionResource.php, backend/app/Http/Resources/CmsCountryResource.php

GET /api/{market}/{lang}/cms/region-labels

Section titled “GET /api/{market}/{lang}/cms/region-labels”

Global per-locale UI labels for region pages (hero buttons, newsletter section). Returns hardcoded defaults when no translation exists for the resolved locale.

Response shape:

{
"data": {
"hero_button_primary": "VER PAÍSES",
"hero_button_secondary": "A MEDIDA",
"newsletter_heading": "Subscribe to our newsletter...",
"newsletter_name_placeholder": "Name",
"newsletter_email_placeholder": "Email",
"newsletter_button_text": "enviar"
}
}

Fallback behavior: Each field falls back independently to its default value when the locale translation is missing or the field is null. See LABEL_DEFAULTS in the controller.

Source: backend/app/Http/Controllers/Api/CmsController.php (regionLabels())

GET /api/{market}/{lang}/cms/destinations-menu

Section titled “GET /api/{market}/{lang}/cms/destinations-menu”

Slim destinations payload consumed by the public site’s Navbar “Destinos” dropdown. Returns only active regions and, within each, only their active countries.

Response shape:

{
"success": true,
"data": [
{
"id": "andes-del-sur",
"label": "Andes del Sur",
"href": "/es/regions/andes-del-sur",
"imageSrc": "https://cdn.example.com/cms-regions/andes.jpg",
"imageAlt": "Andes del Sur",
"countries": [
{
"id": "argentina",
"label": "Argentina",
"href": "/es/destinations/argentina",
"eyebrow": "Patagonia y glaciares",
"imageSrc": "https://cdn.example.com/cms-countries/argentina.jpg",
"imageAlt": "Argentina"
}
]
}
]
}

Key fields:

  • href (region) – /{market}/regions/{slug}
  • href (country) – /{market}/destinations/{slug}
  • label / imageAlt / eyebrow – resolved from the translation matching the request locale; label and imageAlt fall back to the slug when no translation exists
  • Regions and countries are sorted alphabetically by the resolved localized label, case- and diacritic-insensitive (uses mb_strtolower(Str::ascii(...)) so “Ándes” sorts under A). sort_order on cms_regions and cms_countries only feeds the initial query and acts as a tiebreaker when two labels are equal – the navbar dropdown effectively ignores sort_order. Other endpoints (/cms/regions, the home-page region carousel) still surface regions in sort_order

Source: backend/app/Http/Controllers/Api/CmsController.php (destinationsMenu())

GET /api/{market}/{lang}/cms/appointment-countries

Section titled “GET /api/{market}/{lang}/cms/appointment-countries”

Destination list for the “Agendar cita” form: every active CMS country, with the cheapest current “desde” price per person among its active products in the market.

Response shape:

{
"data": [
{ "slug": "japon", "name": "Japón", "from_price": 5790, "currency": "EUR" },
{ "slug": "kenia", "name": "Kenia", "from_price": null, "currency": "EUR" }
]
}

Key fields:

  • name – translation in the request locale, falling back to the slug; the list is sorted by it, case- and diacritic-insensitive (same key as destinations-menu)
  • from_price – rounded minimum of each linked active product’s latest product_leading_price_snapshots row (written daily by products:check-leading-price, the same figure the PDP shows). null when no product is priced; a product whose latest snapshot is null has no bookable offer today and is ignored, so an older price never resurfaces
  • currency – the market’s default currency

Source: backend/app/Http/Controllers/Api/CmsController.php (appointmentCountries()), backend/app/Services/Cms/AppointmentCountryList.php

Homepage content (singleton) translated to the resolved locale. Returns null for data when no active page exists. The response carries the multi-slide hero (heroSlides) plus the contact intro, accordion, celebrity, experience carousel, and contact closing sections. Contact intro and accordion always include their content plus a visible flag; the frontend renders them only when it is true. The legacy single hero key is retained for backward compatibility while the frontend migrates to heroSlides (#2180).

Response shape:

{
"data": {
"hero": {
"title": "Patagonia Austral",
"description": "Glaciares y montañas infinitas.",
"media": {
"kind": "video",
"src": "https://cdn.example.com/cms-home/hero/bg.mp4",
"alt": ""
}
},
"heroSlides": [
{
"id": "product-42-0",
"title": "Aventura en Kenia",
"subtitle": "Nairobi · Masái Mara",
"includes": "Vuelos incluidos · Hoteles · Actividades",
"price": "8 días desde 3.400€",
"ctas": [{ "label": "Descubre el viaje", "href": "/es/circuito/aventura-kenia" }],
"media": { "kind": "image", "src": "https://cdn.example.com/hero/kenia.jpg", "alt": "Aventura en Kenia" }
}
],
"contactIntro": {
"visible": false,
"caption": "No vendemos viajes. Diseñamos experiencias.",
"cta": { "label": "Contáctanos", "href": "/es/contact" }
},
"accordion": {
"visible": false,
"heading": "Creemos en un turismo que respeta.",
"description": "Nuestros valores guían cada experiencia.",
"items": [
{ "title": "Autenticidad", "content": "Cada experiencia es genuina." }
]
},
"celebrity": {
"eyebrow": "Viajes de película",
"title": "Descubre nuestros viajes",
"description": "Experiencias con personalidades únicas.",
"poster": {
"src": "https://cdn.example.com/poster.jpg",
"alt": "Maribel Verdú"
},
"cta": { "label": "Ver más", "href": "/es/celebrity/maribel-verdu" },
"modalTitle": "Trailers",
"trailers": [
{
"id": "maribel-verdu",
"posterSrc": "https://cdn.example.com/poster.jpg",
"posterAlt": "Maribel Verdú",
"href": "/es/celebrity/maribel-verdu"
}
]
},
"experienceCarousel": {
"slides": [
{
"title": "Aventura",
"description": "Momentos únicos.",
"tag_label": "Nature",
"cta_label": "Descubrir",
"cta_href": "/es/destinations/kenya",
"image": "https://cdn.example.com/slide1.jpg",
"country_name": "Kenia"
}
]
},
"contactClosing": {
"caption": "Viaja diferente con Volare.",
"cta": { "label": "Hablemos" }
}
}
}

Key fields:

  • heroSlides – the multi-slide hero, built by HomeHeroSlideBuilder from the translation’s per-locale hero_slides config (#2180). Each config entry is a celebrity, product, or generic slide. For Celebrity and Trip slides every text field is an editorial override that falls back to the referenced entity when left blank; Generic slides are fully custom. The output matches the frontend HomeHeroSlide shape one-to-one. When no slides are configured it falls back to a single slide derived from the legacy hero fields, so the hero is never empty. See the Homepage key fields section above for the per-type override matrix, and backend/app/Services/Cms/HomeHeroSlideBuilder.php
  • hero – legacy single-hero block, kept for backward compatibility while the frontend migrates to heroSlides. hero.media.kind is "video" for mp4/webm/mov/avi extensions, "image" otherwise
  • celebrity.poster – sourced from the featured_celebrity_page_id on the translation; null when no celebrity page is linked
  • celebrity.trailers – all active CmsCelebrityPage records for the request market: id (slug), posterSrc, posterAlt (title), and href (/{market}/celebrity/{slug})
  • experienceCarousel.slides[].image – storage paths resolved to full URLs, existing URLs passed through
  • experienceCarousel.slides[].country_name – derived from cta_href when it matches /destinations/{slug}. The slug is resolved against active CmsCountry records with a translation in the request locale; null when cta_href is empty, doesn’t point to a destination, or the country has no translation for this locale. The frontend falls back to a hardcoded “Experiencia” label when null.
  • The Filament admin’s cta_href picker for experience slides is restricted to Destinations only and scoped to the translation row’s market, so editors can only pick /{market}/destinations/{slug} paths for the locale they’re currently editing. Other home-page CTA pickers (contact intro, celebrity, hero slides) keep the full multi-category, multi-market option list.
  • contactClosing.cta – only label is emitted (no href); the frontend always links the closing CTA to the “Agendar cita” page (/{market}/book-appointment, /es/agendar-cita), so the link is not editable. The frontend reuses this caption and label for the closing band on country, region (unless overridden) and trip pages. contactIntro.cta still includes href because that block is a real navigation link
  • contactIntro.visible / accordion.visible – the Arkana Show on website switches (contact_intro_visible / accordion_visible), false by default

Source: backend/app/Http/Controllers/Api/CmsController.php (home()), backend/app/Http/Resources/CmsHomePageResource.php, backend/app/Services/Cms/HomeHeroSlideBuilder.php

Footer content (singleton) for the public-site layout, translated to the resolved locale. Returns { "data": null } when no singleton record exists so the frontend can fall back to its hardcoded mock. When a record exists, the response always prepends up to three synthetic groups built from published countries, followed by whatever the admin has entered in the Footer CMS, plus the global design fields (social links, trust logos, address, disclaimer, legal links). Empty linkGroups, null newsletter heading, and null/[] design fields are all valid states. The frontend handles empty/null values gracefully.

Response shape:

{
"data": {
"linkGroups": [
{
"items": [
{ "label": "Alemania", "href": "/destinations/alemania", "external": false },
{ "label": "Argentina", "href": "/destinations/argentina", "external": false },
{ "label": "Brasil", "href": "/destinations/brasil", "external": false }
]
},
{
"items": [
{ "label": "Cuba", "href": "/destinations/cuba", "external": false },
{ "label": "España", "href": "/destinations/espana", "external": false },
{ "label": "Italia", "href": "/destinations/italia", "external": false }
]
},
{
"items": [
{ "label": "Perú", "href": "/destinations/peru", "external": false },
{ "label": "Portugal", "href": "/destinations/portugal", "external": false }
]
},
{
"items": [
{ "label": "Our mission", "href": "/about-us", "external": false },
{ "label": "Instagram", "href": "https://instagram.com/", "external": true }
]
}
],
"newsletter": { "heading": "Inspírate para tu próximo gran viaje." },
"copyrightText": "© VOLARE 2026. Todos los derechos reservados",
"addressText": "C/ Príncipe de Vergara, 43, 28001, Madrid",
"disclaimerText": "Las imágenes de esta web son ilustrativas...",
"socialLinks": [
{ "platform": "instagram", "href": "https://instagram.com/volare", "ariaLabel": "Instagram" },
{ "platform": "tiktok", "href": "https://tiktok.com/@volare", "ariaLabel": "TikTok" }
],
"trustLogos": ["confianzaonline", "mastercard", "visa"],
"legalLinks": [
{ "label": "Política de privacidad", "href": "/privacy-policy", "external": false },
{ "label": "Política de cookies", "href": "#", "external": false }
]
}
}

Key fields:

  • linkGroups[0..2] – country columns. Auto-built from published CmsCountry records with a translation for the resolved locale, sorted alphabetically by localized name using a locale-aware Collator with sort_order as tiebreaker, then split into 3 balanced columns. Column sizes differ by at most 1 and the leftmost columns receive the extras (e.g. 32 → 11/11/10, 8 → 3/3/2, 7 → 3/2/2). Empty columns are omitted, so 2 countries collapse to 1/1 and 1 country collapses to a single column. Hrefs are canonical English paths (/destinations/{slug}); the frontend’s localizedPath adds the market prefix and translates the destinations segment per locale (e.g. ES → /es/destinos/peru)
  • linkGroups[3..] (or earlier if there are fewer country columns) – CMS-driven groups, in the order configured in /admin/cms-footers
  • linkGroups[].items[].href – for internal links (external: false) this is a canonical English path (e.g. /about-us, /terms), picked from a curated dropdown. The frontend localizes it via i18n.server.ts::localizedPath at render time (e.g. /es/sobre-nosotros, respecting CMS slug overrides). For external links (external: true) it’s an absolute URL; the frontend renders it as <a target="_blank" rel="noopener noreferrer">
  • linkGroups[].items[].href – may be # when the admin added a link without picking a page / entering a URL. The frontend renders it as a non-navigating anchor
  • newsletter.heading – only the heading is CMS-editable; null when not set. Form labels (name/email placeholders, submit button) are static UI chrome and not served by this endpoint
  • copyrightText – free text; null when not set
  • addressText / disclaimerText – per-locale plain text from the translation; null when not set
  • socialLinks[] – built from the global social_links column. Server-side filtered to the supported platforms (tiktok, instagram, linkedin, youtube, pinterest); entries with unsupported platforms or missing/empty href are silently dropped. ariaLabel is hardcoded to the platform’s brand display name (TikTok, Instagram, LinkedIn, YouTube, Pinterest) — not stored, not editable, not localized
  • trustLogos[] – built from the global trust_logos column. Server-side filtered to visa, mastercard, confianzaonline; unknown identifiers are silently dropped. Order in the response mirrors the stored order
  • legalLinks[] – per-locale; same shape and href semantics as linkGroups[].items (internal wins over external, # fallback)

Fallback behavior:

  • No CmsFooter record at all -> { "data": null } (frontend uses its hardcoded mock; countries are not rendered in this case)
  • CmsFooter exists, no translation for resolved locale -> { "data": null } (same fallback; countries not rendered)
  • Record + translation exist -> country columns prepended, CMS-driven groups appended; null hrefs in CMS items coalesce to #; unset design fields come back as null (text) or [] (arrays), and the frontend renders nothing for those sections

Source: backend/app/Http/Controllers/Api/CmsController.php (footer()), backend/app/Http/Resources/CmsFooterResource.php, backend/app/Models/CmsCountry.php

GET /api/{market}/{lang}/cms/countries/{slug}

Section titled “GET /api/{market}/{lang}/cms/countries/{slug}”

Single country by slug with its parent region and editorial experiences. Each experience references a product_by_market_id directly; the matching ProductByMarket is loaded per requested market at read time.

Response shape:

{
"data": {
"slug": "sri-lanka",
"name": "Sri Lanka",
"description": "The pearl of the Indian Ocean",
"tagline": "THE PEARL OF THE INDIAN OCEAN",
"image": "https://cdn.example.com/countries/sri-lanka.jpg",
"heroVideo": null,
"population": "22 million",
"area": "65,610 km²",
"currency": "Rupee (LKR)",
"bestTime": "December - March",
"languages": "Sinhala, Tamil",
"experiences": [
{
"image": "https://cdn.example.com/experiences/tea.jpg",
"video": null,
"title": "Walk through tea fields at dawn",
"tag": "Nature & landscapes",
"labelColor": "#183a2a",
"description": "An editorial description of this experience.",
"sectionTitle": null,
"product": {
"id": 10,
"title": "Tour de Sri Lanka",
"slug": "tour-sri-lanka",
"path": "/es/circuito/tour-sri-lanka",
"durationDays": 10,
"priceFrom": "1249",
"currency": "EUR",
"image": "https://cdn.example.com/images/hero.jpg",
"itinerary": "Colombo, Kandy, Ella y Galle",
"countries": ["Sri Lanka"]
}
}
],
"region": {
"slug": "asia",
"name": "Asia"
},
"relatedCountries": [
{
"slug": "india",
"name": "India",
"tagline": "A SUBCONTINENT OF WONDERS",
"image": "https://cdn.example.com/countries/india.jpg",
"region": "Asia"
}
],
"celebrityTrailer": {
"slug": "lara-alvarez-sri-lanka",
"title": "El Reino Esmeralda",
"subtitle": "Lara Álvarez · Aventura",
"description": "An editorial description of the celebrity trip.",
"background": {
"kind": "video",
"src": "https://cdn.example.com/cms-celebrity-pages/teaser.mp4",
"alt": "El Reino Esmeralda"
},
"trailer": {
"youtubeUrl": "https://youtu.be/VIDEO_ID",
"title": "El Reino Esmeralda"
},
"playCta": {
"label": "VER VÍDEO"
}
}
}
}

Key fields:

  • experiences – editorial content per country, stored as JSON on translations. Each entry has editorial fields (image, title, tag, description), optional video (card plays video with image as poster), labelColor (hex tag background), sectionTitle (only rendered on the featured/3rd position), plus a product_by_market_id reference. The API resolves this into a product object (null if no matching active product for the current market)
  • heroVideo – optional video URL. When set, the country page hero plays the video with image as poster. The region page country carousel stage also plays the video with image as poster; thumbnails still show the static image
  • product.itinerary – the localized PBM translation subtitle (editorial, market/locale-specific). Falls back to the product template cities joined with · when no subtitle is set
  • product.countries – every country the product is linked to via the cms_country_product_by_market pivot, ordered by sort_order, with names localized to the request locale. The frontend renders one tag per entry on trip cards (country page and “Ver todos los viajes” modal). Single-country products return a one-element list
  • Stat labels (Población, Superficie, etc.) are hardcoded on the frontend; i18n will be added later

Source: backend/app/Http/Controllers/Api/CmsController.php (showCountry())

Slim list of every active CMS collection, intended for navbar menus and listing pages. Each entry returns enough to render a label + color swatch + thumbnail.

Response shape:

{
"data": [
{
"slug": "fauna-y-vida-salvaje",
"label": "Fauna y vida salvaje",
"color": "#183a2a",
"image": "https://cdn.example.com/cms-collections/jaguar.jpg"
}
]
}

Key fields:

  • label – translated label for the resolved locale; falls back to slug when no translation exists for the locale
  • color – hex from the linked CollectionColor palette row
  • image – hero image storage path resolved to a full URL; null when the collection has no image

Source: backend/app/Http/Controllers/Api/CmsController.php (collections())

GET /api/{market}/{lang}/cms/collections/{slug}

Section titled “GET /api/{market}/{lang}/cms/collections/{slug}”

Single collection by slug with full detail for the collection page. Returns 404 if the slug does not match an active collection. Eager-loads tagged ProductTemplates and their per-market ProductByMarket rows (filtered to the request market and STATUS_ACTIVE) along with PBM countries and regions.

Response shape:

{
"data": {
"slug": "gastronomia-y-enologia",
"collection": {
"type": { "label": "Gastronomía y enología", "color": "#7a3b2e" },
"title": "GASTRONOMÍA Y ENOLOGÍA",
"description": "Sabores que cuentan historias...",
"image": "https://cdn.example.com/cms-collections/hero.jpg"
},
"featuredExperiences": [
{
"id": 42,
"image": "https://cdn.example.com/cms-countries/experiences/lima-ceviche.jpg",
"video": null,
"title": "Lima",
"description": "Probar el ceviche más auténtico",
"section_title": null,
"label": "Gastronomía y enología",
"label_color": "#7a3b2e",
"country": "Perú",
"country_code": "PE",
"country_slug": "peru",
"region_name": "Andes",
"href": "/es/circuito/tour-peru"
}
],
"experiencesFinalSection": {
"rows": [
{
"experienceName": "Kausay",
"regionName": "Andes",
"countryName": "Perú",
"countrySlug": "peru",
"previewImage": {
"src": "https://cdn.example.com/product-templates/kausay-hero.jpg",
"alt": "Kausay"
},
"href": "/es/circuito/kausay-andes-peru"
}
]
}
}
}

Key fields:

  • featuredExperiences[] – aggregated from country experiences. For every active CmsCountry (ordered by sort_order ASC, id ASC for ties), the resolver iterates that country’s experiences JSON for the active locale and keeps entries whose tag === collection.label for that locale. JSON index order is preserved within a country. Each kept entry’s product_by_market_id is batch-loaded scoped to the request market with status=active; cards whose linked PBM is missing/inactive/wrong-market are silently skipped. The aggregation lives in App\Services\Cms\CollectionFeaturedExperiencesAggregator
  • Card fields: image and video (storage paths resolved to URLs; external http(s):// URLs are passed through), title (place name), description (descriptive heading), section_title (only used when this card lands in the featured/full-width position on the frontend), label (collection label), label_color (per-experience override or fallback to the collection’s palette color), country/country_code/country_slug/region_name (taken from the source country, not from the linked PBM’s first attached country), href from ProductByMarket::getFrontendPath()
  • experiencesFinalSection.rows[] – auto-built dynamic list. One row per active ProductByMarket reachable through cms_collection_product_template -> ProductTemplate -> productsByMarket for the request market. No editor configuration. Rows where the PBM has no translations at all are skipped
  • previewImage.src – sourced from ProductTemplate.hero_image (resolved via Storage::url())
  • regionName / countryName / countrySlug – on dynamic rows, resolved from the PBM’s first attached CmsCountry and its parent region, with translations matched to the request locale (falls back to the first available translation when the locale is missing)

Source: backend/app/Http/Controllers/Api/CmsController.php (showCollection()), backend/app/Http/Resources/CmsCollectionDetailResource.php, backend/app/Services/Cms/CollectionFeaturedExperiencesAggregator.php

The live Last Seats (“Últimas plazas”) section for the market: the cover image, the headline discount, the intro copy, the countdown end and the cards that are showing right now, in the admin’s order.

Query parameters:

  • preview – optional encrypted token produced by the Arkana Preview action. It carries an unsaved configuration and deliberately bypasses the section’s active switch and schedule, so a campaign can be inspected before publishing. Valid for 30 minutes. An invalid, tampered or expired token is treated as no preview at all (the live section is returned), never as an error. Seats already sold still count in a preview.

Response shape:

{
"data": {
"coverImage": "https://cdn.example.com/last-seats/cover.jpg",
"maxDiscount": 40,
"introParagraphs": [
"En VOLĀRE eliminamos intermediarios…",
"Si estabas esperando una señal para decidir cuándo hacer ese viaje, es esta:"
],
"countdownEndsAt": "2026-10-31T22:59:00+00:00",
"cards": [
{
"title": "Namaste",
"country": "India",
"subtitle": "Delhi, Agra y Jaipur",
"days": 8,
"fromPrice": 2490,
"comparisonPrice": 3900,
"discountPercent": 36,
"image": "https://cdn.example.com/last-seats/cards/namaste.jpg",
"path": "/es/circuito/namaste"
}
]
}
}

data: null semantics. The endpoint returns {"data": null} whenever the section is not live, which covers four cases:

  1. No section configured for the market
  2. The section’s is_active switch is off
  3. Now is outside the section’s scheduled start/end window
  4. Every card is hidden — all of a product’s ticked departures are sold out, or its “from” price is not at least 1% below its comparison price

All public surfaces key off that same null: the navbar link, the home band, the page (which redirects to /{market}/404) and the sitemap entry. The frontend fetcher also maps a failed request and an empty cards[] to null, so a backend blip hides the section instead of breaking the page it sits on.

Key fields:

  • maxDiscount – hand-typed headline percentage driving the navbar label (Últimas plazas -40%) and the “hasta X% de descuento” line; null hides both
  • introParagraphs – the copy above the cards, one entry per paragraph. Written in Arkana with a blank line between paragraphs (line breaks inside a paragraph collapse to a space); a section with no copy of its own sends the default copy the page shipped with (LastSeatsSection::DEFAULT_INTRO_TEXT)
  • countdownEndsAt – ISO 8601 instant the page’s “Las ofertas terminan en” countdown runs to: the section’s ends_at. null unless the Show countdown switch is on and the schedule has an end date. Under preview the admin converts the unsaved end date from the operator’s timezone to UTC before encoding the token, since the picker itself only converts on save
  • fromPrice – cheapest per-person price among the product’s campaign departures that still have seats, 2A room, any departure airport. On the live path it is read from the persisted offers.marketing_price_per_pax, so the page cannot advertise a price checkout would not honour; under preview it is re-quoted from supplier rates at the percentage being edited, since the unsaved campaign has not reached the offers yet
  • comparisonPrice – hand-typed reference price shown struck through
  • discountPercent – derived from the two prices above (floored), never stored
  • image – the card photo override, falling back to the product template’s hero image
  • path – ProductByMarket::getFrontendPath()

Source: backend/app/Http/Controllers/Api/CmsController.php (lastSeats()), backend/app/Services/LastSeats/LastSeatsSectionQuery.php

About Us page content (singleton) translated to the resolved locale. Returns null for data when no active page exists.

Every section carries a visible flag (the Arkana Show on website switch), except trailersSection, which only feeds the celebrity block. The redesign sections (heroSection, pressSection, valuesSection) default to shown; the legacy ones (headerSection, captionSection, celebritySection) also default to shown so markets without the new content keep the old page. A data migration fills the redesign content for es_ES and switches its legacy sections off. valuesSection also feeds the home “El viaje que siempre imaginaste” band.

Response shape:

{
"data": {
"heroSection": {
"visible": true,
"title": "El lujo de viajar libremente",
"image": "/about-us/hero-porthole.jpg"
},
"pressSection": {
"visible": true,
"title": "VOLĀRE en prensa",
"intro": "Descubre lo que dicen de nosotros...",
"items": [
{
"outlet": "Forbes",
"logo": "/about-us/press-forbes.png",
"headline": "Redefinir el viaje de lujo...",
"href": "https://forbes.es/brandvoice/952050/redefinir-el-viaje-de-lujo/"
}
]
},
"valuesSection": {
"visible": true,
"eyebrow": "Qué nos diferencia",
"title": "El viaje que siempre imaginaste",
"cards": [
{
"title": "Guía privado",
"paragraphs": ["Aunque viajéis solo dos...", "Sin grupos, sin prisas..."],
"image": "/home/private-guide/own-pace.jpg"
}
]
},
"headerSection": {
"visible": false,
"title": "Viajar es salir de nuestra vida...",
"leadCaption": "Ser un explorador en Kenia...",
"supportingCaption": "Hay quien dice que viajar...",
"media": {
"kind": "youtube",
"src": "https://www.youtube.com/watch?v=abc123",
"title": "VOLARE About Us"
}
},
"captionSection": {
"visible": false,
"text": "En Volāre creemos que el viaje..."
},
"celebritySection": {
"visible": false,
"eyebrow": "Embajadores",
"title": "Quienes ya vuelan con nosotros",
"description": "Descubre los viajes de quienes...",
"ctaLabel": "Ver tráiler",
"modalTitle": "Tráiler",
"poster": {
"src": "https://cdn.example.com/cms-celebrity-pages/posters/maribel.jpg",
"alt": "Maribel Verdú"
}
},
"trailersSection": {
"items": [
{
"id": "trailer-0",
"href": "https://www.youtube.com/watch?v=trail1",
"posterSrc": "https://cdn.example.com/cms-about-us/trailers/poster.jpg",
"posterAlt": "Patagonia trailer",
"ariaLabel": "Patagonia trailer",
"target": "_blank"
}
]
}
}
}

Key fields:

  • media – null when video_url is not set on the page record
  • celebritySection.poster – sourced from the featured_celebrity_page_id on the translation; null only when no celebrity is selected (the frontend then falls back to the first celebrity by sort_order). When a celebrity is selected the poster is always returned even if it has no image (src may be null) — the explicit selection is honored and adding the image is the admin’s responsibility
  • trailersSection.items – trailer entries with missing href, poster_image, or poster_alt are silently skipped
  • Storage paths in poster_image, hero_image, press logos and card images are converted to full URLs; absolute URLs and site-relative paths starting with / (assets served from the website’s public/ folder, used by the seeded es_ES content) are passed through unchanged
  • pressSection.items – entries without a headline or logo are skipped; href may be null (the card is then not a link)
  • valuesSection.cards – cards without a title are skipped; paragraphs is the card text split on blank lines
  • With no translation for the locale, the redesign sections come back visible: false with empty content and the legacy ones visible: true

Source: backend/app/Http/Controllers/Api/CmsController.php (aboutUs()), backend/app/Http/Resources/CmsAboutUsPageResource.php

Localized URL slugs and titles for CMS pages in the resolved locale. Consumed by the frontend middleware (to rewrite/localize URLs) and the navbar (to label navigation entries).

Response shape:

{
"data": {
"about-us": { "slug": "quienes-somos", "title": "Quiénes somos" }
}
}

Each entry’s slug/title are null when the CMS has no override for the current locale (or the page is inactive).

Source: backend/app/Http/Controllers/Api/CmsController.php (slugs())

GET /api/{market}/{lang}/cms/celebrity-pages

Section titled “GET /api/{market}/{lang}/cms/celebrity-pages”

All active celebrity pages for the given market, ordered by sort_order. Slim payload intended for navigation.

Response shape:

{
"data": [
{
"slug": "memorias-de-africa",
"title": "Memorias de África",
"posterImage": "https://cdn.example.com/cms-celebrity-pages/poster.jpg",
"sortOrder": 0
}
]
}

Source: backend/app/Http/Controllers/Api/CmsController.php (celebrityPages()), backend/app/Http/Resources/CmsCelebrityPageListResource.php

GET /api/{market}/{lang}/cms/celebrity-pages/{slug}

Section titled “GET /api/{market}/{lang}/cms/celebrity-pages/{slug}”

Single celebrity page by slug. Returns 404 if slug does not match an active page in the requested market. Eager-loads the destinationProduct relationship (plus its market, translation, and productTemplate) to build the destination CTA href.

Response shape:

{
"data": {
"slug": "memorias-de-africa",
"background": {
"kind": "video",
"src": "https://cdn.example.com/cms-celebrity-pages/bg.mp4"
},
"poster": {
"src": "https://cdn.example.com/cms-celebrity-pages/poster.jpg",
"alt": "Memorias de África"
},
"title": "Memorias de África",
"subtitle": "Un viaje al corazón de Kenia",
"meta": {
"year": "2026",
"duration": "00:30min",
"tags": ["Aventura", "Safari"]
},
"description": "Descubre la magia de África...",
"playCta": {
"label": "Reproducir"
},
"destinationCta": {
"label": "Descubrir destino",
"href": "/es/circuito/karibu-safari-kenia-completo"
},
"trailer": {
"youtubeUrl": "https://www.youtube.com/watch?v=abc123",
"title": "Memorias de África"
}
}
}

Key fields:

  • background.kind – "video" for mp4/webm/ogg/mov extensions, "image" otherwise. Image backgrounds include an alt field
  • destinationCta.href – the linked product’s frontend path via ProductByMarket::getFrontendPath() (e.g. /es/circuito/karibu-safari-kenia-completo); null when no product is linked
  • trailer.youtubeUrl – null when not set

Source: backend/app/Http/Controllers/Api/CmsController.php (showCelebrityPage()), backend/app/Http/Resources/CmsCelebrityPageResource.php

Legal page content translated to the resolved locale, selected by type (privacy, cookies, or terms — enforced by a route whereIn constraint). Returns null for data when no active page of that type exists.

Response shape:

{
"data": {
"title": "Aviso Legal",
"contentHtml": "<h2>1. Datos Identificativos</h2><p>Información legal.</p><h2>2. Objeto</h2><p>Finalidad del sitio.</p>",
"seo": {
"title": "Aviso Legal | Volare",
"description": "Información legal de Volare."
}
}
}

Key fields:

  • contentHtml – built server-side by concatenating sections array entries. Each entry’s subtitle is wrapped in <h2> (HTML-escaped), followed by the body (raw HTML from RichEditor). Returns null when sections are empty or translation is missing
  • seo.title / seo.description – optional SEO overrides; null when not set

Source: backend/app/Http/Controllers/Api/CmsController.php (legalPage()), backend/app/Http/Resources/CmsLegalPageResource.php

Nine Filament admin interfaces under the CMS navigation group. All edit pages (except Region Labels) have a Preview on Frontend header action that opens the corresponding frontend page in a new tab using config('app.frontend_url'). Singleton pages default to the ES market; Celebrity Pages use the record’s market; Regions and Countries use ES with the record slug. Any new CMS resource should follow this pattern and include a Preview on Frontend action on its edit page.

Singleton CMS page for homepage content. Vertical tabs: General (hero media upload accepting images and video, active toggle) and Translations. Each translation uses nested tabs: Hero (locale, title, description), Contact Intro (Show on website switch, caption, CTA label + link), Accordion (Show on website switch, heading, description, items repeater with title + content), Celebrity (eyebrow, title, description, CTA, modal title, featured celebrity page select), Experience Carousel (slides repeater with title, description, tag label, destination CTA, image upload — eyebrow derived from the destination, not manually entered; the CTA picker is restricted to destinations scoped to the translation row’s market), and Contact Closing (caption + CTA label only — the closing button opens a drawer, so no link picker is exposed).

Source: backend/app/Filament/Resources/CmsHomePages/Schemas/CmsHomePageForm.php

CRUD for regions with vertical tabs (General + Translations). General tab has slug, hero image, full-width image uploads (public disk), and active toggle. Translations tab uses nested tabs per translation: Content (name, description, quote), Call to Action, and Experiences. The Call to Action tab has just two fields — CTA Button Text (cta_button_text) and CTA Subheading (cta_subheading) — both with helper text marking them as optional overrides of the home page’s Contact Closing label and caption. Leave them blank and the region inherits from home. The Experiences tab has an Intro section (eyebrow, title, description, background image) and a Phases repeater where each phase has experience name, caption, title, phase image, and a searchable multi-select for related ProductByMarket products.

Source: backend/app/Filament/Resources/CmsRegions/Schemas/CmsRegionForm.php

CRUD for countries with vertical tabs (General + Translations). General tab has region select, slug, hero section (image + optional video for the country page hero), sort order, and active toggle. Translations tab has Content (badge: “Region page” — name, description, tagline, country stats) and Experiences (badge: “Country page”) tabs. The Experiences tab has a reorderable repeater for editorial experiences, each with optional section title (only rendered on the featured/3rd position), image, optional video, place name, tag label, tag color (select from 3 predefined colors), description, and a product select filtered to ProductByMarket records attached via the Products by Market relation manager. The frontend groups experiences by position: 1-2 double, 3 featured, 4-5 double.

Source: backend/app/Filament/Resources/CmsCountries/Schemas/CmsCountryForm.php

CRUD for thematic collections with vertical tabs (General + Translations). General tab has slug (auto-generated from label when empty), color select fed from the CollectionColor palette (rendered as inline-SVG swatches via CollectionColor::swatchHtml()), hero image upload (cms-collections/), sort order, and active toggle. Translations tab uses a per-locale repeater; each translation row has locale, label (navbar/badge text), hero title, and description — no per-collection card editor.

The featured experience cards rendered above the dynamic list are NOT configured here. They are aggregated at API read time from country experiences whose tag matches this collection’s translated label, so editors curate them on the Country admin (/admin/cms-countries → Translations → Experiences), where each experience picks its target collection via the Category (tag) select.

Editors do not configure the bottom-of-page experience list either — it is auto-built at API read time from every active ProductByMarket reachable through the cms_collection_product_template pivot. To add a tour to that list, tag the underlying ProductTemplate with the collection on the Product Template form.

Source: backend/app/Filament/Resources/CmsCollections/Schemas/CmsCollectionForm.php, backend/app/Filament/Resources/CmsCollections/CmsCollectionResource.php

Singleton CMS page for the About Us content. Only one record can exist (create button hidden when a record exists). Vertical tabs: General (hero cover image upload, optional hero video upload, active toggle) and Translations. Each translation has nested tabs: Header (Show on website switch for the legacy header, locale, page title, slug, title, lead caption, supporting caption), Hero (switch, title, image), Press (switch, title, intro, reorderable repeater of outlet + logo + headline + link), What sets us apart (switch, eyebrow, title, reorderable repeater of title + text + image; shared with the home band), Content (switch, description), Celebrity Section (switch, eyebrow, title, description, CTA label, modal title, and a Featured Celebrity Page select that picks which celebrity trailer is shown as the About Us hero — options are labelled "{title} ({market.code})" e.g. “Maribel Verdú (ES)”, searchable by title; leave empty to fall back to the first celebrity by sort order), and Trailers (repeater with poster image upload, alt text, link URL, link target).

Source: backend/app/Filament/Resources/CmsAboutUsPages/Schemas/CmsAboutUsPageForm.php

Celebrity Pages (/admin/cms-celebrity-pages)

Section titled “Celebrity Pages (/admin/cms-celebrity-pages)”

CRUD for celebrity/trailer pages with vertical tabs (General, Media, Content, CTAs). General tab has market select, a “Show trailer on country pages” multi-select (countries relation; countries labelled by their es_ES name, inactive ones stay selectable and are suffixed “(inactive)”), slug, active toggle, and sort order. Media tab has poster image upload, background media upload (accepts images and video: mp4/webm/ogg), and YouTube trailer URL. Content tab has title, subtitle, description, tags input, year, and duration. CTAs tab has play CTA label, destination CTA label, a destination product select (destination_product_id FK to products_by_market) — searchable by translated title or SKU and scoped to the page’s selected market.

Source: backend/app/Filament/Resources/CmsCelebrityPages/Schemas/CmsCelebrityPageForm.php

Region Labels (/admin/cms-region-labels-settings)

Section titled “Region Labels (/admin/cms-region-labels-settings)”

Settings page for managing global per-locale region page labels. Uses vertical tabs (Hero Buttons, Newsletter) with repeaters for adding translations per locale. Each locale row is upserted on save; removed locales are deleted.

Source: backend/app/Filament/Pages/Settings/CmsRegionLabelsSettings.php

Singleton CMS page for legal content. Only one record can exist (create button hidden when a record exists). Vertical tabs: General (active toggle) and Translations. Each translation has locale select, page title, a nested sections repeater (subtitle + RichEditor body), SEO title, and SEO description.

Source: backend/app/Filament/Resources/CmsLegalPages/Schemas/CmsLegalPageForm.php

Singleton resource for the public-site footer. Only one record can exist (canCreate() returns false once it does). The form has two parts:

  • Global section (top of the form, above translations): repeater for social_links — each row picks a platform from a Select (tiktok, instagram, linkedin, youtube, pinterest) and enters a profile URL; same platform should appear only once. CheckboxList for trust_logos with the supported identifiers (visa, mastercard, confianzaonline). Both fields are global because URLs and logos don’t vary per locale.
  • Translations repeater (per-locale): locale select (options from CmsRegionForm::getLocaleOptions()), nested link_groups repeater (one row per footer column, each with its own items repeater), four simple text fields arranged in a 2-column Grid for compactness (newsletter_heading, copyright_text, address_text, disclaimer_text — the last one a Textarea), and a legal_links repeater using the same item schema as link-group items.

Every link item (in link_groups[].items or legal_links) has a label and two independent fields — a Page select (internal) and an External URL text input. Both fields are always visible so the admin can set either one; no toggle is needed. The resource infers external from which field was populated (internal takes precedence if both are set).

The internal href Select is fed by CmsLinkOptions::getStaticPathOptions() — a hardcoded list of canonical static public paths (/about-us, /terms, /privacy-policy, /legal) with no database queries. To add a new static page, edit CmsLinkOptions::getStaticPaths() and add a matching SEGMENT_TRANSLATIONS entry in frontend/src/shared/config/i18n.ts so the frontend can localize it.

Source: backend/app/Filament/Resources/CmsFooters/Schemas/CmsFooterForm.php, backend/app/Filament/Concerns/CmsLinkOptions.php

Page Route Data Source
Home /[market]/home GET cms/home + GET cms/regions (parallel) – hero slides, contact intro and accordion (when switched on), regions, contact closing, celebrity, experience carousel
Region /[market]/regions/[slug] GET cms/regions/{slug}
Country /[market]/destinations/[country] GET cms/countries/{slug} – hero (image/video), experience grid (2-1-2 layout from flat list), celebrity trailer scene (CelebrityTrailerScene, only when celebrityTrailer is set), trips from linked products; GET cms/home for the closing contact band
Collection /[market]/collections/[slug] (translated /es/colecciones/[slug]) GET cms/collections/{slug} – featured cards above a dynamic experience list with Region/Country filters
About Us /[market]/about-us GET cms/about-us – hero, press, “Qué nos diferencia”, then the legacy header, caption and celebrity blocks, each when switched on (legacy blocks use hardcoded fallback content)
Celebrity /[market]/celebrity/[slug] GET cms/celebrity-pages/{slug} – hero with video/image background, poster, trailer modal, CTAs
Legal /[market]/legal GET cms/legal – title, structured sections as HTML, SEO metadata
Footer (public) PublicLayout.astro GET cms/footer – link columns, newsletter heading, copyright, social links, trust logos, address, disclaimer, legal links; falls back to hardcoded mock on failure
Footer (checkout/confirmation) 7 checkout pages + confirmation GET cms/footer – full FooterLinksModuleProps (link groups, copyright, social links, trust logos, address, disclaimer, legal links — no newsletter in this variant); falls back to hardcoded list on failure
Navbar “Destinos” dropdown PublicLayout.astro GET cms/destinations-menu – regions + nested countries, alphabetically sorted by localized label
Agendar cita /[market]/book-appointment (translated /es/agendar-cita) GET cms/appointment-countries – destination picker with “desde” prices; empty list on failure (see Leads API)
Últimas plazas /[market]/last-seats (translated /es/ultimas-plazas) GET cms/last-seats – hero, optional live countdown (countdownEndsAt, ticks every second and hides at zero), intro copy (introParagraphs), card grid; redirects to /{market}/404 on data: null
Últimas plazas navbar link + home band shared/ui/Navbar.astro, [market]/home.astro GET cms/last-seats – same payload; both omitted on data: null

The homepage fetches CMS home and regions data in parallel via Promise.allSettled. CMS data is mapped to component props via cmsHomeMapper.ts – each section (hero slides, contact intro, accordion, celebrity, experience carousel, contact closing) is independently optional; missing data results in the section being omitted, and contact intro and accordion are also omitted while their visible flag is off. Regions are fetched from the separate /cms/regions endpoint and rendered as the RegionInfoSection component. Render order: hero slider, PrivateGuideSection (the About Us valuesSection title and cards, fetched from /cms/about-us in parallel; omitted while switched off or empty), contact intro (when switched on), regions, accordion (when switched on), Últimas plazas band (when live), contact closing, celebrity, experience carousel. The home carousel slide uses the region’s image (Hero Image) as the background, falling back to fullWidthImage only when image is null; the region title links to /{market}/regions/{slug} via localizedPath (the API response still returns both image fields).

The region page fetches the region detail. The experiences response is mapped to HorizontalScrollSection props (intro + phases with related trips). The closing CTA, the last section of the page, renders the same HomeContactClosing band as the home page (its button links to the “Agendar cita” page); the caption and button label inherit from the home page’s contactClosing block but can be overridden per region via the ctaSubheading and ctaButtonText API fields. Country stat labels (Población, Superficie, Moneda, etc.) are hardcoded in the frontend template; i18n will be added later.

The country page and the trip (product) page also end with the HomeContactClosing band. They load its copy through frontend/src/features/landing/data/fetchContactClosing.ts, which reads the home contactClosing caption and label (no per-page overrides), sharing the home page’s SSR cache key. If the home request fails or its closing caption or label is empty the band is omitted.

The About Us page maps the CMS payload with frontend/src/data/about-us/about-us-page.data.ts: the redesign sections (AboutUsHero, AboutUsPress, AboutUsValues) render only when switched on and non-empty, the legacy header/media, caption and celebrity blocks when their switch is on (missing flag = on). The legacy blocks merge CMS data with hardcoded fallback content; if the API fails or returns null, only the fallback legacy page renders. The breadcrumb only shows when there is no redesign hero. The navbar builds the About Us link with the CMS-aware i18n.server.ts::localizedPath, so it points at the admin-managed slug from /cms/slugs when one exists.

The celebrity page fetches a single celebrity page by slug from the CMS API. The API response is mapped to HeroCelebrityProps (the image/video background through mapCelebrityBackground, shared with the country trailer scene) for the HeroCelebrity component, which renders a full-viewport hero with video or image background, a movie-style poster, metadata (year, duration, tags), trailer modal (YouTube, the shared CelebrityTrailerModal component, also used by the country page’s CelebrityTrailerScene in its cinema variant), and a destination CTA linking directly to the showcased product page. SEO title is auto-generated as ${title} | Volare. If the API returns 404 or fails, the user is redirected to the market’s 404 page.

The legal page fetches CMS data from GET /{market}/{lang}/cms/legal. If the API returns no data (no active page or missing translation), the user is redirected to the market home page. The LegalPage component renders the pre-built contentHtml directly. SEO title falls back to ${title} | Volare when no explicit seo_title is set.

The collection page fetches a single collection by slug. featuredExperiences[] are chunked by cmsCollectionMapper.ts into the existing 1-featured + double-double rhythm and reuse the ExperienceGrid / ExperienceCard components — each card’s overline shows the experience title (place name) and the heading shows the experience description, matching the cards rendered on the country page. When an entry carries a video, the card plays it with the image as poster. The featured (full-width) section title is hardcoded as MEJORES ESCENAS in the mapper. The dynamic experiencesFinalSection.rows[] is rendered as a filterable list with Filtrar por Region/País filters; each row’s “Vivir experiencia” CTA navigates directly to the row’s href. The intro paragraph above the dynamic list is hardcoded in the mapper (FINAL_SECTION_INTRO_DESCRIPTION, currently Spanish-only); other locales fall back to Spanish until product/marketing supplies translations.

The footer is consumed in two places. Public pages use PublicLayout.astro, which fetches the footer and maps it to component props; on any failure it falls back to the hardcoded PUBLIC_LAYOUT_FOOTER mock. Checkout and confirmation pages use fetchCheckoutFooter() — previously a slim helper that returned only link groups + copyright, it now returns the full FooterLinksModuleProps object (all 7 fields: link groups, copyright, social links, trust logos, address, disclaimer, legal links). The configurator footer therefore renders the same bottom block (social icons, trust logos, address, disclaimer, legal links) as the public footer; only the newsletter is swapped for a checkout contact module. The wiring was simplified to forward a single links: FooterLinksModuleProps prop across the 7 checkout pages, the confirmation page, and the React confirmation component instead of duplicating individual fields per page. Link groups fall back to frontend/src/data/footerLinks.ts on failure; copyright falls back to the component’s own hardcoded default. Newsletter form labels (name/email placeholders, submit button) are static UI chrome, not served by the API.

Source: frontend/src/pages/[market]/home.astro, frontend/src/pages/[market]/regions/[slug].astro, frontend/src/pages/[market]/about-us.astro, frontend/src/pages/[market]/celebrity/[slug].astro, frontend/src/pages/[market]/legal.astro, frontend/src/pages/[market]/collections/[slug].astro, frontend/src/features/landing/data/cmsCollectionMapper.ts, frontend/src/layouts/PublicLayout.astro

  • Only active (status = true) regions and countries appear in API responses
  • The regions index is deliberately slim (CmsRegionListResource): region summary fields plus each country’s slug and name. Hydrating country experiences costs one leading-price query per product and the home page SSR blocks on this call, so anything heavier belongs on the detail endpoint — adding a field back to the index means accepting its query cost on every home render
  • Country products are filtered by the request market and must be active
  • Sort order controls display position in both admin and API
  • Navbar “Destinos” dropdown sorts regions and countries alphabetically by localized name (case- and diacritic-insensitive); sort_order is the tiebreaker only
  • Translations are matched by the locale resolved from {market}/{lang}
  • A country always belongs to exactly one region
  • Region label fields fall back individually to hardcoded defaults when missing
  • Image storage paths are converted to full URLs via Storage::url() (default disk)
  • Celebrity pages have no translation table; each record belongs to one market
  • Celebrity page background media type is auto-detected from file extension (mp4/webm/ogg/mov = video)
  • Celebrity page destination CTA links to a ProductByMarket via FK, not a free-text URL; the href is the product’s frontend path
  • A country page shows a celebrity trailer scene only when a celebrity page of the same market is linked to it, active, and has a trailer; when several qualify, the lowest sort_order wins (then lowest id). Nothing is derived automatically from the celebrity’s destination product
  • Homepage is a singleton (one active record); returns null when missing or inactive
  • Homepage hero media type is auto-detected from file extension (mp4/webm/mov/avi = video)
  • Featured celebrity page is per-translation (market-specific), not global on the homepage record
  • Homepage celebrity trailers come from active CmsCelebrityPage records for the request market, not stored on the homepage itself
  • The About Us hero poster can be explicitly chosen per translation via featured_celebrity_page_id (market-specific, mirrors the homepage pattern); when unset the frontend falls back to the first celebrity by sort_order (the original implicit behavior)
  • Homepage experience slide images are stored as paths and resolved to full URLs in the API response
  • Legal page is a singleton (one active record); returns null when missing or inactive
  • Legal page sections are concatenated into contentHtml server-side; subtitles are HTML-escaped, body is raw HTML
  • Legal page footer link points to /{market}/legal (hardcoded to /es/legal in current footer)
  • Footer is a singleton with no status column; the record either exists (served) or not ({data: null}, frontend mock used)
  • Footer’s first two linkGroups are always the published countries, prepended server-side from CmsCountry records. CMS editors do not (and cannot) configure these in /admin/cms-footers — they sync automatically when countries are added/removed/renamed
  • Country column sort is locale-aware (PHP Collator) so accented names like Perú or España order correctly per market. sort_order on CmsCountry is used only as a tiebreaker when localized names are equal
  • The hardcoded fallback mock (PUBLIC_LAYOUT_FOOTER in frontend/src/layouts/layout.mock.ts) does not include country columns. When the API returns {data: null} (missing record or untranslated), the user sees the mock without countries
  • Footer internal link href values are picked from a hardcoded static-paths list (/about-us, /terms, /privacy-policy, /legal) — no DB queries, no dynamic pages. Adding a new static page requires editing CmsLinkOptions::getStaticPaths() and frontend/src/shared/config/i18n.ts::SEGMENT_TRANSLATIONS
  • Footer internal hrefs are stored as canonical English paths; the frontend localizes them at render (e.g. /about-us -> /es/sobre-nosotros), respecting CMS slug overrides from /cms/slugs
  • Footer items with null/empty hrefs resolve to # so the frontend always renders a valid anchor
  • Footer newsletter form labels are NOT CMS-editable — only the heading is. Labels are static UI chrome
  • Collection featuredExperiences[] are aggregated from country experiences (per-locale: each country translation contributes only entries matching the collection’s translated label); the dynamic experiencesFinalSection.rows[] is per-market (one row per active PBM reachable via the pivot for the request market)
  • Aggregation order is country sort_order ASC (ties broken by cms_country.id), then within a country preserve the JSON index of the experiences array
  • Featured experience cards whose linked ProductByMarket is missing, inactive, or in a different market are silently skipped — they never reach the API response
  • The featured-card source country (country/country_code/country_slug/region_name) is the country whose translation contains the experience entry, NOT the linked PBM’s first attached country (the two can diverge when an editor links a cross-country product)
  • Collection palette colors come from a seed-only CollectionColor table; editing a palette row’s hex updates every collection that references it. The palette color also serves as the fallback label_color for aggregated cards whose source experience does not carry a per-card override
  • Collection slugs auto-generate from the label only when the admin leaves the slug field empty
  • The collection page intro paragraph above the dynamic list is hardcoded in the frontend mapper, not CMS-editable; non-ES locales currently fall back to the Spanish copy
  • Last Seats is configured on its own Arkana screen (Products → Last Seats), not under the CMS group; the endpoint lives here only because it serves the public site the same way the other CMS reads do
  • Last Seats card prices and percentages are derived, never stored — the “from” price comes from bookable 2A offers on campaign departures with seats left, and the percentage from that price against the hand-typed comparison price
  • Last Seats responses are SSR-cached with the same CMS TTL, except previews, which are always fetched fresh so one operator’s draft is never served to the next visitor

CmsSeeder seeds 3 regions (Andes del Sur, Centroamerica, Sudeste Asiatico) with 2 countries each, all with es_ES translations including region content fields (quote, experiences, CTA), country stats with labels, gallery images, region labels, and an example celebrity page (“Memorias de Africa”). Run via:

Terminal window
./vendor/bin/sail artisan db:seed --class=CmsSeeder

The footer has no seeder — admins populate it manually via /admin/cms-footers after the first deploy. When no record exists the API returns {data: null} and the frontend falls back to the hardcoded mock.

Source: backend/database/seeders/CmsSeeder.php

57 tests in backend/tests/Feature/Api/CmsControllerTest.php organized across eight describe blocks:

  • GET /regions (4 tests) – active filtering, translation, country inclusion, empty state
  • GET /regions/{slug} (9 tests) – single region, 404s, content fields, null optional fields, country stats
  • GET /countries/{slug} (11 tests) – country with experiences, product enrichment via product_by_market_id, 404s, market filtering, stats fields, all linked countries returned per product in pivot sort_order
  • GET /region-labels (3 tests) – default labels, translated labels, locale fallback
  • GET /home (10 tests) – translated sections (hero, contact closing, celebrity, experience carousel), null/inactive states, featured celebrity poster, hero media with resolved URL, contact closing CTA, contactIntro/accordion content with their visible switches (hidden by default), experience slides with image URL resolution and country_name derived from the destination cta_href, celebrity trailers from active pages
  • GET /about-us (14 tests) – translated content, null when missing/inactive, hero media variants (image, video, cover+video, none), per-locale page_title and slug, trailers with storage URLs, skipping incomplete trailers, locale matching, explicitly featured celebrity poster in the celebrity section, null celebrity poster when no featured page is set, featured selection honored (poster returned with null src) when the selected celebrity has no image
  • GET /cms/slugs (3 tests) – resolves the about-us slug/title for the requested locale, null when no translation exists, null when the page is inactive
  • GET /legal (6 tests) – translated sections built into contentHtml, null when missing/inactive, empty sections, missing locale translation, locale matching

13 tests in backend/tests/Feature/Api/CmsCountryCelebrityTrailerTest.php:

  • GET /countries/{slug} celebrityTrailer – full payload with video and image backgrounds, null when unlinked / inactive / without trailer / from another market, sort_order then id tie-breaking, constant query count regardless of linked pages, pivot cascade on delete of either side

9 tests in backend/tests/Feature/Api/CmsCelebrityPageApiTest.php across two describe blocks:

  • GET /celebrity-pages (4 tests) – slim payload shape, market filtering, inactive exclusion, sort order
  • GET /celebrity-pages/{slug} (5 tests) – full detail structure, destination CTA href resolves to the linked product page, null CTA href when no product is linked, 404 for missing slug, 404 for inactive page

20 tests in backend/tests/Feature/Api/CmsFooterTest.php:

  • GET /footer (7 tests) – translation for current locale, null newsletter/copyright when not set, empty linkGroups when translation’s link_groups is null, null/empty hrefs coalesce to # for frontend rendering, empty payload when no translation exists for the requested locale, {data: null} when no record exists, correct locale resolution across markets
  • GET /footer country columns (6 tests) – country columns lead linkGroups and split alphabetically across up to 3 balanced columns with locale-aware sort, hrefs are market-agnostic English paths (/destinations/{slug}), unpublished countries excluded, sort_order is the tiebreaker when localized names are equal, countries without a translation for the current locale are skipped, country columns are entirely omitted when no eligible countries exist
  • GET /footer design fields (7 tests) – address and disclaimer text returned from the translation, null when not set; social links paired with platform-brand aria labels; unsupported social platforms and missing hrefs silently filtered; trust logos returned in stored order with unsupported identifiers filtered; legal links follow the same internal/external href semantics as link items; empty arrays when design fields are unset

16 tests in backend/tests/Feature/Http/Resources/CmsCollectionDetailResourceCuratedExperiencesTest.php:

  • experiencesFinalSection.rows (dynamic list) (6 tests) – one row per active PBM reachable via the pivot, market filtering, status filtering, locale fallback, region/country resolution, missing-country handling
  • featuredExperiences (aggregated from countries) (10 tests) – aggregation across multiple countries with sort_order then JSON-index ordering, tie-breaking on equal sort_order by country id, exclusion of mismatched tags, exclusion of inactive PBMs, exclusion of PBMs in other markets, exclusion of countries without a locale translation, source-country wins over the PBM’s pivot country, fallback to the collection palette color when an experience has no label_color, empty-array response when no countries match

3 tests in backend/tests/Feature/Filament/CmsCollectionResourceTest.php:

  • CmsCollectionResource color palette (3 tests) – create form accepts a palette color id, create form rejects a non-palette color id, edit form can reassign to another palette color id
  • Multi-Market API – parent route group and market middleware
  • Products by Market – ProductByMarket model linked via pivot
  • Source: backend/app/Http/Resources/CmsHomePageResource.php
  • Source: backend/app/Http/Resources/CmsRegionResource.php
  • Source: backend/app/Http/Resources/CmsRegionListResource.php
  • Source: backend/app/Http/Resources/CmsCountryResource.php
  • Source: backend/app/Http/Resources/CmsAboutUsPageResource.php
  • Source: backend/app/Http/Resources/CmsCelebrityPageResource.php
  • Source: backend/app/Http/Resources/CmsCelebrityPageListResource.php
  • Source: backend/app/Http/Resources/CmsLegalPageResource.php
  • Source: backend/app/Http/Resources/CmsFooterResource.php
  • Source: backend/app/Http/Resources/CmsCollectionDetailResource.php