37 KiB
HarborSmith Landing Page Conversion Plan
From Static HTML to Nuxt 3 + Tailwind CSS v4
Table of Contents
- Executive Summary
- Current State Analysis
- Target Architecture
- Implementation Strategy
- Technical Implementation
- Component Architecture
- Theme System
- Performance Optimizations
- Deployment Strategy
- Migration Checklist
Executive Summary
This document outlines the comprehensive plan for converting the HarborSmith landing page from static HTML/CSS/JS to a production-ready Nuxt 3 application with Tailwind CSS v4, while preserving the sophisticated design system with 4 theme variants, video hero, and premium animations.
Key Objectives
- Preserve 100% visual parity with existing mockups
- Implement modern development practices with TypeScript
- Achieve < 1 second page load time
- Maintain SEO optimization with SSG
- Deploy as standalone Docker container
Approach
Hybrid system using Tailwind for layout/utilities, CSS custom properties for dynamic theming, and Vue composables for interactivity.
Current State Analysis
Existing Mockup Structure
website-mockups/
├── HTML Files (18 total)
│ ├── index.html # Main landing page
│ ├── charter-booking-*.html # 4-step booking flow
│ ├── maintenance/*.html # Portal pages
│ └── portal-login.html # Authentication
├── CSS Files
│ ├── voyage-layout.css # Main layout styles
│ ├── themes.css # Theme variables
│ ├── animations.css # Keyframe animations
│ └── booking.css # Booking-specific styles
└── JavaScript
├── main.js # Core functionality
├── animations.js # Scroll effects
└── booking.js # Booking logic
Current Technologies
- Styling: Plain CSS with CSS variables
- Themes: 4 variants (Nautical, Coastal Dawn, Deep Sea, Monaco)
- Icons: Lucide via CDN
- Fonts: Inter + Playfair Display (Google Fonts)
- Video: Hero background with fallback
- Animations: CSS keyframes + JavaScript scroll effects
Design System Highlights
- Warm gradients and ocean-inspired colors
- Premium yacht aesthetic
- Smooth transitions and animations
- Mobile-responsive design
- Accessibility considerations (WCAG AA)
Target Architecture
Technology Stack
Frontend Framework: Nuxt 3 (v3.10+)
CSS Framework: Tailwind CSS v4 (beta)
UI Components: Nuxt UI v3
Animation: @vueuse/motion
State Management: Pinia
Icons: lucide-vue-next
Type Safety: TypeScript
Image Optimization: Nuxt Image
Deployment: Docker + nginx
Project Structure
harborsmith-web/
├── app.vue # Root component with theme provider
├── nuxt.config.ts # Nuxt configuration
├── tailwind.config.ts # Tailwind with custom properties
├── package.json # Dependencies
├── Dockerfile # Production build
├── nginx.conf # Production server config
├── components/
│ ├── layout/
│ │ ├── HarborNav.vue # Navigation with theme switcher
│ │ └── HarborFooter.vue # Footer component
│ ├── sections/
│ │ ├── HeroVideo.vue # Video hero section
│ │ ├── TrustBadges.vue # Social proof
│ │ ├── FleetGrid.vue # Vessel showcase
│ │ ├── Services.vue # Services overview
│ │ └── Testimonials.vue # Customer reviews
│ └── ui/
│ ├── ThemedButton.vue # Gradient CTAs
│ ├── VesselCard.vue # Fleet display cards
│ └── ThemeSwitcher.vue # Theme selector dropdown
├── composables/
│ ├── useTheme.ts # Theme switching logic
│ ├── useScrollEffects.ts # Scroll animations
│ └── useVideoLoader.ts # Video optimization
├── stores/
│ └── theme.ts # Pinia theme store
├── assets/
│ └── css/
│ ├── base.css # Global styles
│ ├── themes.css # CSS custom properties
│ └── animations.css # Keyframe definitions
└── public/
├── videos/ # Hero videos
├── images/ # Static images
└── fonts/ # Self-hosted fonts (optional)
Implementation Strategy
Phase 1: Foundation Setup (Day 1)
- Initialize Nuxt 3 project
- Configure Tailwind CSS v4
- Set up TypeScript
- Install core dependencies
- Create base project structure
Phase 2: Theme System (Day 2)
- Port CSS variables to Tailwind config
- Implement theme switcher with Pinia
- Create theme persistence logic
- Test all 4 theme variants
Phase 3: Component Development (Days 3-4)
- Convert navigation component
- Build video hero section
- Create reusable UI components
- Implement fleet showcase grid
- Add testimonial carousel
Phase 4: Animations & Polish (Day 5)
- Implement scroll animations
- Add hover effects
- Optimize transitions
- Fine-tune responsive design
Phase 5: Optimization & Deployment (Day 6)
- Image optimization
- Performance testing
- Docker configuration
- Production deployment
Technical Implementation
1. Project Initialization
# Create Nuxt 3 application
npx nuxi@latest init harborsmith-web --template minimal
cd harborsmith-web
# Install core dependencies
npm install -D @nuxtjs/tailwindcss@next @nuxt/ui@next
npm install lucide-vue-next @vueuse/motion @vueuse/nuxt
npm install pinia @pinia/nuxt
npm install @nuxt/image
# Development dependencies
npm install -D @types/node typescript sass
2. Nuxt Configuration
// nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
'@nuxt/ui',
'@nuxtjs/tailwindcss',
'@vueuse/nuxt',
'@pinia/nuxt',
'@nuxt/image',
],
css: [
'~/assets/css/base.css',
'~/assets/css/themes.css',
'~/assets/css/animations.css',
],
typescript: {
strict: true,
typeCheck: true,
},
nitro: {
preset: 'static',
prerender: {
routes: ['/'],
crawlLinks: true,
},
compressPublicAssets: true,
},
image: {
quality: 80,
format: ['webp', 'jpg'],
screens: {
xs: 320,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
xxl: 1536,
},
},
app: {
head: {
charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1',
title: 'HarborSmith - Premium Yacht Charters SF Bay',
meta: [
{ name: 'description', content: 'Experience luxury yacht charters in the San Francisco Bay. Premium vessels, professional maintenance, unforgettable adventures.' },
{ property: 'og:title', content: 'HarborSmith - Your Adventure Awaits' },
{ property: 'og:image', content: '/og-image.jpg' },
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Playfair+Display:wght@400;700;900&display=swap'
},
],
script: [
// Prevent theme flash
{
innerHTML: `
(function() {
const theme = localStorage.getItem('harborsmith-theme') || 'nautical';
document.documentElement.className = 'theme-' + theme;
})();
`,
type: 'text/javascript',
}
],
},
},
experimental: {
payloadExtraction: false,
renderJsonPayloads: false,
viewTransition: true,
},
})
3. Tailwind Configuration
// tailwind.config.ts
import type { Config } from 'tailwindcss'
export default <Config>{
content: [],
theme: {
extend: {
colors: {
// Dynamic theme colors using CSS variables
'primary': 'rgb(var(--primary-blue) / <alpha-value>)',
'accent': 'rgb(var(--warm-orange) / <alpha-value>)',
'accent-hover': 'rgb(var(--warm-amber) / <alpha-value>)',
'accent-light': 'rgb(var(--warm-yellow) / <alpha-value>)',
'cream': 'rgb(var(--soft-cream) / <alpha-value>)',
'text-primary': 'rgb(var(--text-dark) / <alpha-value>)',
'text-secondary': 'rgb(var(--text-light) / <alpha-value>)',
},
backgroundImage: {
'gradient-warm': 'var(--gradient-warm)',
'gradient-sunset': 'var(--gradient-sunset)',
'gradient-ocean': 'var(--gradient-ocean)',
},
fontFamily: {
'sans': ['Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
'display': ['Playfair Display', 'serif'],
},
spacing: {
'18': '4.5rem',
'88': '22rem',
'120': '30rem',
},
animation: {
'fade-up': 'fadeInUp 0.8s ease forwards',
'fade-up-delay': 'fadeInUp 0.8s ease 0.2s forwards',
'fade-up-delay-2': 'fadeInUp 0.8s ease 0.4s forwards',
'float': 'float 20s ease-in-out infinite',
'pulse-slow': 'pulse 3s ease-in-out infinite',
},
transitionTimingFunction: {
'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
},
},
},
plugins: [],
}
Component Architecture
Video Hero Component
<!-- components/sections/HeroVideo.vue -->
<template>
<section class="hero-voyage relative h-screen overflow-hidden">
<!-- Video Background -->
<div class="absolute inset-0 z-0">
<!-- Video Element -->
<ClientOnly>
<video
v-if="!videoError && videoLoaded"
ref="videoRef"
:key="videoUrl"
autoplay
loop
muted
playsinline
@loadeddata="onVideoLoaded"
@error="onVideoError"
class="w-full h-full object-cover scale-110"
>
<source :src="videoUrl" type="video/mp4">
</video>
</ClientOnly>
<!-- Fallback/Poster Image -->
<NuxtImg
:src="posterImage"
alt="San Francisco Bay"
class="absolute inset-0 w-full h-full object-cover"
:class="{ 'opacity-0': videoLoaded && !videoError }"
loading="eager"
quality="90"
/>
<!-- Gradient Overlays -->
<div class="absolute inset-0 bg-gradient-warm opacity-40 mix-blend-multiply" />
<div class="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/50" />
</div>
<!-- Hero Content -->
<div class="relative z-10 h-full flex items-center justify-center px-4">
<div class="text-center text-white max-w-4xl mx-auto">
<!-- Trust Badge -->
<div class="inline-flex items-center gap-2 bg-white/10 backdrop-blur-sm rounded-full px-4 py-2 mb-6">
<div class="flex">
<Icon v-for="i in 5" :key="i" name="lucide:star" class="w-4 h-4 text-yellow-400 fill-current" />
</div>
<span class="text-sm">500+ Premium Charters</span>
</div>
<!-- Headlines -->
<h1 class="font-display text-5xl md:text-6xl lg:text-7xl font-bold mb-6 leading-tight">
Your Adventure Awaits
</h1>
<p class="text-xl md:text-2xl mb-8 opacity-95 font-light">
Premium Yacht Charters in the San Francisco Bay
</p>
<!-- CTA Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<ThemedButton size="lg" variant="primary" @click="scrollToFleet">
Explore Our Fleet
<Icon name="lucide:arrow-right" class="ml-2" />
</ThemedButton>
<ThemedButton size="lg" variant="outline" @click="navigateToBooking">
View Availability
</ThemedButton>
</div>
</div>
</div>
<!-- Scroll Indicator -->
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<button @click="scrollToNext" class="text-white/70 hover:text-white transition-colors">
<Icon name="lucide:chevron-down" class="w-8 h-8" />
</button>
</div>
</section>
</template>
<script setup lang="ts">
const videoRef = ref<HTMLVideoElement>()
const videoLoaded = ref(false)
const videoError = ref(false)
// Video configuration
const videoUrl = 'https://videos.pexels.com/video-files/3571264/3571264-uhd_2560_1440_30fps.mp4'
const posterImage = '/images/hero-poster.jpg'
// Video handlers
const onVideoLoaded = () => {
videoLoaded.value = true
}
const onVideoError = () => {
videoError.value = true
console.warn('Video failed to load, using fallback image')
}
// Navigation
const scrollToFleet = () => {
document.getElementById('fleet')?.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
}
const scrollToNext = () => {
window.scrollBy({
top: window.innerHeight,
behavior: 'smooth'
})
}
const navigateToBooking = () => {
navigateTo('/booking')
}
// Lazy load video after initial render
onMounted(() => {
// Only load video on desktop or good connection
if (navigator.connection?.effectiveType === '4g' || !navigator.connection) {
videoRef.value?.load()
}
})
</script>
Navigation Component
<!-- components/layout/HarborNav.vue -->
<template>
<nav
:class="[
'fixed top-0 w-full z-50 transition-all duration-500',
{
'bg-white/95 backdrop-blur-md shadow-lg': scrolled,
'bg-gradient-to-b from-black/50 to-transparent': !scrolled
}
]"
>
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-20">
<!-- Logo -->
<NuxtLink to="/" class="flex items-center gap-3 group">
<img
src="/logo.jpg"
alt="HarborSmith"
class="h-12 w-12 rounded-lg shadow-md group-hover:shadow-xl transition-shadow"
>
<span
class="font-display text-2xl font-bold transition-colors"
:class="scrolled ? 'text-primary' : 'text-white'"
>
HarborSmith
</span>
</NuxtLink>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
<!-- Nav Links -->
<NuxtLink
v-for="link in navLinks"
:key="link.href"
:to="link.href"
:class="[
'font-medium transition-colors relative',
scrolled ? 'text-gray-700 hover:text-accent' : 'text-white hover:text-accent-light',
'after:absolute after:bottom-0 after:left-0 after:w-0 after:h-0.5',
'after:bg-accent after:transition-all hover:after:w-full'
]"
>
{{ link.label }}
</NuxtLink>
<!-- Theme Switcher -->
<ThemeSwitcher :scrolled="scrolled" />
<!-- CTA Button -->
<ThemedButton variant="primary" size="sm">
Book Charter
<Icon name="lucide:calendar" class="ml-2 w-4 h-4" />
</ThemedButton>
</div>
<!-- Mobile Menu Button -->
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden text-current"
>
<Icon
:name="mobileMenuOpen ? 'lucide:x' : 'lucide:menu'"
class="w-6 h-6"
/>
</button>
</div>
</div>
<!-- Mobile Menu -->
<Transition name="slide-down">
<div
v-if="mobileMenuOpen"
class="md:hidden bg-white/95 backdrop-blur-md border-t"
>
<div class="container mx-auto px-4 py-4 space-y-2">
<NuxtLink
v-for="link in navLinks"
:key="link.href"
:to="link.href"
@click="mobileMenuOpen = false"
class="block px-4 py-2 text-gray-700 hover:text-accent hover:bg-gray-50 rounded-lg transition-colors"
>
{{ link.label }}
</NuxtLink>
<div class="pt-2 border-t">
<ThemedButton variant="primary" size="sm" class="w-full">
Book Charter
</ThemedButton>
</div>
</div>
</div>
</Transition>
</nav>
</template>
<script setup lang="ts">
const scrolled = ref(false)
const mobileMenuOpen = ref(false)
const navLinks = [
{ label: 'Fleet', href: '#fleet' },
{ label: 'Services', href: '#services' },
{ label: 'Experiences', href: '#experiences' },
{ label: 'About', href: '/about' },
{ label: 'Contact', href: '/contact' },
]
// Handle scroll behavior
onMounted(() => {
const handleScroll = () => {
scrolled.value = window.scrollY > 50
}
window.addEventListener('scroll', handleScroll, { passive: true })
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
})
</script>
<style scoped>
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
transform: translateY(-100%);
opacity: 0;
}
</style>
Theme System
CSS Custom Properties
/* assets/css/themes.css */
:root {
/* Default Theme: Nautical */
--primary-blue: 0 31 63; /* #001f3f */
--warm-orange: 220 20 60; /* #dc143c */
--warm-amber: 185 28 60; /* #b91c3c */
--warm-yellow: 239 68 68; /* #ef4444 */
--soft-cream: 240 244 248; /* #f0f4f8 */
--text-dark: 10 22 40; /* #0a1628 */
--text-light: 74 85 104; /* #4a5568 */
/* Gradients */
--gradient-warm: linear-gradient(135deg, #dc143c 0%, #ef4444 100%);
--gradient-sunset: linear-gradient(135deg, #b91c3c 0%, #dc143c 50%, #ef4444 100%);
--gradient-ocean: linear-gradient(135deg, #001f3f 0%, #003366 100%);
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
}
/* Coastal Dawn Theme */
.theme-coastal-dawn {
--primary-blue: 169 180 194; /* #A9B4C2 */
--warm-orange: 212 175 55; /* #D4AF37 */
--warm-amber: 201 169 97; /* #C9A961 */
--warm-yellow: 230 208 136; /* #E6D088 */
--soft-cream: 248 247 244; /* #F8F7F4 */
--text-dark: 51 55 69; /* #333745 */
--text-light: 107 114 128; /* #6B7280 */
--gradient-warm: linear-gradient(135deg, #D4AF37 0%, #E6D088 100%);
--gradient-sunset: linear-gradient(135deg, #C9A961 0%, #D4AF37 50%, #E6D088 100%);
--gradient-ocean: linear-gradient(135deg, #A9B4C2 0%, #C5D3E0 100%);
}
/* Deep Sea Theme */
.theme-deep-sea {
--primary-blue: 30 32 34; /* #1E2022 */
--warm-orange: 0 191 255; /* #00BFFF */
--warm-amber: 30 144 255; /* #1E90FF */
--warm-yellow: 65 105 225; /* #4169E1 */
--soft-cream: 42 45 48; /* #2A2D30 */
--text-dark: 229 228 226; /* #E5E4E2 */
--text-light: 192 192 192; /* #C0C0C0 */
--gradient-warm: linear-gradient(135deg, #00BFFF 0%, #4169E1 100%);
--gradient-sunset: linear-gradient(135deg, #1E90FF 0%, #00BFFF 50%, #4169E1 100%);
--gradient-ocean: linear-gradient(135deg, #1E2022 0%, #2A2D30 100%);
}
/* Monaco White Theme */
.theme-monaco {
--primary-blue: 44 62 80; /* #2C3E50 */
--warm-orange: 231 76 60; /* #E74C3C */
--warm-amber: 230 126 34; /* #E67E22 */
--warm-yellow: 243 156 18; /* #F39C12 */
--soft-cream: 248 249 250; /* #F8F9FA */
--text-dark: 44 62 80; /* #2C3E50 */
--text-light: 127 140 141; /* #7F8C8D */
--gradient-warm: linear-gradient(135deg, #E74C3C 0%, #F39C12 100%);
--gradient-sunset: linear-gradient(135deg, #E67E22 0%, #E74C3C 50%, #F39C12 100%);
--gradient-ocean: linear-gradient(135deg, #2C3E50 0%, #34495E 100%);
}
Theme Store (Pinia)
// stores/theme.ts
import { defineStore } from 'pinia'
export type ThemeVariant = 'nautical' | 'coastal-dawn' | 'deep-sea' | 'monaco'
interface ThemeOption {
id: ThemeVariant
name: string
description: string
colors: {
primary: string
accent: string
background: string
}
}
export const useThemeStore = defineStore('theme', {
state: () => ({
currentTheme: 'nautical' as ThemeVariant,
themes: [
{
id: 'nautical',
name: 'Classic Nautical',
description: 'Deep navy and crimson',
colors: { primary: '#001f3f', accent: '#dc143c', background: '#ffffff' }
},
{
id: 'coastal-dawn',
name: 'Coastal Dawn',
description: 'Soft blue and gold',
colors: { primary: '#A9B4C2', accent: '#D4AF37', background: '#F8F7F4' }
},
{
id: 'deep-sea',
name: 'Deep Sea',
description: 'Dark mode with electric blue',
colors: { primary: '#1E2022', accent: '#00BFFF', background: '#2A2D30' }
},
{
id: 'monaco',
name: 'Monaco White',
description: 'Clean and sophisticated',
colors: { primary: '#2C3E50', accent: '#E74C3C', background: '#F8F9FA' }
}
] as ThemeOption[]
}),
getters: {
activeTheme: (state) => state.themes.find(t => t.id === state.currentTheme),
},
actions: {
setTheme(theme: ThemeVariant) {
this.currentTheme = theme
// Apply theme class to HTML element
if (process.client) {
document.documentElement.className = `theme-${theme}`
// Persist to localStorage
localStorage.setItem('harborsmith-theme', theme)
// Also set cookie for SSR
const cookie = useCookie('harborsmith-theme', {
httpOnly: false,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 365 // 1 year
})
cookie.value = theme
}
},
loadTheme() {
if (process.client) {
const saved = localStorage.getItem('harborsmith-theme') ||
useCookie('harborsmith-theme').value
if (saved && this.themes.find(t => t.id === saved)) {
this.setTheme(saved as ThemeVariant)
}
}
}
}
})
Theme Switcher Component
<!-- components/ui/ThemeSwitcher.vue -->
<template>
<UDropdown
:items="themeMenuItems"
:popper="{ placement: 'bottom-end' }"
:ui="{ item: { disabled: 'cursor-default opacity-60' } }"
>
<UButton
:color="scrolled ? 'gray' : 'white'"
variant="ghost"
size="sm"
:icon="currentThemeIcon"
:class="!scrolled && 'hover:bg-white/10'"
>
<template #trailing>
<Icon name="lucide:chevron-down" class="w-4 h-4 ml-1" />
</template>
</UButton>
<template #item="{ item }">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<div class="flex gap-1">
<span
v-for="color in item.colors"
:key="color"
:style="{ backgroundColor: color }"
class="w-3 h-3 rounded-full border border-gray-200"
/>
</div>
<span>{{ item.label }}</span>
</div>
<Icon
v-if="item.active"
name="lucide:check"
class="w-4 h-4 text-accent"
/>
</div>
</template>
</UDropdown>
</template>
<script setup lang="ts">
import type { DropdownItem } from '#ui/types'
const props = defineProps<{
scrolled?: boolean
}>()
const themeStore = useThemeStore()
const currentThemeIcon = computed(() => {
const icons = {
'nautical': 'lucide:anchor',
'coastal-dawn': 'lucide:sunrise',
'deep-sea': 'lucide:waves',
'monaco': 'lucide:sun'
}
return icons[themeStore.currentTheme]
})
const themeMenuItems = computed<DropdownItem[][]>(() => [[
{
label: 'Classic Nautical',
colors: ['#001f3f', '#dc143c', '#ffffff'],
active: themeStore.currentTheme === 'nautical',
click: () => themeStore.setTheme('nautical')
},
{
label: 'Coastal Dawn',
colors: ['#A9B4C2', '#D4AF37', '#F8F7F4'],
active: themeStore.currentTheme === 'coastal-dawn',
click: () => themeStore.setTheme('coastal-dawn')
},
{
label: 'Deep Sea',
colors: ['#1E2022', '#00BFFF', '#2A2D30'],
active: themeStore.currentTheme === 'deep-sea',
click: () => themeStore.setTheme('deep-sea')
},
{
label: 'Monaco White',
colors: ['#2C3E50', '#E74C3C', '#F8F9FA'],
active: themeStore.currentTheme === 'monaco',
click: () => themeStore.setTheme('monaco')
}
]])
</script>
Performance Optimizations
1. Image Optimization Strategy
<!-- components/ui/OptimizedImage.vue -->
<template>
<div class="relative overflow-hidden" :class="containerClass">
<!-- Blur placeholder -->
<div
v-if="!loaded"
class="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 animate-pulse"
/>
<!-- Main image -->
<NuxtImg
:src="src"
:alt="alt"
:loading="loading"
:quality="quality"
:format="format"
:sizes="sizes"
@load="onLoad"
class="w-full h-full object-cover transition-opacity duration-500"
:class="{ 'opacity-0': !loaded }"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
src: string
alt: string
loading?: 'lazy' | 'eager'
quality?: number
format?: string
sizes?: string
containerClass?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: 'lazy',
quality: 80,
format: 'webp',
sizes: '100vw'
})
const loaded = ref(false)
const onLoad = () => loaded.value = true
</script>
2. Scroll Animation Composable
// composables/useScrollEffects.ts
import { useMotion } from '@vueuse/motion'
import { ref, onMounted, onUnmounted } from 'vue'
export const useScrollEffects = () => {
const elements = ref<HTMLElement[]>([])
const observer = ref<IntersectionObserver>()
const fadeInUp = {
initial: {
opacity: 0,
y: 50
},
enter: {
opacity: 1,
y: 0,
transition: {
duration: 800,
ease: [0.4, 0, 0.2, 1]
}
}
}
const fadeInScale = {
initial: {
opacity: 0,
scale: 0.95
},
enter: {
opacity: 1,
scale: 1,
transition: {
duration: 600,
ease: [0.4, 0, 0.2, 1]
}
}
}
const initScrollAnimations = () => {
observer.value = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-visible')
observer.value?.unobserve(entry.target)
}
})
},
{
threshold: 0.1,
rootMargin: '50px'
}
)
// Find all elements with data-animate attribute
elements.value = Array.from(
document.querySelectorAll('[data-animate]')
) as HTMLElement[]
elements.value.forEach(el => observer.value?.observe(el))
}
onMounted(() => initScrollAnimations())
onUnmounted(() => observer.value?.disconnect())
return {
fadeInUp,
fadeInScale
}
}
3. Performance Monitoring
// plugins/performance.client.ts
export default defineNuxtPlugin(() => {
if (process.dev) return
// Web Vitals monitoring
onNuxtReady(() => {
// First Contentful Paint
const paintObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
console.log('FCP:', entry.startTime)
// Send to analytics
}
}
})
paintObserver.observe({ entryTypes: ['paint'] })
// Largest Contentful Paint
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
console.log('LCP:', lastEntry.startTime)
// Send to analytics
})
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
// Cumulative Layout Shift
let clsValue = 0
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
console.log('CLS:', clsValue)
}
}
})
clsObserver.observe({ entryTypes: ['layout-shift'] })
})
})
Deployment Strategy
Docker Configuration
# Dockerfile
# Build stage
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build application
RUN npm run generate
# Production stage
FROM nginx:alpine
# Install nginx-module-brotli for better compression
RUN apk add --no-cache nginx-mod-http-brotli
# Copy built static files
COPY --from=builder /app/.output/public /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy custom nginx site config
COPY default.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
nginx Configuration
# nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml application/atom+xml image/svg+xml
text/x-js text/x-cross-domain-policy application/x-font-ttf
application/x-font-opentype application/vnd.ms-fontobject
image/x-icon;
# Brotli compression
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml application/atom+xml image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}
# default.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' https:; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; media-src 'self' https://videos.pexels.com;" always;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache HTML (shorter duration)
location ~* \.(html)$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK";
}
}
Docker Compose (Development)
# docker-compose.yml
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
container_name: harborsmith-web
ports:
- "3001:80"
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
networks:
- harborsmith
networks:
harborsmith:
driver: bridge
Migration Checklist
Pre-Migration
- Backup current mockups
- Document all custom styles
- List all JavaScript functionality
- Catalog all images and videos
- Note responsive breakpoints
Phase 1: Setup
- Initialize Nuxt 3 project
- Configure TypeScript
- Install dependencies
- Setup Tailwind CSS v4
- Configure Nuxt modules
Phase 2: Theme System
- Port CSS variables
- Create theme files
- Implement Pinia store
- Build theme switcher
- Test theme persistence
Phase 3: Components
- Convert navigation
- Build video hero
- Create button components
- Port trust badges
- Convert fleet grid
- Build testimonials
- Add footer
Phase 4: Content
- Migrate images
- Optimize videos
- Add meta tags
- Setup analytics
- Configure sitemap
Phase 5: Optimization
- Run Lighthouse audit
- Optimize bundle size
- Test all viewports
- Check accessibility
- Validate SEO
Phase 6: Deployment
- Build Docker image
- Configure nginx
- Setup CI/CD
- Deploy to staging
- Production deployment
Post-Migration
- Performance monitoring
- Error tracking
- User testing
- Documentation
- Team training
Testing Strategy
Unit Tests (Vitest)
// components/ui/ThemedButton.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ThemedButton from './ThemedButton.vue'
describe('ThemedButton', () => {
it('renders with primary variant', () => {
const wrapper = mount(ThemedButton, {
props: {
variant: 'primary'
}
})
expect(wrapper.classes()).toContain('bg-gradient-warm')
})
it('emits click event', async () => {
const wrapper = mount(ThemedButton)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
})
E2E Tests (Playwright)
// tests/e2e/landing.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Landing Page', () => {
test('loads with video hero', async ({ page }) => {
await page.goto('/')
// Check hero section
const hero = await page.locator('.hero-voyage')
await expect(hero).toBeVisible()
// Check video or fallback image
const video = page.locator('video')
const hasVideo = await video.count() > 0
if (hasVideo) {
await expect(video).toHaveAttribute('autoplay')
} else {
const fallbackImage = page.locator('.hero-voyage img')
await expect(fallbackImage).toBeVisible()
}
})
test('theme switcher works', async ({ page }) => {
await page.goto('/')
// Open theme menu
await page.click('[data-testid="theme-switcher"]')
// Select coastal dawn theme
await page.click('text=Coastal Dawn')
// Check theme applied
const html = page.locator('html')
await expect(html).toHaveClass('theme-coastal-dawn')
// Check persistence
await page.reload()
await expect(html).toHaveClass('theme-coastal-dawn')
})
})
Success Metrics
Performance Targets
- Lighthouse Score: > 95 (all categories)
- First Contentful Paint: < 1.2s
- Largest Contentful Paint: < 2.5s
- Time to Interactive: < 3.5s
- Cumulative Layout Shift: < 0.1
- JavaScript Bundle: < 200KB (initial)
- CSS Bundle: < 50KB
Quality Metrics
- TypeScript Coverage: 100%
- Test Coverage: > 80%
- Accessibility: WCAG AA compliant
- SEO Score: 100
- Mobile Score: 100
Business Metrics
- Conversion Rate: Track booking CTAs
- Engagement: Time on site, scroll depth
- Theme Usage: Analytics on theme preferences
- Performance: Real user monitoring
Maintenance & Updates
Regular Tasks
- Update dependencies monthly
- Review performance metrics weekly
- Monitor error logs daily
- Backup before major changes
- Document all customizations
Optimization Opportunities
- Implement service worker for offline
- Add PWA capabilities
- Integrate with CDN
- Implement A/B testing
- Add personalization features
Conclusion
This comprehensive plan provides a clear roadmap for converting the HarborSmith landing page from static HTML to a modern Nuxt 3 application. The approach preserves the sophisticated design while implementing best practices for performance, maintainability, and developer experience.
The hybrid strategy of using Tailwind for utilities with CSS custom properties for theming ensures flexibility while maintaining the exact visual design. The component-based architecture with TypeScript provides type safety and reusability for future development.
Following this plan will result in a production-ready landing page that loads in under 1 second, maintains perfect visual parity with the mockups, and provides a solid foundation for the entire HarborSmith web platform.