Macumba Travel Frontend - Vue.js Travel Search Interface¶
๐จ Frontend Overview¶
The Macumba Travel frontend is a modern Vue.js 3 application that provides an intuitive interface for users to search, discover, and save travel destinations. It communicates with the FastAPI backend to deliver AI-powered travel recommendations with real-time data.
๐ฏ What This Frontend Does¶
- Interactive Search Form: Collects user travel preferences (budget, dates, travelers, preferences)
- Real-time Recommendations: Displays AI-generated travel suggestions with rich data
- User Authentication: Handles login, registration, and profile management
- Trip Management: Allows users to save, organize, and revisit favorite trips
- Responsive Design: Works seamlessly across desktop, tablet, and mobile devices
๐ Current Technology Stack¶
- Node.js: v23.11.0
- npm: 10.9.2
- Vue.js: 3.5.13 - Progressive JavaScript framework
- Vite: 6.2.5 - Fast build tool and development server
- TypeScript: Type-safe JavaScript development
- Tailwind CSS: Utility-first CSS framework
- Pinia: Vue state management
- Vue Router: Client-side routing
- Vitest: Unit testing framework
- Playwright: End-to-end testing
- Firebase: Authentication and hosting
๐๏ธ Frontend Architecture¶
High-Level Component Structure¶
โโโโโโโโโโโโโโโโโโโ
โ App.vue โ Root application component
โ โ Navigation, routing, global state
โโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโ
โ Components โ Reusable UI components
โ โ SearchForm, RecommendationCard, etc.
โโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโ
โ Services โ API communication layer
โ โ HTTP requests, authentication
โโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโ
โ Store โ State management (Pinia)
โ โ Global application state
โโโโโโโโโโโโโโโโโโโ
Vue.js 3 Architecture Pattern¶
User Interaction โ Component Event โ Store Action โ API Service โ Backend
โ โ
UI Updates โ Reactive Data โ Store State โ HTTP Response โ Backend Response
๐ Detailed Directory Structure¶
frontend/
โโโ public/ # Static files served directly
โ โโโ favicon.ico # Website icon
โ โโโ index.html # Main HTML template
โ
โโโ src/ # Source code
โ โโโ main.js # Application entry point
โ โโโ App.vue # Root Vue component
โ โ
โ โโโ components/ # Reusable Vue components
โ โ โโโ SearchForm.vue # Main travel search form
โ โ โโโ RecommendationCard.vue # Individual destination display
โ โ โโโ LoadingSpinner.vue # Loading state indicator
โ โ โโโ ErrorMessage.vue # Error display component
โ โ โโโ Navigation.vue # Site navigation bar
โ โ โโโ AuthForms.vue # Login/register forms
โ โ โโโ TripManager.vue # Saved trips interface
โ โ
โ โโโ services/ # API communication layer
โ โ โโโ api.js # Main HTTP client with axios
โ โ โโโ auth.js # Authentication services
โ โ โโโ config.js # Environment configuration
โ โ
โ โโโ store/ # State management (Pinia)
โ โ โโโ recommendations.js # Travel recommendations state
โ โ โโโ auth.js # User authentication state
โ โ โโโ savedTrips.js # Saved trips management
โ โ โโโ ui.js # UI state (loading, errors)
โ โ
โ โโโ assets/ # Static assets
โ โ โโโ styles/ # CSS and styling files
โ โ โ โโโ main.css # Global styles
โ โ โ โโโ tailwind.css # Tailwind CSS imports
โ โ โโโ images/ # Image assets
โ โ
โ โโโ utils/ # Utility functions
โ โโโ formatters.js # Data formatting helpers
โ โโโ validators.js # Form validation functions
โ โโโ constants.js # Application constants
โ
โโโ .env.development.local # Local development (localhost:8000)
โโโ .env.development # Dev deployment (api-dev.macumbatravel.com)
โโโ .env.production # Production (api.macumbatravel.com)
โโโ firebase.json # Firebase hosting configuration
โโโ package.json # Dependencies and scripts
โโโ vite.config.js # Vite build configuration
โโโ tailwind.config.js # Tailwind CSS configuration
โโโ postcss.config.js # PostCSS configuration
๐ง Key Components Explained¶
1. Main Search Form (SearchForm.vue)¶
The core component that collects user travel preferences:
<template>
<form @submit.prevent="handleSearch" class="space-y-6">
<!-- Budget Slider -->
<div class="budget-section">
<label>Total Budget: ${{ budget }}</label>
<input type="range" v-model="budget" min="500" max="10000" step="100">
</div>
<!-- Date Selection -->
<div class="date-section">
<input type="date" v-model="fromDate" :min="today">
<input type="date" v-model="toDate" :min="fromDate">
</div>
<!-- Travelers Selection -->
<div class="travelers-section">
<select v-model="numAdults">
<option v-for="n in 8" :key="n" :value="n">{{ n }} Adult{{ n > 1 ? 's' : '' }}</option>
</select>
<select v-model="numChildren">
<option v-for="n in 6" :key="n-1" :value="n-1">{{ n-1 }} Children</option>
</select>
</div>
<!-- Preferences -->
<div class="preferences-section">
<label v-for="pref in availablePreferences" :key="pref">
<input type="checkbox" :value="pref" v-model="selectedPreferences">
{{ pref }}
</label>
</div>
<button type="submit" :disabled="isLoading">
{{ isLoading ? 'Searching...' : 'Find Destinations' }}
</button>
</form>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRecommendationsStore } from '@/store/recommendations'
const recommendationsStore = useRecommendationsStore()
// Form data
const budget = ref(1500)
const fromDate = ref('')
const toDate = ref('')
const numAdults = ref(2)
const numChildren = ref(0)
const selectedPreferences = ref([])
// Computed properties
const today = computed(() => new Date().toISOString().split('T')[0])
const isLoading = computed(() => recommendationsStore.isLoading)
// Form submission
const handleSearch = async () => {
const searchParams = {
budget: budget.value,
fromDate: fromDate.value,
toDate: toDate.value,
numAdults: numAdults.value,
numChildren: numChildren.value,
preferences: selectedPreferences.value,
departureCity: "Sydney, New South Wales, Australia" // Would be from autocomplete
}
await recommendationsStore.searchRecommendations(searchParams)
}
</script>
Key Features: - Reactive Data Binding: Form inputs automatically update component state - Computed Properties: Dynamic values like minimum dates - Store Integration: Calls Pinia store actions for API requests - Loading States: Disables form during API calls
2. API Service Layer (services/api.js)¶
Centralized HTTP client for all backend communication:
// services/api.js
import axios from 'axios'
// Create axios instance with base configuration
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL, // Environment-specific API URL
timeout: 30000, // 30 second timeout for travel recommendations
headers: {
'Content-Type': 'application/json'
}
})
// Request interceptor - adds auth token to requests
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handles common errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('authToken')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// API service methods
const apiService = {
// Travel recommendations
async getRecommendations(searchParams) {
const response = await apiClient.post('/travel/recommendations', searchParams)
return response.data
},
async enrichRecommendation(recommendationId) {
const response = await apiClient.get(`/travel/${recommendationId}/enrich`)
return response.data
},
// Authentication
async login(credentials) {
const response = await apiClient.post('/auth/login', credentials)
return response.data
},
async register(userData) {
const response = await apiClient.post('/auth/register', userData)
return response.data
},
async getCurrentUser() {
const response = await apiClient.get('/users/me')
return response.data
},
// Saved trips
async saveTrip(recommendationId, tripData) {
const response = await apiClient.post(`/travel/${recommendationId}/save`, tripData)
return response.data
},
async getSavedTrips() {
const response = await apiClient.get('/users/me/trips')
return response.data
},
async deleteTrip(tripId) {
await apiClient.delete(`/trips/${tripId}`)
},
// Rate limiting
async getRateLimitStatus() {
const response = await apiClient.get('/travel/rate-limit')
return response.data
}
}
export default apiService
Key Features: - Environment Configuration: Automatically uses correct API URL - Authentication Handling: Adds JWT tokens to requests automatically - Error Handling: Centralized error processing and token refresh - Request/Response Interceptors: Automatic headers and error handling
3. State Management (store/recommendations.js)¶
Pinia store for managing travel recommendations state:
// store/recommendations.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import apiService from '@/services/api'
export const useRecommendationsStore = defineStore('recommendations', () => {
// State
const recommendations = ref([])
const isLoading = ref(false)
const error = ref(null)
const searchParams = ref(null)
const rateLimitInfo = ref(null)
// Getters (computed properties)
const hasRecommendations = computed(() => recommendations.value.length > 0)
const sortedRecommendations = computed(() => {
return [...recommendations.value].sort((a, b) => a.cost - b.cost)
})
const budgetFilteredRecommendations = computed(() => {
if (!searchParams.value?.budget) return recommendations.value
const maxCostPerPerson = searchParams.value.budget / getTotalTravelers()
return recommendations.value.filter(rec => rec.cost <= maxCostPerPerson)
})
// Actions
const searchRecommendations = async (params) => {
isLoading.value = true
error.value = null
searchParams.value = params
try {
// Transform frontend format to API format
const apiParams = {
budget: params.budget,
from_date: params.fromDate,
to_date: params.toDate,
departure_city: params.departureCity,
preferences: params.preferences,
max_travel_time: params.maxTravelTime || 24,
num_adults: params.numAdults,
num_children: params.numChildren,
with_pets: params.withPets || false
}
const data = await apiService.getRecommendations(apiParams)
// Transform API response to frontend format
recommendations.value = data.map(rec => ({
id: rec.id,
destination: rec.destination,
country: rec.country,
cost: rec.cost, // Per person cost
totalCost: rec.cost * getTotalTravelers(), // Calculate total cost
travelTimeHours: rec.travel_time_hours,
activities: rec.activities || [],
imageUrl: rec.image_url,
description: rec.description
}))
} catch (err) {
error.value = err.response?.data?.detail || 'Failed to get recommendations'
console.error('Search recommendations error:', err)
} finally {
isLoading.value = false
}
}
const clearRecommendations = () => {
recommendations.value = []
error.value = null
searchParams.value = null
}
// Helper functions
const getTotalTravelers = () => {
if (!searchParams.value) return 1
return (searchParams.value.numAdults || 0) + (searchParams.value.numChildren || 0)
}
return {
// State
recommendations,
isLoading,
error,
searchParams,
rateLimitInfo,
// Getters
hasRecommendations,
sortedRecommendations,
budgetFilteredRecommendations,
// Actions
searchRecommendations,
clearRecommendations
}
})
State Management Concepts: - Reactive State: Data that automatically updates the UI when changed - Computed Properties: Derived data that recalculates when dependencies change - Actions: Functions that modify state (usually async for API calls) - Data Transformation: Converting between API format and UI format
๐ Data Flow and API Integration¶
Complete Request-Response Cycle¶
-
User Interaction
-
Component Event Handler
const handleSearch = async () => { const searchParams = { budget: 1500, fromDate: "2025-07-15", toDate: "2025-07-22", numAdults: 2, numChildren: 1, preferences: ["beach", "culture"], departureCity: "Sydney, New South Wales, Australia" } // Call store action await recommendationsStore.searchRecommendations(searchParams) } -
Store Action (Data Transformation)
// Transform frontend format to API format const apiParams = { budget: 1500, // Same from_date: "2025-07-15", // Snake case for API to_date: "2025-07-22", // Snake case for API departure_city: "Sydney, New South Wales, Australia", preferences: ["beach", "culture"], num_adults: 2, // Snake case for API num_children: 1, // Snake case for API with_pets: false // Default value } -
API Service Call
-
Backend Processing (Invisible to frontend)
-
API Response Processing
// Transform API response back to frontend format recommendations.value = data.map(rec => ({ id: rec.id, destination: rec.destination, // "Gold Coast" country: rec.country, // "Australia" cost: rec.cost, // 250 (per person) totalCost: rec.cost * getTotalTravelers(), // 750 (for 3 travelers) travelTimeHours: rec.travel_time_hours, // 1.5 activities: rec.activities, // ["Surfing", "Theme parks"] imageUrl: rec.image_url // "https://images.pexels.com/..." })) -
UI Update (Automatic via Vue's reactivity)
Environment-Based Configuration¶
The frontend automatically connects to different backends based on environment:
// .env.development.local (for local development)
VITE_API_URL=http://localhost:8000/api/v1
// .env.development (for staging deployment)
VITE_API_URL=https://api-dev.macumbatravel.com/api/v1
// .env.production (for production deployment)
VITE_API_URL=https://api.macumbatravel.com/api/v1
How Environment Switching Works: 1. Vite reads the appropriate .env file based on build mode 2. API service uses import.meta.env.VITE_API_URL to get the correct URL 3. All HTTP requests automatically go to the right backend
๐จ Styling and UI Architecture¶
Tailwind CSS Integration¶
The application uses Tailwind CSS for utility-first styling:
<!-- Example component with Tailwind classes -->
<template>
<div class="max-w-4xl mx-auto p-6">
<!-- Card container -->
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<!-- Header with gradient background -->
<div class="bg-gradient-to-r from-blue-500 to-purple-600 text-white p-6">
<h2 class="text-2xl font-bold">Travel Recommendations</h2>
</div>
<!-- Content area -->
<div class="p-6 space-y-4">
<!-- Loading state -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span class="ml-3 text-gray-600">Finding destinations...</span>
</div>
<!-- Recommendations grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<RecommendationCard
v-for="rec in recommendations"
:key="rec.id"
:recommendation="rec"
class="transform hover:scale-105 transition-transform duration-200"
/>
</div>
</div>
</div>
</div>
</template>
Tailwind Benefits: - Rapid Development: Pre-built utility classes for common styles - Responsive Design: Built-in breakpoint classes (sm:, md:, lg:) - Consistent Design: Standardized spacing, colors, and typography - Small Bundle Size: Only includes used classes in production
๐ ๏ธ Development Setup and Build Process¶
Local Development Environment¶
# 1. Install dependencies
cd frontend
npm install
# 2. Set up environment for local development
# Create .env.development.local for localhost backend
echo "VITE_API_URL=http://localhost:8000/api/v1" > .env.development.local
# 3. Start development server
npm run dev
# The frontend will be available at:
# http://localhost:5173 (or next available port)
Build Process for Different Environments¶
# Build for development/staging
npm run build:dev
# Uses .env.development file (api-dev.macumbatravel.com)
# Build for production
npm run build:prod
# Uses .env.production file (api.macumbatravel.com)
# Preview production build locally
npm run preview
Firebase Deployment¶
# Deploy to staging
npm run deploy:dev
# 1. Builds with development environment
# 2. Deploys to Firebase hosting:development target
# Deploy to production
npm run deploy:prod
# 1. Builds with production environment
# 2. Deploys to Firebase hosting:production target
๐ Authentication Flow¶
Frontend Authentication Implementation¶
// store/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import apiService from '@/services/api'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref(null)
const token = ref(localStorage.getItem('authToken'))
const isLoading = ref(false)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
// Actions
const login = async (credentials) => {
isLoading.value = true
try {
const response = await apiService.login(credentials)
// Store token and user data
token.value = response.access_token
user.value = response.user
// Persist token to localStorage
localStorage.setItem('authToken', response.access_token)
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.detail || 'Login failed'
}
} finally {
isLoading.value = false
}
}
const logout = () => {
user.value = null
token.value = null
localStorage.removeItem('authToken')
}
return {
user,
token,
isLoading,
isAuthenticated,
login,
logout
}
})
๐ฑ Responsive Design and Mobile Support¶
Mobile-First Design Approach¶
<template>
<!-- Mobile-first grid system -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<!-- Cards stack on mobile, become grid on larger screens -->
</div>
<!-- Responsive navigation -->
<nav class="flex flex-col md:flex-row md:items-center justify-between p-4">
<!-- Mobile: stacked vertically, Desktop: horizontal -->
</nav>
<!-- Mobile-friendly form inputs -->
<input
type="range"
class="w-full h-2 bg-gray-200 rounded-lg slider:bg-blue-600"
:class="{ 'h-8': isMobile }"
>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
Touch-Friendly Interactions¶
- Larger touch targets: Buttons and links sized for finger taps
- Swipe gestures: Horizontal scrolling for recommendation cards
- Pull-to-refresh: Refresh recommendations on mobile
- Infinite scroll: Load more results as user scrolls
๐งช Testing and Debugging¶
Development Debugging Tools¶
// Enable Vue devtools in development
if (import.meta.env.DEV) {
window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = window.__VUE_DEVTOOLS_GLOBAL_HOOK__ || {}
}
// API request logging
apiClient.interceptors.request.use((config) => {
if (import.meta.env.DEV) {
console.log('API Request:', config.method?.toUpperCase(), config.url, config.data)
}
return config
})
apiClient.interceptors.response.use(
(response) => {
if (import.meta.env.DEV) {
console.log('API Response:', response.status, response.data)
}
return response
},
(error) => {
if (import.meta.env.DEV) {
console.error('API Error:', error.response?.status, error.response?.data)
}
return Promise.reject(error)
}
)
Common Development Commands¶
# Development server with hot reload
npm run dev
# Build and preview production bundle
npm run build:prod
npm run preview
# Check for JavaScript/TypeScript errors
npm run lint
# Clear node_modules and reinstall (if having issues)
rm -rf node_modules package-lock.json
npm install
๐ Performance Optimization¶
Code Splitting and Lazy Loading¶
// Lazy load components for better performance
const SearchForm = defineAsyncComponent(() => import('@/components/SearchForm.vue'))
const RecommendationCard = defineAsyncComponent(() => import('@/components/RecommendationCard.vue'))
// Lazy load pages with routing
const routes = [
{
path: '/search',
component: () => import('@/pages/SearchPage.vue')
},
{
path: '/dashboard',
component: () => import('@/pages/Dashboard.vue')
}
]
Image Optimization¶
<template>
<!-- Responsive images with loading states -->
<img
:src="optimizedImageUrl"
:alt="destination"
loading="lazy"
class="w-full h-48 object-cover"
@load="imageLoaded = true"
@error="handleImageError"
>
</template>
<script setup>
const optimizedImageUrl = computed(() => {
if (!props.recommendation.image_url) return defaultImage
// Add image optimization parameters
const url = new URL(props.recommendation.image_url)
url.searchParams.set('w', '400') // Width for card display
url.searchParams.set('q', '80') // Quality
return url.toString()
})
</script>
๐ Learning Resources for Junior Developers¶
Key Vue.js 3 Concepts to Master¶
- Composition API: Modern way to write Vue components
- Reactivity System: How Vue tracks and updates data changes
- Component Communication: Props, events, and provide/inject
- State Management: Using Pinia for global state
- Lifecycle Hooks: Component mounting, updating, and unmounting
Recommended Learning Path¶
- Start with Components: Understand how Vue components work
- Learn State Management: Practice with Pinia stores
- API Integration: Study the services/api.js file
- Styling: Get comfortable with Tailwind CSS utilities
- Build Features: Try adding new components or modifying existing ones
Development Best Practices¶
- Component Structure: Keep components small and focused
- Naming Conventions: Use PascalCase for components, camelCase for variables
- Error Handling: Always handle API errors gracefully
- Performance: Use computed properties for derived data
- Accessibility: Include proper ARIA labels and keyboard navigation
This frontend documentation provides a comprehensive understanding of how the Vue.js application works, from component architecture to API integration. The key is understanding how user interactions flow through components to state management to API calls and back to the UI.