Overview#
Wander is a website for a small-group tour company, built for Saltwind Journeys, a fictional travel company by Flagstaff's rail depot that runs trips of twelve travelers or fewer in Morocco, Peru, Norway, the Aegean, Japan and Tanzania. It has a trip finder, a filterable tours page, tour pages with a day-by-day itinerary, a route map, departure dates and a live-priced booking inquiry, a destinations page with a seasonality chart, a journal with an article, an about page and a contact page. This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site. This guide covers version 1.0.0. See it live at freeframe.dev/templates/wander.
Quick start#
You need Node.js 24 or newer and pnpm.
pnpm install
pnpm devOpen http://localhost:3000. Edits to files in app/ reload the page.
| Script | What it does |
|---|---|
pnpm dev |
Development server |
pnpm generate |
Static site in .output/public/ |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint |
pnpm typecheck |
Type check with vue-tsc |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with postcard art, trip finder, destination ticker, featured tours (from the data), why travel with us, destinations grid, traveler postcards, journal teaser, newsletter |
/tours |
app/pages/tours.vue |
Region, style, duration, month and budget filters with live counts, sort, filter chips, result count, empty state, URL parameters |
/tour |
app/pages/tour.vue |
One tour (/tour?id=atlas-sahara): key facts, highlights, itinerary accordion, route map, included and excluded, departure dates table, booking widget, inquiry form, related tours, mobile price bar |
/destinations |
app/pages/destinations.vue |
Seasonality table (regions × months), a section per region with highlights and a best-time chart |
/journal |
app/pages/journal.vue |
Featured story, topic filter (/journal?topic=food), story cards |
/article |
app/pages/article.vue |
Reading progress bar, table of contents, drop cap, pull quote, packing checklist, related tours |
/about |
app/pages/about.vue |
Numbers, timeline, guides with illustrated portraits, responsible travel commitments, "where your money goes" bar |
/contact |
app/pages/contact.vue |
Contact form with trip prefill (/contact?tour=kumano-kodo), office details, emergency line, FAQ (/contact#faq) |
| 404 | app/error.vue |
Compass illustration and links back into the site (generated as 404.html) |
Project structure#
wander/
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
app/
app.vue layout and page; pages restart when a link opens the page already showing
router.options.ts query strings written like the HTML version (?region=morocco,japan)
error.vue 404 page
layouts/default.vue skip link, announcement bar, header, footer, toast, back to top
pages/ one file per page (index.vue, tours.vue, tour.vue ...)
components/
SiteHeader.vue header, main menu and mobile menu
SiteFooter.vue footer
TourCard.vue trip card used on every page
SeasonChart.vue one region's best-time chart
NewsletterForm.vue home page sign-up
LandscapeArt.vue illustrated landscapes
RouteMap.vue, AvatarArt.vue, PostMark.vue, LogoMark.vue other SVG artwork
ReadProgress.vue, BackToTop.vue, SiteToast.vue
ArrowRight.vue, StarRating.vue, TickIcon.vue ... small icons
composables/
useValidatedForm.ts shared form validation and success state
useToast.ts the toast message
usePageSeo.ts title, description, canonical and Open Graph tags
useCurrentPage.ts aria-current for the menus
data/
tours.ts regions, styles, durations, settings and the 12 tours
content.ts destinations copy, reviews, journal posts, guides, timeline, money split
site.ts phone, email and the menu
utils/
tours.ts lookups, departures and prices, filters and URL state
art.ts the drawings: landscapes (SKIES, LAYERS), route maps and avatars
validate.ts form field rules and messages
ref.ts short references for confirmations
plugins/
reveal.client.ts scroll reveals
same-page.client.ts links to the page already showing
stable-build.server.ts reproducible builds
assets/css/main.css Tailwind CSS: fonts, brand tokens (@theme) and component classes
assets/fonts/ woff2 font files and their license (OFL.txt)
public/img/ favicon.svg, og-image.png (social image) and its og-image.svg source
template.json gallery metadata (used by freeframe.dev)
README.md, DOCS.md, CHANGELOG.mdCustomizing#
Brand and colors#
The design tokens are in the @theme block of app/assets/css/main.css. Every utility such as bg-clay
or text-muted comes from them.
| Token | Default | Used for |
|---|---|---|
--color-paper |
#fbf5ea |
Page background |
--color-postcard |
#fffaf1 |
Cards, form fields, stamps |
--color-sand / --color-dune |
#f4e6cc / #ecd5ad |
Panels, chart cells |
--color-line |
#e2cfae |
Borders and dashed rules |
--color-ink |
#23180f |
Text, outlines and the offset card shadows |
--color-muted |
#66523f |
Secondary text |
--color-clay / --color-clay-dark |
#b0431d / #8c3214 |
Brand color: primary buttons, links, eyebrows |
--color-sun / --color-sun-deep |
#f4a62a / #b86e00 |
Stamps, ticker, highlights / stars and icons |
--color-sea / --color-deep |
#0e5a5f / #0a3b3f |
Dark bands, "best" season, footer |
--color-lagoon, --color-coral |
#7cc4b8, #ef7b5a |
Decoration and charts |
--color-foam |
#fff9ef |
Text on dark backgrounds |
To change the brand color, edit the tokens:
@theme {
--color-clay: #2f6b4f;
--color-clay-dark: #214d39;
}A few colors are written directly into SVG: the landscape palettes (SKIES and LAYERS) and the route
map in app/utils/art.ts, the logo in app/components/LogoMark.vue and public/img/favicon.svg. The
browser theme color (theme-color) is in nuxt.config.ts. Search for the hex value to change them too.
Typography#
Three Google Fonts, self-hosted from the woff2 files in app/assets/fonts/ (SIL Open Font License, see
app/assets/fonts/OFL.txt) and declared with @font-face at the top of main.css: Caprasimo for
headings (font-display), Outfit for body text (font-sans) and Kalam for handwritten postcard
notes (font-hand, the .hand class). Each family has a latin and a latin-ext file, so names such as
Hongū and Kaş render in the same font. The tokens name the families:
--font-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Caprasimo", "Cooper Black", ui-serif, Georgia, serif;
--font-hand: "Kalam", "Segoe Print", "Bradley Hand", cursive;To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the names in
the tokens. Caprasimo has a single weight, so headings use font-normal. The SVG text in the route map,
postmark and 404 compass names Outfit or Kalam directly.
Content#
Trip data is in app/data/tours.ts; everything the tours page, tour pages, filters, booking widget and
contact form show is built from it.
| Export | What it holds |
|---|---|
REGIONS |
Regions: id, name, label, the scene and time of their artwork and a season array (12 values, 0 avoid to 3 best) |
STYLES |
Trip styles (hiking, culture, wildlife, coast) and their labels |
DURATIONS |
Duration filter buckets with min and max days |
TOURS |
The trips, one object each (type Tour) |
INCLUDED, EXCLUDED |
Lines shown on every tour page, after or before each tour's own includes / excludes |
PRICE_MIN, PRICE_MAX |
Range of the budget slider on /tours |
PEAK_SUPPLEMENT |
Added per person to departures in a region's best months |
DEPOSIT |
Deposit per person shown in the booking widget |
MAX_TRAVELLERS |
Most travelers in one inquiry |
BOOKING_CUTOFF |
Departures sooner than this many days from today are not offered |
DEPARTURE_DAYS |
Days of the month after which each departure's Saturday is chosen |
To add a tour, add an object to TOURS:
{
id: "dolomites-huts", name: "Dolomites Hut to Hut", region: "aegean", country: "Italy", style: "hiking",
days: 8, price: 3090, single: 380, rating: 4.8, reviews: 0, group: 12, level: "Moderate", badge: "New",
months: [6, 7, 8, 9], scene: "mountains", time: "day",
summary: "Seven days between mountain huts, with your bags sent ahead.",
highlights: ["Tre Cime at sunrise", "Nights in family-run huts", "Via ferrata taster"],
includes: ["Hut-to-hut luggage transfers"],
excludes: [],
stops: [[20, 70, "Cortina"], [45, 45, "Tre Cime"], [75, 30, "Sesto"]],
itinerary: [
{ d: "1", t: "Cortina", x: "Meet the group and walk to the first hut.", stay: "Rifugio Lago" },
{ d: "2–7", t: "Hut to hut", x: "Six days on high trails.", stay: "Mountain huts" },
{ d: "8", t: "Departure", x: "Transfers to Venice airport.", stay: "" },
],
},idis used in URLs (/tour?id=dolomites-huts,/contact?tour=dolomites-huts): lower case, no spaces.regionmust be theidof aREGIONSentry andstyletheidof aSTYLESentry. TypeScript checks both. The tour then appears under those filters, in the trip finder and in the contact form's trip list.- To add a region, add its id to the
RegionIdtype and an entry toREGIONS; the filters, trip finder, seasonality table and home page grid pick it up. Add a matching entry toDESTINATIONSinapp/data/content.tsfor its section on/destinations(and the home grid's "best" months). The home page grid's tile sizes (TILESinapp/pages/index.vue) are laid out for six regions. priceis the lowest price per person andsinglethe own-room supplement per person, in US dollars.months(1–12) are the months the tour departs; they drive the month filter and the departure dates.stopsare route map points:[x, y, name], withxandyas percentages of the map's width and height.itineraryentries are accordion rows:dis the day or range,tthe title,xthe text andstaythe overnight (leave empty to hide it).featured: 1to4puts the tour on the home page in that order and first in "Recommended" sort;badgeadds a label on the card.
Other content is in app/data/content.ts: DESTINATIONS (region copy, highlights and "best" months),
REVIEWS, POSTS and TOPICS (journal), HOME_POSTS, GUIDES, TIMELINE and MONEY_SPLIT. Page copy
that appears once is written in the page files in app/pages/. The phone number, email and menu are in
app/data/site.ts.
Images and artwork#
There are no photos. Every landscape is an SVG drawn by LandscapeArt (app/components/LandscapeArt.vue)
from artInner() and the colors in app/utils/art.ts. Scenes are dunes, mountains, fjord (with
northern lights at night), coast, fuji and savannah; times are dawn, day, dusk and night. Put
it inside an .art-frame box, which sets the size; pass label to give it alt text (without it the art is
decorative):
<div class="art-frame aspect-[4/3] rounded-[2rem]"><LandscapeArt scene="coast" time="dusk" label="Sunset over Milos" /></div>The art is drawn at 3:2 and cropped to fit its frame (preserveAspectRatio="xMidYMid slice"), so any
aspect ratio works. To use photos, put an <img class="h-full w-full object-cover"> inside the same
.art-frame box instead (cards use 4:3, region sections 4:3, the tour hero 21:9 on desktop), with the file
in public/img/ and the base path written out: :src="`${$config.app.baseURL}img/milos.jpg`". The
.art-frame class adds a subtle paper grain over whatever is inside.
Other artwork: RouteMap draws the tour page map from stops, AvatarArt draws portraits from a
"skin,hair,style,background" string (style is short, long, bun, curly or bald), PostMark is
the round company stamp and LogoMark the logo.
The social image is public/img/og-image.png (1200×630, drawn from og-image.svg); replace it with a PNG
of the same size. The favicon is public/img/favicon.svg.
Navigation, header and footer#
The menu items are NAV in app/data/site.ts, used by the desktop menu and the mobile menu (which adds
Home). SiteHeader marks the current page with aria-current="page" (useCurrentPage()). The footer
columns are TRIPS and COMPANY in app/components/SiteFooter.vue. The announcement bar is in
app/layouts/default.vue. To add a page, create app/pages/<route>.vue with its own
<main id="main" tabindex="-1" class="flex-1 focus:outline-none">, call usePageSeo(), add the route to
routes in nuxt.config.ts and add it to NAV.
Features#
Trip finder#
In app/pages/index.vue. The form #trip-finder on the home page has four selects (region, month,
style, duration). On submit it opens /tours with only the chosen values as URL parameters, for example
/tours?region=japan&month=4. Without JavaScript the form still submits to /tours with GET.
Tour filters#
app/pages/tours.vue with the logic in app/utils/tours.ts. Checkboxes named region, style and
duration (several in one group combine with OR, groups combine with AND), a month select and a max
budget slider. #sort orders by recommended, price, length or rating (SORTS). Every change updates the
grid, the live count #result-count, the removable chips in #active-filters and the small count next to
each option (how many trips you'd see if you checked it), and writes the state to the URL with
history.replaceState:
/tours?region=morocco,japan&style=hiking&duration=medium&month=5&max=4000&sort=price-ascreadState() reads the URL: names and values are case-insensitive, lists can be comma-separated or
repeated, and unknown values are ignored; writeState() builds the query string. When nothing matches,
#tour-empty appears with a reset button (clearing keeps the sort). Removing a chip moves focus to the next
chip or the result count. On phones the filters are behind #filters-toggle (aria-expanded), with a
badge showing how many are active. When a month is chosen, card links carry it (/tour?id=…&month=5) so the
tour page preselects a departure in that month.
The generated HTML shows all tours; the filters in the URL apply once the page has loaded. Links to the
tours page with other filters (the footer's "Hiking & trekking", say) open it afresh at the top, as a page
load would: see app/plugins/same-page.client.ts.
Tour page#
app/pages/tour.vue. ?id= picks the tour (case-insensitive); a missing or unknown id shows the first
tour, which is also what the generated HTML shows until the page has loaded. The tab title and meta
description follow the tour shown. The itinerary is a list of buttons with aria-expanded and
aria-controls; the first day starts open and the "Expand all days" button opens or closes every day.
Departures and prices#
departures(tour, today) in app/utils/tours.ts works out the next eight departures: in each month listed
in tour.months, a departure leaves on the first Saturday on or after each day in DEPARTURE_DAYS,
skipping any sooner than BOOKING_CUTOFF days from today. They are worked out in the browser (with today's
date), so a site generated months ago still shows upcoming dates. Months rated 3 ("best") in the region's
season cost PEAK_SUPPLEMENT more and are marked "Peak". Places left are demo data from a stable hash of
the tour and date, so they are the same on every visit: 0 shows "Full" (and is disabled in the widget), 1–3
"Only N left", and four or more travelers booked shows "Guaranteed". Replace departures() with your real
dates if you have them; the table and widget only need objects of the type Departure
({ start, end, month, price, peak, left, guaranteed }).
Booking widget and inquiry#
The #booking-widget card has a departure select (#dep-select), a travelers stepper (1 to
MAX_TRAVELLERS, never more than the places left) and a room choice. quote(tour, departure, travelers, room) returns the price per person, the own-room supplement, the total and the deposit, shown live in an
aria-live list. "Choose" buttons in the dates table select a departure, scroll to the widget and show a
toast (useToast()). The current choice is written to the summary above the inquiry form and to its hidden
fields (tour, departure, travellers, room, total), so a connected form receives everything. On
phones a fixed bar at the bottom shows the total and links to the widget. ?date=YYYY-MM-DD, ?month=5 and
?travellers=3 preselect choices.
Seasonality#
app/pages/destinations.vue renders the #season-table table (a row per region, a cell per month, each
with a screen-reader label such as "April: Best") and SeasonChart (app/components/SeasonChart.vue) a
12-bar chart with the list of best months for one region, all from REGIONS[].season. The table scrolls
sideways on small screens with the region names kept in view.
Journal filter#
app/pages/journal.vue. Buttons with data-topic-btn and aria-pressed show only the stories with that
topic; #journal-status announces the result and the URL keeps ?topic=. To add a story, add an entry to
POSTS in app/data/content.ts. Every story links to the one demo article, /article.
Scroll reveal, ticker, progress and back to top#
Elements with .reveal fade in when scrolled into view (app/plugins/reveal.client.ts, including content
rendered later; everything above is revealed too). Don't put .reveal on an element whose class changes
with a :class binding, or Vue drops the is-visible class the plugin adds. The home page ticker scrolls
with CSS (.ticker) and pauses on hover; the hero stamp floats (.float). On /article, ReadProgress
grows with how far you have read through [data-article]. Reveals, the ticker and the float stop for
visitors who prefer reduced motion. To remove the back-to-top button, delete <BackToTop /> from
app/layouts/default.vue and the .back-to-top styles in main.css.
Forms#
All three forms use useValidatedForm() (app/composables/useValidatedForm.ts) with the rules in
app/utils/validate.ts: errors appear on blur and on submit, update as you type once shown, the first
invalid field is focused, and each error is in the element <field id>-error, linked with
aria-describedby. The fields are plain inputs; the form's submit, focusout, input and change
events call the composable.
| Attribute | Rule |
|---|---|
required |
Must not be empty (checkboxes must be checked; message from data-msg) |
type="email" |
Must look like an email address |
type="tel" |
Digits, spaces, +, (, ) and - only, 7 to 20 characters |
minlength |
Minimum length |
data-label |
Words used in the "Enter …" message |
| Form | Where | Success state |
|---|---|---|
Newsletter (#newsletter-form) |
app/components/NewsletterForm.vue |
#newsletter-success |
Inquiry (#enquiry-form) |
app/pages/tour.vue (/tour#enquire) |
#enquiry-success, repeating the booking summary |
Contact (#contact-form) |
app/pages/contact.vue |
#contact-success |
The forms don't send anything until you connect them, although their success messages read as if they
had: the newsletter one asks the reader to look out for a confirmation email, the contact one promises a
reply within one business day and the inquiry one a reply from a travel expert within one business day, as
the pages already say. The contact and inquiry messages show a short reference (SJ- and four characters)
made by makeRef() (app/utils/ref.ts) in onValid(); show your form service's own reference instead if
it returns one. To collect submissions, post the form in onValid() in useValidatedForm.ts before
showing the success state, for example to Formspree:
async function onValid(el: HTMLFormElement) {
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(el),
headers: { Accept: "application/json" },
});
if (!res.ok) return useToast().show("Something went wrong. Please email us.");
// ...then the existing lines: reference.value = makeRef("SJ"), sent.value = first, el.reset()
}The submit function calls onValid(el); it can stay as it is, since an async onValid still runs.
Then check the success text matches what really happens. Every field has a name, so FormData carries
all values; keep a name on any field you add. The contact form's trip list is built from TOURS, and
/contact?tour=<id> preselects a trip.
If you deploy with pnpm build to a host that runs Node.js, you can post to your own server route instead,
for example server/api/inquiry.post.ts:
export default defineEventHandler(async (event) => {
const data = await readFormData(event);
// send an email, save to a database...
return { ok: true, tour: data.get("tour") };
});SEO and sharing#
Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the
title, description, canonical link and Open Graph and Twitter tags. Canonical URLs and the social image URL
are absolute: set your domain with SITE_URL when building.
SITE_URL=https://www.example.com pnpm generateThe tour page's generated HTML describes the first tour; the browser updates the title and description for
the tour in ?id=. If search visibility for each tour matters, generate one route per tour instead (for
example app/pages/tours/[id].vue, with every TOURS id added to routes in nuxt.config.ts). Consider
adding a sitemap, a public/robots.txt and TouristTrip structured data (JSON-LD, with useHead()). The
404 page is noindex.
Accessibility#
- Skip link, landmarks, one
h1per page and a logical heading order; breadcrumbs usearia-current. aria-current="page"in the menus; the mobile menu hasaria-expanded, closes with Escape and returns focus to its button.- The itinerary and FAQ are buttons and
<details>; filter options and room choices are real checkboxes and radios. - Live regions for the result count, the journal status, the booking total, the toast and form status; errors are linked to their fields and the first one is focused.
- The seasonality chart is a real table with text for every cell; the region charts and route map have text alternatives; decorative art is hidden from screen readers.
- Visible focus outlines, AA contrast, and
prefers-reduced-motionturns off reveals, the ticker, the floating stamp and smooth scrolling.
When editing, keep one h1 per page, give new inputs a <label> and a name, pass label to art that
carries meaning, and keep icon-only buttons' aria-label.
Building and deploying#
pnpm generate writes a static site to .output/public/, one HTML file per page (tours.html,
tour.html and so on, plus 404.html). Upload that folder to any static host: Netlify, Cloudflare Pages,
Vercel, GitHub Pages or your own server. Most hosts serve tours.html at /tours; on others, add a
rewrite from extensionless paths to .html. The pages are listed in routes in nuxt.config.ts; add new
pages there.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building (the live demo uses
/demos/nuxt/wander):
DEMO_BASE_PATH=/trips pnpm generateNuxtLink adds the base path to links; files in public/ need it written out ($config.app.baseURL).
pnpm build makes a Node.js server build in .output/ instead (run it with
node .output/server/index.mjs), which you need for server routes.
Troubleshooting#
- A new tour doesn't appear under a filter. Its
regionandstylemust match anidinREGIONSandSTYLES, and itsmonthsmust be numbers from 1 to 12. /tour?id=…shows the wrong tour. Unknown ids fall back to the first tour; check the id matchesTOURS.- A tour shows no departures. Departures are only generated for the next 15 months and never sooner
than
BOOKING_CUTOFFdays away; check the tour'smonths. - Prices look higher than "from". Departures in a region's best months add
PEAK_SUPPLEMENT; set it to0to turn peak pricing off. - The inquiry shows its confirmation but nothing arrives. The forms send nothing until you connect them
in
onValid()(see Forms). - A new page shows the 404 page after
pnpm generate. Add its route toroutesinnuxt.config.ts. - A character shows in a fallback font. The bundled fonts cover Latin and Latin Extended. For other
scripts, add the matching woff2 file to
app/assets/fonts/and an@font-facerule tomain.css. pnpm lintfails with a missing.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) once first;nuxt preparewrites it.
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Caprasimo, Outfit
and Kalam are included under the SIL Open Font License. Saltwind Journeys, its guides, travelers and contact
details are fictional.