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.
When to Use
Section titled “When to Use”- Display homepage sections (hero, contact, accordion, celebrity, experience carousel)
- 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 with translated header, caption, and trailers
- 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
Data Model
Section titled “Data Model”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_heading, 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, trailers JSON)
CmsHomePage (hero_media_path, status) — singleton└── CmsHomePageTranslation (locale, hero_title, hero_description, contact_intro_caption, contact_intro_cta_label, contact_intro_cta_href, 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, contact_closing_cta_href) └── 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_country_id FK, meta_year, meta_duration, status, sort_order)├── Market (belongs-to)└── CmsCountry (belongs-to via destination_country_id)
CmsLegalPage (status) — singleton└── CmsLegalPageTranslation (locale, title, sections JSON, seo_title, seo_description)
CmsFooter — singleton (no status column)└── CmsFooterTranslation (locale, link_groups JSON, newsletter_heading, copyright_text)
CmsCollection (slug, collection_color_id FK, image, sort_order, status)├── CollectionColor (hex, label, sort_order) — seed-only palette├── CmsCollectionTranslation (locale, label, description, title,│ featured_experiences JSON: [{image, title, product_by_market_id}])└── 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), optionalvideoandlabel_colorfor card styling, optionalsection_titlefor the featured position, and aproduct_by_market_idreference. 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 withimageas poster -
Stat labels (Población, Superficie, etc.) are hardcoded on the frontend; i18n will be added later
-
trailers— JSON array on translations: each entry hasposter_image(storage path or URL),poster_alt,href, and optionaltarget(_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_country_id— FK tocms_countries; the API builds the destination CTA href as/{market}/destinations/{country_slug}tags— JSON array of free-text tag strings
Homepage key fields:
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)accordion_items— JSON array of{title, content}objectsexperience_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 tocms_celebrity_pagesper 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
CmsCelebrityPagerecords for the request market
Legal page key fields:
sections— JSON array of{subtitle, body}objects on the translation. The API concatenates them into a singlecontentHtmlstring (<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
statuscolumn — 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 carrylabel,href, andexternal(bool). Whenexternalis false the admin pickshreffromCmsLinkOptions::getStaticPathOptions()(canonical English paths like/about-us); whenexternalis true the admin enters an absolute URL. Null or empty hrefs fall back to#- Region columns are auto-prepended — the API response always leads with two synthetic groups built from published
CmsRegionrecords (sorted alphabetically by localized name with locale-awareCollator,sort_orderas tiebreaker, splitceil(n/2)/ rest). These come from the Regions CMS, not fromlink_groups, so editors don’t need to mirror regions in the footer admin newsletter_heading/copyright_text— plain strings; emitted asnullin the API response when the admin hasn’t filled them in (frontend components treat missing values as “don’t render”)
Collection key fields:
featured_experiences— JSON array on translations. Each entry hasimage(storage path),title(editor-set), andproduct_by_market_id(FK reference). The API resolves the linked PBM at read time to derive the country name + ISO code + PDP href; the editor never writes country or href directlycms_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 marketcollection_color_id— FK to a seed-onlyCollectionColorpalette row. Hex is read via$collection->color->hex; editing a palette row updates every collection that references it
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
API Endpoints
Section titled “API Endpoints”All endpoints live inside the /{market}/{lang}/cms/ route group and use the market middleware for locale resolution.
Source: backend/routes/api.php:66
GET /api/{market}/{lang}/cms/regions
Section titled “GET /api/{market}/{lang}/cms/regions”All active regions with their active countries, translated to the resolved locale.
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", "quote": "A JOURNEY THROUGH TEMPLES AND RICE FIELDS", "experiences": { "intro": { "eyebrow": "Curated memories", "title": "Discover unique experiences", "description": "...", "media": { "src": "https://cdn.example.com/experiences/bg.jpg", "alt": "" }, "previewMedia": { "src": "https://cdn.example.com/experiences/phase1.jpg", "alt": "" } }, "phases": [ { "id": "0", "title": "A quote about this phase", "caption": "Hotel Riamal - Córdoba, Argentina", "experienceName": "Hotel Riamal", "modalTitle": "Viajes para vivir Hotel Riamal", "triggerAriaLabel": "Abrir viajes para vivir Hotel Riamal", "media": { "src": "https://cdn.example.com/experiences/phase1.jpg", "alt": "" }, "relatedTrips": [ { "id": "10", "href": "/es/circuito/tour-sri-lanka", "country": "Sri Lanka", "title": "Tour de Sri Lanka", "itinerary": "Colombo · Kandy · Ella", "duration": "10 nights · from 1.249 €" } ] } ] }, "ctaHeading": "Write your next chapter", "ctaSubheading": "Schedule a call and we'll design your trip.", "ctaButtonText": "talk to us", "countries": [ { "slug": "sri-lanka", "name": "Sri Lanka", "description": "The pearl of the Indian Ocean", "image": "https://cdn.example.com/countries/sri-lanka.jpg", "population": "22 million", "area": "65,610 km²", "currency": "Rupee (LKR)", "bestTime": "December - March", "languages": "Sinhala, Tamil", } ] } ]}Source: backend/app/Http/Controllers/Api/CmsController.php:22
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: Same shape as a single item from the regions list above.
Source: backend/app/Http/Controllers/Api/CmsController.php:42
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:70
GET /api/{market}/{lang}/cms/home
Section titled “GET /api/{market}/{lang}/cms/home”Homepage content (singleton) translated to the resolved locale. Returns null for data when no active page exists. The response is organized into six sections matching the frontend layout: hero, contact intro, accordion, celebrity, experience carousel, and contact closing.
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": "" } }, "contactIntro": { "caption": "No vendemos viajes. Diseñamos experiencias.", "cta": { "label": "Contáctanos", "href": "/es/contact" } }, "accordion": { "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ú" } ] }, "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", "href": "/es/contact" } } }}Key fields:
hero.media.kind—"video"for mp4/webm/mov/avi extensions,"image"otherwisecelebrity.poster— sourced from thefeatured_celebrity_page_idon the translation; null when no celebrity page is linkedcelebrity.trailers— all activeCmsCelebrityPagerecords for the request market (slug as id, poster image, title as alt)experienceCarousel.slides[].image— storage paths resolved to full URLs, existing URLs passed throughexperienceCarousel.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/closing, celebrity) keep the full multi-category, multi-market option list.
contactClosing.cta.href— present in the API response but ignored by the frontend; the closing CTA opens a ContactModal drawer instead of navigating- Contact intro and closing share the same shape but serve different page positions
Source: backend/app/Http/Controllers/Api/CmsController.php:95, backend/app/Http/Resources/CmsHomePageResource.php
GET /api/{market}/{lang}/cms/footer
Section titled “GET /api/{market}/{lang}/cms/footer”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 two synthetic groups built from published regions, followed by whatever the admin has entered in the Footer CMS. Empty linkGroups, null newsletter heading, and null copyright are all valid states for the CMS-driven portion. The frontend handles empty/null values gracefully.
Response shape:
{ "data": { "linkGroups": [ { "items": [ { "label": "África", "href": "/regions/africa", "external": false }, { "label": "América", "href": "/regions/america", "external": false }, { "label": "Asia", "href": "/regions/asia", "external": false }, { "label": "Caribe", "href": "/regions/caribe", "external": false } ] }, { "items": [ { "label": "Europa", "href": "/regions/europa", "external": false }, { "label": "Oceanía", "href": "/regions/oceania", "external": false }, { "label": "Oriente Medio", "href": "/regions/oriente-medio", "external": false }, { "label": "Pacífico", "href": "/regions/pacifico", "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" }}Key fields:
linkGroups[0..1]— region columns. Auto-built from publishedCmsRegionrecords with a translation for the resolved locale, sorted alphabetically by localizednameusing a locale-awareCollatorwithsort_orderas tiebreaker, then splitceil(n/2)/ rest. The second column is omitted when only one region is eligible. Hrefs are canonical English paths (/regions/{slug}); the frontend’slocalizedPathadds the market prefix and translates theregionssegment per locale (e.g. ES →/es/regiones/asia)linkGroups[2..]— CMS-driven groups, in the order configured in/admin/cms-footerslinkGroups[].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 viai18n.server.ts::localizedPathat 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 anchornewsletter.heading— only the heading is CMS-editable;nullwhen not set. Form labels (name/email placeholders, submit button) are static UI chrome and not served by this endpointcopyrightText— free text;nullwhen not set
Fallback behavior:
- No
CmsFooterrecord at all ->{ "data": null }(frontend uses its hardcoded mock; regions are not rendered in this case) CmsFooterexists, no translation for resolved locale ->{ "data": null }(same fallback; regions not rendered)- Record + translation exist -> region columns prepended, CMS-driven groups appended;
nullhrefs in CMS items coalesce to#
Source: backend/app/Http/Controllers/Api/CmsController.php:142, backend/app/Http/Resources/CmsFooterResource.php, backend/app/Models/CmsRegion.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 · Galle" } } ], "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" } ] }}Key fields:
experiences— editorial content per country, stored as JSON on translations. Each entry has editorial fields (image,title,tag,description), optionalvideo(card plays video with image as poster),labelColor(hex tag background),sectionTitle(only rendered on the featured/3rd position), plus aproduct_by_market_idreference. The API resolves this into aproductobject (null if no matching active product for the current market)heroVideo— optional video URL; when set, the country page hero plays video withimageas posterproduct.itinerary— cities from the product template joined with·- Stat labels (Población, Superficie, etc.) are hardcoded on the frontend; i18n will be added later
Source: backend/app/Http/Controllers/Api/CmsController.php:87
GET /api/{market}/{lang}/cms/collections
Section titled “GET /api/{market}/{lang}/cms/collections”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 toslugwhen no translation exists for the localecolor— hex from the linkedCollectionColorpalette rowimage— hero image storage path resolved to a full URL; null when the collection has no image
Source: backend/app/Http/Controllers/Api/CmsController.php:300
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": "fauna-y-vida-salvaje", "collection": { "type": { "label": "Fauna y vida salvaje", "color": "#183a2a" }, "title": "COMO PEZ EN EL AGUA", "description": "Encuentros con la fauna salvaje...", "image": "https://cdn.example.com/cms-collections/hero.jpg" }, "featuredExperiences": [ { "id": 42, "image": "https://cdn.example.com/cms-collections/featured-experiences/macacos.jpg", "title": "Vista de macacos", "country": "Costa Rica", "country_code": "CR", "href": "/es/circuito/tour-costa-rica" } ], "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[]— editor-curated cards fromtranslation.featured_experiences. The card’scountry,country_code, andhrefare derived from the linkedProductByMarketat read time (first attachedCmsCountrytranslated to the request locale; ISO code fromcms_countries.iso_code; href fromProductByMarket::getFrontendPath()). PBMs that aren’t already eager-loaded via the pivot are fetched in a single follow-up query. Cards whose linked PBM no longer exists (status flipped, hard delete, etc.) are silently skippedexperiencesFinalSection.rows[]— auto-built dynamic list. One row per activeProductByMarketreachable throughcms_collection_product_template->ProductTemplate->productsByMarketfor the request market. No editor configuration. Rows where the PBM has no translations at all are skippedpreviewImage.src— sourced fromProductTemplate.hero_image(resolved viaStorage::url())regionName/countryName/countrySlug— resolved from the PBM’s first attachedCmsCountryand its parent region, with translations matched to the request locale (falls back to the first available translation when the locale is missing)href— PDP path fromProductByMarket::getFrontendPath()for both featured cards and dynamic rows
Source: backend/app/Http/Controllers/Api/CmsController.php:323, backend/app/Http/Resources/CmsCollectionDetailResource.php
GET /api/{market}/{lang}/cms/about-us
Section titled “GET /api/{market}/{lang}/cms/about-us”About Us page content (singleton) translated to the resolved locale. Returns null for data when no active page exists.
Response shape:
{ "data": { "headerSection": { "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": { "text": "En Volāre creemos que el viaje..." }, "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 whenvideo_urlis not set on the page recordtrailersSection.items— trailer entries with missinghref,poster_image, orposter_altare silently skipped- Storage paths in
poster_imageare converted to full URLs; existing URLs are passed through
Source: backend/app/Http/Controllers/Api/CmsController.php:131, backend/app/Http/Resources/CmsAboutUsPageResource.php
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:149, 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 destinationCountry relationship 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/destinations/kenya" }, "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 analtfielddestinationCta.href— built as/{market}/destinations/{country_slug}from the linked CmsCountry; null when no country is linked or the linked country is inactivetrailer.youtubeUrl— null when not set
Source: backend/app/Http/Controllers/Api/CmsController.php:163, backend/app/Http/Resources/CmsCelebrityPageResource.php
GET /api/{market}/{lang}/cms/legal
Section titled “GET /api/{market}/{lang}/cms/legal”Legal page content (singleton) translated to the resolved locale. Returns null for data when no active page exists or no record is found.
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 concatenatingsectionsarray entries. Each entry’ssubtitleis wrapped in<h2>(HTML-escaped), followed by thebody(raw HTML from RichEditor). Returns null when sections are empty or translation is missingseo.title/seo.description— optional SEO overrides; null when not set
Source: backend/app/Http/Controllers/Api/CmsController.php:167, backend/app/Http/Resources/CmsLegalPageResource.php
Admin Panel
Section titled “Admin Panel”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.
Home Page (/admin/cms-home-pages)
Section titled “Home Page (/admin/cms-home-pages)”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 (caption, CTA label + link), Accordion (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 + link).
Source: backend/app/Filament/Resources/CmsHomePages/Schemas/CmsHomePageForm.php
Regions (/admin/cms-regions)
Section titled “Regions (/admin/cms-regions)”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 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
Countries (/admin/cms-countries)
Section titled “Countries (/admin/cms-countries)”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
Collections (/admin/cms-collections)
Section titled “Collections (/admin/cms-collections)”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 nested tabs:
- Content — locale, label (navbar/badge text), hero title, description.
- Featured Experiences — repeater of cards. Each card has an image upload (
cms-collections/featured-experiences/), a title, and a searchable Linked Product select. The select queries activeProductByMarketrecords by SKU or translation title and renders options asSKU — title (market_code).
Editors do not configure the bottom-of-page experience list — 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
About Us (/admin/cms-about-us-page)
Section titled “About Us (/admin/cms-about-us-page)”Singleton CMS page for the About Us content. Only one record can exist (create button hidden when a record exists). Vertical tabs: General (video URL, active toggle) and Translations. Each translation has nested tabs: Header (locale, title, lead caption, supporting caption), Content (description), 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, 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, and destination country select (FK to CmsCountry).
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
Legal Page (/admin/cms-legal-page)
Section titled “Legal Page (/admin/cms-legal-page)”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
Footer (/admin/cms-footers)
Section titled “Footer (/admin/cms-footers)”Singleton resource for the public-site footer. Only one record can exist (canCreate() returns false once it does). The form is a single repeater of translations — each translation has a locale select (options from CmsRegionForm::getLocaleOptions()), a nested repeater of link groups (one per visual footer column), a newsletter heading, and copyright text. Each link group contains an items repeater; every item 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
Frontend Integration
Section titled “Frontend Integration”| Page | Route | Data Source |
|---|---|---|
| Home | /[market]/home | GET cms/home + GET cms/regions (parallel) — hero, contact, accordion, celebrity, experience carousel, regions |
| Region | /[market]/regions/[slug] | GET cms/regions/{slug} + GET cms/region-labels (parallel) |
| Country | /[market]/destinations/[country] | GET cms/countries/{slug} — hero (image/video), experience grid (2-1-2 layout from flat list), trips from linked products |
| 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 — header, caption, trailers (with hardcoded fallback) |
| 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; falls back to hardcoded mock on failure |
| Footer (checkout/confirmation) | 7 checkout pages + confirmation | GET cms/footer — link groups + copyright (slim footer variant, no newsletter), falls back to hardcoded list on failure |
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, contact intro, accordion, celebrity, experience carousel, contact closing) is independently optional; missing data results in the section being omitted. Regions are fetched from the separate /cms/regions endpoint and rendered as the RegionInfoSection component between accordion and celebrity sections.
The region page fetches both the region data and region labels in parallel using Promise.allSettled. If region labels fail, frontend defaults are used. The experiences response is mapped to HorizontalScrollSection props (intro + phases with related trips). Country stat labels (Población, Superficie, Moneda, etc.) are hardcoded in the frontend template; i18n will be added later.
The About Us page fetches CMS data and merges each section individually with hardcoded fallback content. If the API fails or returns null, the fallback is used. The PublicLayout navbar dynamically resolves the About Us link to /{market}/about-us.
The celebrity page fetches a single celebrity page by slug from the CMS API. The API response is mapped to HeroCelebrityProps 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), and a destination CTA linking to a CmsCountry 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 API-derived country and the heading shows the editor-set title. 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() — a slim helper that returns link groups and the copyright text (newsletter is replaced with a checkout contact module on those pages). 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
Business Rules
Section titled “Business Rules”- Only active (
status = true) regions and countries appear in API responses - Country products are filtered by the request market and must be active
- Sort order controls display position in both admin and API
- 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 CmsCountry via FK, not a free-text URL
- 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
- 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
contentHtmlserver-side; subtitles are HTML-escaped, body is raw HTML - Legal page footer link points to
/{market}/legal(hardcoded to/es/legalin current footer) - Footer is a singleton with no
statuscolumn; the record either exists (served) or not ({data: null}, frontend mock used) - Footer’s first two
linkGroupsare always the published regions, prepended server-side fromCmsRegionrecords. CMS editors do not (and cannot) configure these in/admin/cms-footers— they sync automatically when regions are added/removed/renamed - Region column sort is locale-aware (PHP
Collator) so accented names likeÁfricaorder correctly per market.sort_orderonCmsRegionis used only as a tiebreaker when localized names are equal - The hardcoded fallback mock (
PUBLIC_LAYOUT_FOOTERinfrontend/src/layouts/layout.mock.ts) does not include region columns. When the API returns{data: null}(missing record or untranslated), the user sees the mock without regions - Footer internal link
hrefvalues 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 editingCmsLinkOptions::getStaticPaths()andfrontend/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 per-locale (stored oncms_collection_translations.featured_experiences); the dynamicexperiencesFinalSection.rows[]is per-market (one row per active PBM reachable via the pivot for the request market) - Featured experience cards whose linked
ProductByMarketno longer exists are silently skipped — they never reach the API response - Collection palette colors come from a seed-only
CollectionColortable; editing a palette row’s hex updates every collection that references it - 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
Seeding
Section titled “Seeding”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:
./vendor/bin/sail artisan db:seed --class=CmsSeederThe 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
Testing
Section titled “Testing”56 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} (10 tests) — country with experiences, product enrichment via product_by_market_id, 404s, market filtering, stats fields
- GET /region-labels (3 tests) — default labels, translated labels, locale fallback
- GET /home (10 tests) — translated sections (hero, contact, accordion, celebrity, experience carousel), null/inactive states, featured celebrity poster, hero media with resolved URL, contact CTAs, accordion items, experience slides with image URL resolution and
country_namederived from the destinationcta_href, celebrity trailers from active pages - GET /about-us (11 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
- 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
7 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} (3 tests) — full detail structure, 404 for missing slug, 404 for inactive page
13 tests in backend/tests/Feature/Api/CmsFooterTest.php:
- GET /footer (7 tests) — translation for current locale, null newsletter/copyright when not set, empty
linkGroupswhen translation’slink_groupsis 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 region columns (6 tests) — region columns lead
linkGroupsand split alphabeticallyceil(n/2)/rest with locale-aware sort, hrefs are market-agnostic English paths (/regions/{slug}), unpublished regions excluded,sort_orderis the tiebreaker when localized names are equal, regions without a translation for the current locale are skipped, region columns are entirely omitted when no eligible regions exist
10 tests in backend/tests/Feature/Http/Resources/CmsCollectionDetailResourceCuratedExperiencesTest.php:
- CmsCollectionDetailResource (10 tests) — featured experience country/code derived from linked PBM, PBMs not eager-loaded fetched in a follow-up query, missing-PBM cards skipped, dynamic rows auto-built from the pivot chain, region/country resolution falls back when the locale translation is missing, response shape and field mapping
4 tests in backend/tests/Feature/Filament/CmsCollectionResourceTest.php:
- CmsCollectionResource (4 tests) — form schema renders,
featured_experiencesJSON round-trips through save/load, palette color select wiring, translation repeater validation
Related
Section titled “Related”- 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/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