Skip to content

Frontend Overview

The frontend — the Web — is byvolare.com, Volāre’s online travel agency: the customer-facing storefront where travellers browse destinations and buy their trips. It is built with Astro 7 and Tailwind CSS v4 for optimal performance and SEO-friendly travel product pages, and consumes the REST API served by Arkana (the back office where all content, products, and pricing are configured).

Component Technology Version
Framework Astro 7.x
React integration @astrojs/react 6.x
Node adapter @astrojs/node 11.x
Cloudflare adapter @astrojs/cloudflare 14.x
Styling Tailwind CSS 4.x
Runtime Node.js 22.12+
Package Manager pnpm 11.28.5 (pinned via packageManager)
Deployment Docker image on AWS (front servers); Cloudflare Workers for previews -
Frontend
├── Pages (File-based routing)
│ ├── Static pages (Astro components)
│ ├── Dynamic routes ([market], [slug])
│ └── API endpoints (.ts files)
├── Layouts (Shared structure)
│ └── MainLayout.astro
├── Components (Reusable UI)
│ └── Feature-specific components
└── Shared (Utilities & config)
  • Zero JavaScript by default - Astro components ship no JS unless needed
  • File-based routing - Pages map directly to URLs
  • Dynamic routes - Market and product slug parameters
  • SSR/SSG flexibility - Server or static rendering per page
  • HTML whitespace preserved - astro.config.mjs pins compressHTML: true; Astro 7’s 'jsx' default strips whitespace between inline elements
  • Tailwind CSS v4 - Utility-first styling with CSS variables
frontend/
├── src/
│ ├── components/ # Reusable Astro components
│ ├── features/ # Feature-specific components
│ ├── layouts/ # Page layouts
│ │ └── MainLayout.astro
│ ├── pages/ # File-based routing
│ │ ├── index.astro
│ │ ├── health.ts
│ │ ├── [market]/ # Dynamic market routes
│ │ └── products/
│ ├── shared/ # Shared utilities
│ └── styles/ # Global styles
├── public/ # Static assets
├── astro.config.mjs # Astro configuration
└── package.json

All market-scoped pages live under src/pages/[market]/ and use English segment names as file routes. For markets with translated URL segments (currently ES), middleware rewrites translated URLs to the English file routes. See i18n Routing for the full translation system.

src/pages/[market]/index.astro -> /{market} (home)
src/pages/[market]/home.astro -> /{market}/home
src/pages/[market]/destinations/[country].astro -> /{market}/destinations/{country}
src/pages/[market]/regions/[slug].astro -> /{market}/regions/{slug}
src/pages/[market]/about-us.astro -> /{market}/about-us
src/pages/[market]/terms.astro -> /{market}/terms
src/pages/[market]/privacy-policy.astro -> /{market}/privacy-policy
src/pages/[market]/cookie-policy.astro -> /{market}/cookie-policy
src/pages/[market]/legal.astro -> /{market}/legal
src/pages/[market]/collections/[slug].astro -> /{market}/collections/{slug}
src/pages/[market]/last-seats.astro -> /{market}/last-seats (ES: /es/ultimas-plazas)
src/pages/[market]/book-appointment.astro -> /{market}/book-appointment (ES: /es/agendar-cita)
src/pages/[market]/celebrity/ -> /{market}/celebrity/...
src/pages/[market]/[...path].astro -> /{market}/{tourPath}/{slug} (product catch-all)

For the ES market, the first URL segment is translated. Middleware handles the rewrite transparently:

User-facing URL Internal file route
/es/destinos/argentina /es/destinations/argentina
/es/sobre-nosotros /es/about-us
/es/terminos-condiciones /es/terms
/es/ultimas-plazas /es/last-seats
/es/agendar-cita /es/book-appointment

English-language markets (US, UK, CA) and DE use English segments as-is.

Product pages use localized tour path slugs from market configuration:

Pattern Example Description
/{market}/{tourPath}/{slug} /es/circuito/to-maldivas Default language
/{market}/{lang}/{tourPath}/{slug} /es/ca/circuit/to-maldivas Specific language

The tourPath segment is configured per-market and language (e.g., “circuito” for Spanish, “circuit” for Catalan).

Preview Mode: Product pages accept ?preview=<token> to display draft products using signed API URLs.

Source: frontend/src/pages/[market]/[...path].astro

src/pages/health.ts -> /health (JSON response)
src/pages/sitemap.xml.ts -> /sitemap.xml (sitemap index)
src/pages/[market]/sitemap.xml.ts -> /{market}/sitemap.xml (URL set)

The public sitemap is generated at request time rather than during the Astro build. Laravel owns the indexable URL inventory because it has direct access to the market, CMS, and product records. Astro fetches that relative-path manifest from GET /api/sitemap/urls. The root /sitemap.xml response is a <sitemapindex> with one /{market}/sitemap.xml entry for every valid market key in the manifest. Each market endpoint converts only that market’s paths to an absolute XML <urlset> on https://byvolare.com; an absent market returns 404.

This keeps the frontend build independent of a reachable API and works with both the Node and Cloudflare adapters. The response is cacheable for five minutes by browsers and one hour by shared caches, with stale responses allowed for one day when revalidation fails. If Laravel is unavailable and no cached response exists, the Astro endpoint returns an error instead of publishing a partial sitemap.

Only canonical, indexable paths belong in the manifest. Laravel currently includes the ES homepage, static legal pages, the active About Us CMS slug, active destinations, regions, collections and celebrity pages, and active products with a non-empty localized URL slug. URL segment translations must remain aligned between SitemapUrlManifest::LOCALIZED_SEGMENTS and the frontend’s SEGMENT_TRANSLATIONS.

Source: frontend/src/pages/sitemap.xml.ts, frontend/src/pages/[market]/sitemap.xml.ts, frontend/src/shared/seo/sitemap.ts, backend/app/Services/SitemapUrlManifest.php

PublicLayout.astro mounts NewsletterPopup.astro on market-scoped public pages. Checkout, the booked customer’s trip, confirmation and error pages are excluded; the unscoped design previews do not receive the automatic popup. The wrapper passes the API URL, market and localized privacy-policy link to the React island.

The popup appears after 15 seconds, once per tab session. Closing it with the cross, outside click or Escape suppresses it for 30 days. A successful signup from either the popup or footer suppresses it indefinitely in that browser, until browser storage is cleared. Storage holds only preference flags and a timestamp, never contact details; when storage is unavailable, suppression cannot persist across visits.

Name, email and privacy consent are required by the form; phone is optional. Success closes the popup and shows a five-second confirmation toast at the bottom right on desktop and full width at the bottom on mobile. The page can still scroll behind the fixed popup. Both forms use the same newsletter subscription endpoint, with queued delivery to Pipedrive and ActiveCampaign.

Source: frontend/src/components/NewsletterPopup/, frontend/src/services/newsletterPreferences.ts, frontend/src/services/pipedrive.ts

---
// Component script (runs at build time)
interface Props {
title: string;
description?: string;
}
const { title, description } = Astro.props;
---
<article class="product-card">
<h2>{title}</h2>
{description && <p>{description}</p>}
</article>
<style>
.product-card {
@apply p-4 rounded-lg shadow-md;
}
</style>
---
import MainLayout from '../layouts/MainLayout.astro';
---
<MainLayout title="Page Title">
<main>
<h1>Content here</h1>
</main>
</MainLayout>

Tailwind v4 uses CSS-first configuration:

src/styles/global.css
@import "tailwindcss";
@theme {
--color-primary: #3b82f6;
--color-secondary: #64748b;
}
<div class="bg-primary text-white p-4 rounded-lg">
<h1 class="text-2xl font-bold">Title</h1>
<p class="text-secondary">Description</p>
</div>
Terminal window
# Start development server
pnpm dev
# Build for production
pnpm build
# Preview production build
pnpm preview

The frontend runs at http://localhost:4321 by default.

---
const market = Astro.params.market;
const lang = 'en';
const response = await fetch(
`${import.meta.env.API_URL}/api/${market}/${lang}/products`
);
const { data: products } = await response.json();
---
<ul>
{products.map((product) => (
<li>
<a href={`/${market}/products/${product.url_slug}`}>
{product.title}
</a>
</li>
))}
</ul>

Use Astro’s <Image /> component for optimized images:

---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---
<Image
src={heroImage}
alt="Hero image"
width={1200}
height={600}
/>

All pages using PublicLayout or MainLayout automatically get canonical and hreflang tags via the shared SeoHead.astro component. This generates:

  • <link rel="canonical"> pointing to the current market’s translated URL
  • <link rel="alternate" hreflang="..."> for all 5 markets (es-ES, de-DE, en-US, en-GB, en-CA) plus x-default (ES)

No manual hreflang work is needed when using the standard layouts. The production domain for absolute URLs is https://byvolare.com.

Source: frontend/src/shared/ui/SeoHead.astro, frontend/src/shared/config/i18n.ts

Product pages include JSON-LD structured data generated from the product API response.

Product detail pages use a magazine-style editorial layout with an interactive trip configurator in a slide-out drawer.

Section Component Description
Hero viaje/Hero Full-bleed hero with breadcrumbs, country label, duration, price, and Reserve CTA
TextItinerary viaje/TextItinerary Day-by-day itinerary with background image
InclusionsModule InclusionsModule Six fixed “Incluido en el precio” tabs (Vuelos, Traslados, Alojamientos exclusivos, Gastronomía, Actividades, Seguro de viaje / Seguro de cancelación) + plain-text “No incluido” block
AccommodationCarousel viaje/AccommodationCarousel Horizontally scrollable accommodation cards (HospitalityCard), each badged with the hotel’s own category (5★ Incluido / 5★ Exclusivo / 5★ Gran Lujo); a one-line explanation of the tier shows under the tier tabs (labels in frontend/src/shared/utils/hotelCategory.ts)
ImageSection viaje/ImageSection Gallery grid (single or triple layout)
Celebrity home/CelebritySection Celebrity spotlight section (from linked CmsCelebrityPage); replaces gallery when present
QandASection viaje/QandASection Tabbed FAQ categories with accordion
FloatingActions ProductFloatingActions Sticky Reserve/Details buttons + TravelDetailsModal + ConfiguratorDrawer
src/features/product/
├── types/
│ ├── product.ts # Product API response interfaces
│ ├── itinerary.ts # Itinerary module types
│ └── configurator.ts # TripConfigurator types
├── mappers/
│ ├── productToViajeProps.ts # Maps API data to Viaje component props
│ └── productToItineraryTrip.ts # Maps itinerary days, editorial items and activities to module props
├── components/
│ ├── ProductPage.astro # Main page assembler (uses Viaje components)
│ ├── ProductFloatingActions.tsx # Composes FloatingButtons + TravelDetailsModal + ConfiguratorDrawer
│ ├── Itinerary/ # Day-by-day itinerary module (per-product `show_itinerary_module` switch, or the staff `itinerary-module` toggle): ItinerarySection, ItineraryIntro and the full-screen ItineraryModal day view
│ ├── ConfiguratorDrawer/ # Slide-out configurator wrapper
│ │ └── ConfiguratorDrawer.tsx
│ ├── TripConfigurator/ # Trip configuration form
│ │ └── TripConfigurator.tsx
│ └── ReserveButton.tsx # Opens configurator drawer
└── index.ts
src/components/viaje/ # Shared Viaje design components
├── Hero/ # Hero with breadcrumbs, label, CTA
├── TextItinerary/ # Day-by-day itinerary
├── AccommodationCarousel/ # Accommodation cards carousel
├── ImageSection/ # Single or triple image gallery
├── QandASection/ # Tabbed FAQ accordion
├── FloatingButtons/ # Sticky bottom action buttons
└── ExperienceModal/ # Experience detail modal
src/components/InclusionsModule/ # Six fixed "Incluido en el precio" tabs + "No incluido" block (PDP)
src/components/viaje/InfoSection/ # Generic accordion section; used by HomeAccordionSection (home page, when switched on)

ProductFloatingActions is a single React island that composes all interactive product page elements: FloatingButtons, TravelDetailsModal, and ConfiguratorDrawer. The configurator drawer’s open/close state lives in the shared uiStore (isDrawerOpen / openDrawer / closeDrawer); other UI state (e.g. the floating button’s scroll state) is local React state.

  • Hero CTA and FloatingButtons “Reserve” button open the configurator drawer
  • FloatingButtons “Details” button opens the TravelDetailsModal
  • ConfiguratorDrawer renders TripConfigurator inside the Drawer component
  • Drawer component handles escape key, backdrop click, and body scroll lock
  • Scroll coupling with TextItinerary: TextItinerary enables GSAP ScrollTrigger.normalizeScroll for its scroll-scrubbed day animation (it fixes iOS Safari momentum scroll). normalizeScroll stamps touch-action: pan-x on <html>/<body>, which disables native vertical touch-scroll page-wide — including inside the drawer. TextItinerary therefore suspends normalizeScroll while isDrawerOpen is true and restores it on close, so the drawer scrolls natively on touch devices. Setting touch-action on the drawer itself cannot fix this — the restriction must be lifted at the html/body level.
src/shared/ui/
└── Drawer/
└── Drawer.tsx # Reusable slide-out panel with motion animations

Product pages use PublicLayout.astro – the standard public layout with header and footer. The product page route ([...path].astro) fetches both the product data and leading price, then passes them to ProductPage.astro. A mapper (productToViajeProps.ts) transforms the API response into props for each Viaje design component.

Data flow: [...path].astro fetches product + leading price -> ProductPage.astro maps via mapProductToViajeProps() -> renders Viaje components (Hero, TextItinerary, InclusionsModule, etc.) + ProductFloatingActions (React island with configurator drawer). The mapper’s mapInclusions() builds the six fixed inclusion tabs from product.included_in_price (with ProductByMarket overrides already merged) and emits inclusions: null only when both sides are empty (no inclusion category content and no not_included text) so the whole module hides. When only one side has data, the module renders and InclusionsModule itself omits the “No incluido” row if notIncludedText is empty. The anchor <div id="viaje-inclusions"> in ProductPage.astro is rendered unconditionally so the FloatingButtons visibility trigger always exists. When the product’s show_itinerary_module is true (set per product in Arkana) or the staff itinerary-module toggle is on, ProductPage.astro swaps TextItinerary for ItineraryIntro + ItinerarySection (props from mapProductToItineraryTrip(), which builds each day from the day’s editorial items and falls back to the included activities when the product has none) and keeps the current ProductFloatingActions bar with shortItinerary on.

Source: frontend/src/features/product/

The homepage is CMS-managed, with sections fetched from the backend API. Region data and the PrivateGuideSection cards (shared with the About Us page) come from separate endpoints.

/{market}/ (rewrites internally to /{market}/home)
/{market}/home (direct access also works)
Example: /es/
Section Component Data Source
Hero HomeHeroSlider cms/home – hero section (heroSlides)
Private Guide (“El viaje que siempre imaginaste”) PrivateGuideSection cms/about-us – valuesSection (title + cards), shared with the About Us page; omitted while its Arkana switch is off or it has no cards
Contact Intro HomeContactSection cms/home – contactIntro section; rendered only when its Arkana switch is on (visible)
Regions RegionInfoSection cms/regions – separate endpoint
Accordion HomeAccordionSection cms/home – accordion section; rendered only when its Arkana switch is on (visible)
Últimas plazas band lastSeats/LastSeatsBand cms/last-seats – omitted entirely when the Last Seats section is not live
Contact Closing (“Habla con un especialista”) HomeContactClosing cms/home – contactClosing section (button links to the “Agendar cita” page)
Celebrity CelebritySection cms/home – celebrity section (poster + trailers)
Experience Carousel HomeExperienceCarousel cms/home – experienceCarousel section
src/pages/[market]/home.astro # Page: fetches CMS data, renders sections
src/features/landing/data/cmsHomeMapper.ts # Maps API response to component props
src/components/home/
├── HomeHeroSlider/ # Rendered hero: slideshow fed from heroSlides (#2180/#2135)
├── HomeHero/ # Legacy single hero component; still present but no longer rendered
├── PrivateGuideSection/ # Photo cards band fed by the About Us values section
├── HomeContactSection/ # Contact intro (link CTA); types reused by HomeContactClosing
├── HomeContactClosing/ # Closing band; CTA links to the "Agendar cita" page
├── RegionInfoSection/ # Region cards with country links
├── HomeAccordionSection/ # Expandable content items (switchable in Arkana)
├── CelebritySection/ # Celebrity spotlight with trailer modal
└── HomeExperienceCarousel/ # Slide-based experience showcase
  • CMS-driven: Content comes from GET /{market}/{lang}/cms/home, GET /{market}/{lang}/cms/regions and, for PrivateGuideSection, GET /{market}/{lang}/cms/about-us (valuesSection), all fetched in parallel and SSR-cached
  • Contact Closing band: HomeContactClosing.astro (zero JS) renders the bottom CTA as a link to the “Agendar cita” page (/{market}/book-appointment, /es/agendar-cita). That standalone page hosts the features/appointment form, which POSTs to the Leads API. See Leads API
  • Closing band on other pages: The same HomeContactClosing band closes the region, country and trip pages. Country and trip pages load the home copy via features/landing/data/fetchContactClosing.ts; the region page uses its own CMS overrides with home as fallback
  • Graceful degradation: Each section is independently optional; if data is missing, the section is omitted
  • Switchable sections: Contact intro and accordion keep their CMS content but render only when their Arkana Show on website switch is on (visible in the API, default off)
  • Parallel fetching: Home, regions and about-us API calls run concurrently via Promise.allSettled
  • Tag icons: Accordion and experience carousel icons are hardcoded in the frontend mapper, not CMS-managed
  • Region carousel links and image: Each RegionInfoSection slide renders the region title as a link to /{market}/regions/{slug} (built via localizedPath, e.g. /es/regiones/africa, /us/regions/africa) alongside the existing per-country links. The slide background uses the region’s Hero Image (CMS image) and falls back to the Full-Width Image (fullWidthImage) only when image is null
  • Global floating contact FAB: PublicLayout renders a FloatingContact action button on every public page (#2139), not just the homepage
  • Market-sourced footer contact: The footer customer-service schedule, phone, and WhatsApp are sourced from the markets table (customer_service_phone / customer_service_whatsapp / customer_service_hours, #2140), keeping contact details a per-market single source of truth

Source: frontend/src/pages/[market]/home.astro, frontend/src/features/landing/data/cmsHomeMapper.ts, frontend/src/layouts/PublicLayout.astro

Country/destination landing pages showcase editorial experiences for a specific country, using PublicLayout and the PaisExperienceSection components.

/{market}/destinations/{country}
Example: /es/destinations/argentina
Section Description
Title + Description Country name and editorial description
Experience Cards (Desktop) PaisExperienceSection – sticky 3-column gallery with scroll animations
Experience Cards (Mobile) PaisExperienceMobileSection – full-height snap-scroll cards
Trip Modal ExperienceBottomModal – lists linked trips with itinerary and pricing
Navbar + Footer Via PublicLayout
src/components/pais/
├── PaisExperienceSection/
│ ├── PaisExperienceSection.astro # Astro wrapper (desktop)
│ ├── PaisExperienceSection.react.tsx # React component
│ ├── PaisExperienceSection.types.ts # Type definitions
│ └── PaisExperienceSection.module.css # Styles
└── PaisExperienceMobileSection/
├── PaisExperienceMobileSection.astro # Astro wrapper (mobile)
├── PaisExperienceMobileSection.react.tsx # React component
└── PaisExperienceMobileSection.module.css # Styles

Country data is fetched server-side from the CMS API. Editorial experience content (title, tag, description, image) is stored per-locale in cms_country_translations.experiences JSON. Each experience references a product_template_id which is resolved to the matching ProductByMarket per requested market.

Source: frontend/src/pages/[market]/destinations/[country].astro

The checkout flow guides users through customizing their trip after selecting an offer.

TripConfigurator → Loading Screen → Flights → Hotels → Activities → Transfers → Contact → Travelers → Summary & Payment → Confirmation
Step Route Description
Loading /{market}/checkout/{offerId}/loading Transition screen with progress animation
Flights /{market}/checkout/{offerId}/flights Select flight options
Hotels /{market}/checkout/{offerId}/hotels Select hotel accommodations
Activities /{market}/checkout/{offerId}/activities Select optional activity upgrades per day
Transfers /{market}/checkout/{offerId}/transfers Select optional transfer upgrades per day
Contact /{market}/checkout/{offerId}/contact Enter booking contact person details
Travelers /{market}/checkout/{offerId}/travelers Enter traveler information with inline name validation (ASCII-only)
Summary /{market}/checkout/{offerId}/summary Review booking and complete payment
Confirmation /{market}/confirmation/{reference} Booking confirmed, trip details and next steps

The loading screen provides a branded transition between the trip configurator and checkout:

  • Full-screen background with destination imagery
  • Loading card with animated progress bar
  • Rotating tips displaying travel-related messages
  • 3-second minimum display ensures smooth UX
  • Auto-redirect to flights page when ready

The TripConfigurator redirects to the loading page after offer creation, which then forwards to the checkout flow.

Source: frontend/src/features/checkout-loading/

Checkout pages share a common architecture:

  • Astro page - Server-rendered wrapper passing props to React
  • React page component - Handles state, API calls, navigation
  • Selector components - Day-by-day selection UI
  • Shared components - StickyHeader (with summary drawer), NavigationFooter, HelpWidget
src/features/checkout-loading/
├── types/
│ └── loading.ts # Loading screen types
├── data/
│ └── loadingContent.ts # Rotating tips content
├── components/
│ ├── CheckoutLoadingScreen.tsx # Main loading screen
│ ├── LoadingCard.tsx # Card with progress and tips
│ ├── ProgressBar.tsx # Animated progress bar
│ └── RotatingTips.tsx # Cycling tip messages
└── index.ts
src/features/checkout/
├── types/ # TypeScript interfaces
│ ├── checkout.ts # Shared types (OfferSummary, Currency, Labels)
│ ├── hotel.ts # Hotel selection types (3-tier selector)
│ ├── activity.ts # Activity selection types
│ ├── transfer.ts # Transfer selection types
│ ├── traveler.ts # Traveler data types
│ └── summary.ts # Summary page types
├── components/
│ ├── HotelSelectionPage.tsx
│ ├── ActivitySelectionPage.tsx
│ ├── TransferSelectionPage.tsx
│ ├── TravelerDataPage.tsx
│ ├── SummaryPaymentPage.tsx
│ ├── HotelSelector/ # Tabbed 3-tier hotel cards
│ ├── ActivitySelector/ # Day sections, cards
│ ├── TransferSelector/ # Day sections, cards
│ ├── TravelerForm/ # Traveler input forms
│ ├── Summary/ # Summary page components
│ ├── StickyHeader.tsx # Trip info header with summary button
│ ├── CheckoutSummaryDrawer.tsx # Slide-out summary with price breakdown
│ ├── NavigationFooter.tsx # Back/Continue buttons
│ └── HelpWidget.tsx # Support contact
└── api/
└── checkoutApi.ts # Session persistence
src/features/confirmation/
├── components/
│ ├── ConfirmationPage.tsx # Main page component
│ ├── HeroColumn.tsx # Left column with destination image
│ ├── DetailsColumn.tsx # Right column with booking details
│ ├── ChecklistItem.tsx # Individual checklist items
│ └── ActionButtons.tsx # Download/share actions
└── types/
└── confirmation.ts # Confirmation page types

Each selection page uses the reducer pattern:

  • Map<number, string> tracks one selection per day (dayNumber → optionId)
  • Selections persist to backend session via checkout API
  • Previous selections restore on page load

Selections save to the checkout session (backend API):

// Activity selections
{ activity_id: number, day_number: number, price: number }[]
// Transfer selections
{ transfer_id: number, day_number: number, price: number }[]

The summary page edit buttons append ?edit=summary to step URLs. When a step page detects this parameter, it enters edit mode:

  • Navigation footer shows Cancel / Save instead of Back / Next
  • Save persists selections (advance: false) and redirects to summary
  • Cancel redirects to summary without saving

This avoids forcing users through all remaining steps to return to summary after a single edit. The navbar summary drawer does not use edit mode — it keeps the normal step-by-step flow since it is accessible from intermediate steps.

Edit mode utilities live in frontend/src/features/checkout/utils/editMode.ts.

Source: frontend/src/features/checkout/

Production and staging run the frontend Docker image (built by build-images.yml, Node adapter) on the front servers in AWS; you choose the tag in the infra-volare deploy workflow. Every push touching frontend/ also builds on Cloudflare Workers, which gives each branch a preview URL. See Deployment.