Skip to content

Frontend Overview

The Volare frontend is built with Astro 5 and Tailwind CSS v4, providing optimal performance and SEO-friendly travel product pages.

ComponentTechnologyVersion
FrameworkAstro5.x
StylingTailwind CSS4.x
RuntimeNode.js18+
Package Managerpnpm8+
DeploymentCloudflare Pages-
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
  • 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]/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 URLInternal file route
/es/destinos/argentina/es/destinations/argentina
/es/sobre-nosotros/es/about-us
/es/terminos-condiciones/es/terms

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

Product pages use localized tour path slugs from market configuration:

PatternExampleDescription
/{market}/{tourPath}/{slug}/es/circuito/to-maldivasDefault language
/{market}/{lang}/{tourPath}/{slug}/es/ca/circuit/to-maldivasSpecific 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)
---
// 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.

SectionComponentDescription
Heroviaje/HeroFull-bleed hero with breadcrumbs, country label, duration, price, and Reserve CTA
TextItineraryviaje/TextItineraryDay-by-day itinerary with background image
InfoSectionviaje/InfoSectionAccordion with highlights, meals, accommodation details, not-included
AccommodationCarouselviaje/AccommodationCarouselHorizontally scrollable accommodation cards
ImageSectionviaje/ImageSectionGallery grid (single or triple layout)
Celebrityhome/CelebritySectionCelebrity spotlight section (from linked CmsCelebrityPage); replaces gallery when present
QandASectionviaje/QandASectionTabbed FAQ categories with accordion
FloatingActionsProductFloatingActionsSticky Reserve/Details buttons + TravelDetailsModal + ConfiguratorDrawer
src/features/product/
├── types/
│ ├── product.ts # Product API response interfaces
│ └── configurator.ts # TripConfigurator types
├── mappers/
│ └── productToViajeProps.ts # Maps API data to Viaje component props
├── components/
│ ├── ProductPage.astro # Main page assembler (uses Viaje components)
│ ├── ProductFloatingActions.tsx # Composes FloatingButtons + TravelDetailsModal + ConfiguratorDrawer
│ ├── 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
├── InfoSection/ # Accordion-based info section
├── AccommodationCarousel/ # Accommodation cards carousel
├── ImageSection/ # Single or triple image gallery
├── QandASection/ # Tabbed FAQ accordion
├── FloatingButtons/ # Sticky bottom action buttons
└── ExperienceModal/ # Experience detail modal

ProductFloatingActions is a single React island that composes all interactive product page elements: FloatingButtons, TravelDetailsModal, and ConfiguratorDrawer. It manages open/close state locally via 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
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, InfoSection, etc.) + ProductFloatingActions (React island with configurator drawer).

Source: frontend/src/features/product/

The homepage is fully CMS-managed, with all sections fetched from the backend API. Region data comes from a separate endpoint.

/{market}/ (rewrites internally to /{market}/home)
/{market}/home (direct access also works)
Example: /es/
SectionComponentData Source
HeroHomeHerocms/home — hero section
Contact IntroHomeContactSectioncms/home — contactIntro section
RegionsRegionInfoSectioncms/regions — separate endpoint
AccordionHomeAccordionSectioncms/home — accordion section
CelebrityCelebritySectioncms/home — celebrity section (poster + trailers)
Experience CarouselHomeExperienceCarouselcms/home — experienceCarousel section
Contact ClosingHomeContactClosingcms/home — contactClosing section (opens ContactModal drawer)
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/
├── HomeHero/ # Full-screen hero with image/video
├── HomeContactSection/ # Contact CTA (intro: link, closing: opens drawer)
├── HomeContactClosing/ # React wrapper: opens ContactModal with form + toast
├── RegionInfoSection/ # Region cards with country links
├── HomeAccordionSection/ # Expandable content items
├── CelebritySection/ # Celebrity spotlight with trailer modal
└── HomeExperienceCarousel/ # Slide-based experience showcase
  • CMS-driven: All content comes from GET /{market}/{lang}/cms/home and GET /{market}/{lang}/cms/regions
  • Contact Closing drawer: The bottom CTA uses HomeContactClosing (React island with client:visible) which opens a ContactModal drawer with a contact form. Submissions POST to the backend Leads API (/api/leads) via leadApi.ts, creating a Lead record for CRM follow-up. Region options for the form are derived from the already-fetched regions data. See Leads API for the endpoint contract
  • Graceful degradation: Each section is independently optional; if data is missing, the section is omitted
  • Parallel fetching: Home and regions API calls run concurrently via Promise.allSettled
  • Tag icons: Accordion and experience carousel icons are hardcoded in the frontend mapper, not CMS-managed

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

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

/{market}/destinations/{country}
Example: /es/destinations/argentina
SectionDescription
Title + DescriptionCountry 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 ModalExperienceBottomModal — lists linked trips with itinerary and pricing
Navbar + FooterVia 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
StepRouteDescription
Loading/{market}/checkout/{offerId}/loadingTransition screen with progress animation
Flights/{market}/checkout/{offerId}/flightsSelect flight options
Hotels/{market}/checkout/{offerId}/hotelsSelect hotel accommodations
Activities/{market}/checkout/{offerId}/activitiesSelect optional activity upgrades per day
Transfers/{market}/checkout/{offerId}/transfersSelect optional transfer upgrades per day
Contact/{market}/checkout/{offerId}/contactEnter booking contact person details
Travelers/{market}/checkout/{offerId}/travelersEnter traveler information with inline name validation (ASCII-only)
Summary/{market}/checkout/{offerId}/summaryReview 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/

The frontend deploys to Cloudflare Pages:

Terminal window
# Deploy to staging
pnpm deploy-staging

Build output is in dist/ directory.