Overview#
Stay is a complete website for a small hotel or resort, shown as Casa Marea, a fictional 22-room boutique hotel in a 1903 villa above Narragansett Bay in Newport, Rhode Island. Guests can pick dates on the home page, filter the rooms, open a room with a photo lightbox, a live price and a two-month availability calendar, read the menus, browse experiences, copy an offer code and book in five steps with a full price breakdown. Rooms, extras, offers, experiences and menus live in one typed data file, and the pricing rules in one TypeScript file. This is the Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site.
Live demo: freeframe.dev/templates/stay
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm).
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm generate |
Static site in .output/public/ |
pnpm preview |
Serve the last build locally |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm typecheck |
nuxt prepare, then vue-tsc |
pnpm lint |
ESLint (the flat config from @nuxt/eslint) |
pnpm build:demo |
Build for /demos/nuxt/stay into demo/ (the freeframe.dev demo; you can delete it) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with the booking bar, welcome figures, three featured rooms, experiences, restaurant, reviews, getting here, offers banner |
/rooms |
app/pages/rooms.vue |
Six rooms with filters by view, beds and guests (kept in the URL), "In every room" |
/room?room=<id> |
app/pages/room.vue |
One room: gallery and lightbox, live price, details, amenities, availability calendar, other rooms |
/dining |
app/pages/dining.vue |
Three places to eat, menus in tabs, table request form |
/experiences |
app/pages/experiences.vue |
Eight experiences with a filter (also from #spa, #sea...), weddings and events |
/offers |
app/pages/offers.vue |
Three offers with copy-code buttons, terms and "Book with this offer" |
/contact |
app/pages/contact.vue |
Map and directions, contact form (prefilled from ?topic= and ?about=), FAQ |
/booking |
app/pages/booking.vue |
Five-step booking flow with a live summary and a confirmation |
| 404 | app/error.vue |
Page not found (generated as 404.html) |
A room is chosen with a query string (/room?room=suite) rather than a route parameter, so one generated page serves
every room and the URLs match the HTML version.
Project structure#
app/
├── app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, with the page key (see "Links to the same page")
├── layouts/default.vue skip link, header, page, footer, toast and back-to-top button
├── pages/ one file per page (index.vue, rooms.vue, room.vue, booking.vue...)
├── error.vue the 404 page
├── components/
│ ├── SiteHeader.vue logo, menu, "Book a stay", mobile menu
│ ├── SiteFooter.vue address, link columns, newsletter form and copyright year
│ ├── BookingBar.vue the home page's dates-and-guests form
│ ├── PriceBreakdown.vue a stay's price, line by line (room page and booking summary)
│ ├── MenuTabs.vue the dining menus in tabs
│ ├── LogoMark.vue the sun-and-waves mark
│ ├── ToastMessage.vue the status message at the bottom of the screen
│ └── BackToTop.vue the back-to-top button
├── composables/
│ ├── usePageSeo.ts title, description, canonical URL and Open Graph tags
│ ├── useValidatedForm.ts inline validation and success messages for the table, contact and newsletter forms
│ └── useAsset.ts files in public/ with the base path
├── data/
│ ├── hotel.ts rooms, extras, offers, amenities, experiences, menus, FAQ, contact details
│ └── nav.ts header and footer links
├── utils/
│ ├── booking.ts dates, prices, offers, availability and the saved booking draft
│ ├── fields.ts setFieldError(): an inline error and aria-invalid
│ ├── safeStorage.ts localStorage with an in-memory fallback
│ └── toast.ts toast("...")
├── plugins/
│ ├── reveal.client.ts scroll reveals
│ ├── same-page.client.ts links to the page already showing, and scrolling to the top
│ ├── keep-hash.client.ts keeps the #hash exactly as typed (the experiences filter reads it)
│ └── stable-build.server.ts reproducible builds
├── router.options.ts query strings written like the HTML version
└── assets/
├── css/main.css Tailwind, brand tokens, components and @font-face rules
└── fonts/ Bodoni Moda and Jost (woff2) and their licenses
public/img/ SVG artwork, favicon and social image
nuxt.config.ts base path, head tags, SITE_URL, prerendered routes, TailwindCustomizing#
Brand and colors#
All design tokens live in the @theme block of app/assets/css/main.css:
| Token | Used for |
|---|---|
--color-linen |
Page background |
--color-shell, --color-sand |
Panels and deeper panels |
--color-line |
Borders |
--color-ink |
Text and dark sections |
--color-sea |
Secondary dark (selected states, back-to-top) |
--color-mist |
Muted text |
--color-coral, --color-coral-dark |
Accent (eyebrows, focus rings, errors) |
--color-olive, --color-sun |
Decorative details |
--color-foam |
Light text on dark backgrounds |
To change the accent:
@theme {
--color-coral: #2f6f5e;
--color-coral-dark: #245748;
}Tailwind rebuilds as you save in pnpm dev. Keep text at WCAG AA contrast against its background. The checkboxes in
app/pages/booking.vue use accent-[#1d4b57]; search for the old hex when you change --color-sea. The browser
theme color is the theme-color meta tag in nuxt.config.ts.
Typography#
Bodoni Moda (headings, --font-display) and Jost (body, --font-sans) are self-hosted from app/assets/fonts/ and
declared with @font-face at the top of main.css. They are the same Latin files Google Fonts serves, so text sets
exactly like the HTML version. To swap a font, put its woff2 file in app/assets/fonts/, change the @font-face
rule and the family name in the matching @theme token.
Content#
Copy that doesn't change is markup in each page in app/pages/. The data behind the interactive parts is in
app/data/hotel.ts:
| Export | What it holds |
|---|---|
HOTEL |
Name, place, address, phone and email |
ROOMS |
Each room: id, name, view (sea, garden or pool), bed (king or twin), capacity, size (sq ft), rate (per night, US dollars), short, description, highlights |
AMENITIES |
Listed on every room page after the room's own highlights, and under "In every room" |
EXTRAS |
Booking extras: id, name, price, unit, text |
PROMOS |
Offer codes and their rules (see Offer codes) |
EXPERIENCES |
The experiences page: id, type (Spa, Sea, Land or Events), name, time, price, text, img |
MENUS |
The dining menus: tab name → [dish, description, price] rows |
FAQS |
The contact page's questions and answers |
VIEW_LABEL, BED_LABEL |
Labels for the view and bed values |
To add a room, add it to ROOMS:
{
"id": "loft",
"name": "Garden Loft",
"view": "garden",
"bed": "king",
"capacity": 2,
"size": 360,
"rate": 290,
"short": "A split-level room under the eaves.",
"description": "…",
"highlights": ["Mezzanine bedroom", "Garden patio", "Rain shower"]
},Then add three images to public/img/: room-loft.svg, room-loft-terrace.svg and room-loft-bath.svg (the room
page, the booking flow and the "Other rooms" cards build their paths from the id). The room page, the booking flow and
prices pick the room up straight away. The room cards on index.vue and rooms.vue are written as markup: copy an
<article data-room-card …> block, set its data-view, data-bed and data-capacity, and on rooms.vue its
:hidden="!fits('garden', 'king', 2)" to the same values so the filters work. Keep the prices on those cards in step
with rate when you change it.
The extras list on the booking page, the offer cards, the experiences, the menus and the FAQ are all rendered from the data file, so editing it is enough.
Images and artwork#
All artwork is original SVG in public/img/:
| Files | Aspect ratio | Where |
|---|---|---|
hero-coast.svg |
16:10 (1600 × 1000) | Home hero |
room-<id>.svg, room-<id>-terrace.svg, room-<id>-bath.svg |
16:10 | Room cards, room gallery, booking flow |
pool.svg, spa.svg, boat.svg, dining.svg, breakfast.svg, bar.svg, events.svg |
16:10 | Feature cards and pages |
map.svg |
3:2 | Home and location pages |
favicon.svg |
square | Browser tab icon |
og-image.svg / og-image.png |
1200 × 630 | Social sharing image (the SVG is the editable source) |
To use photos, export them at the same aspect ratio (1600 × 1000 works everywhere) and change the paths. Files in
public/ are always written through useAsset(), which adds the base path:
<img :src="asset('img/room-sea.jpg')" alt="Sea View Room: bedroom" width="1600" height="1000">The room photos' paths are built in app/pages/room.vue (photos), app/pages/booking.vue (the room step and
summaryImg) and app/pages/offers.vue (ART): change the extension there too. Keep meaningful alt text.
Navigation, header and footer#
The menu links are NAV in app/data/nav.ts; the mobile menu adds Home first. The footer's two link columns are
FOOTER_LINKS in the same file. The header (SiteHeader.vue) marks the link to the current page with
aria-current="page", which underlines it on desktop and colors it in the mobile menu.
To add a page:
- Create
app/pages/spa.vuewith a root<main id="main" tabindex="-1" class="flex-1 focus:outline-none">and ausePageSeo({ title, description })call. - Add
"/spa"toroutesinnuxt.config.ts, so it is generated. - Add
{ to: "/spa", label: "Spa" }toNAV(and toFOOTER_LINKSif you like). Link to it with<NuxtLink to="/spa">.
Features#
The booking logic shared by the booking bar, the room page and the booking flow is in app/utils/booking.ts. Nuxt
imports its functions (quote(), checkDates(), checkPromo(), readDraft()...) automatically in every component.
Everything that depends on today's date, the URL or storage runs after the page has loaded (onMounted()), so the
generated HTML always shows the same text and never mismatches.
Booking bar#
app/components/BookingBar.vue, in the home page's hero. It:
- starts from the saved draft (or two weeks from today for three nights, two adults);
- keeps the departure after the arrival when the arrival changes;
- validates with
checkDates(): arrival can't be in the past, departure must be after arrival, and stays are limited toMAX_NIGHTS(21); - on submit, saves the stay and opens
/booking?checkin=…&checkout=…&adults=…&children=….
Errors appear under each field (the element named by its aria-describedby), the field gets aria-invalid, and
focus moves to the problem.
Room filters#
/rooms has three selects inside <form data-room-filters>: view, beds and guests (rooms whose capacity is at least
the number chosen). The cards that don't match are hidden, the live count ([data-room-count], announced to screen
readers) updates and the empty state shows when nothing matches. "Clear filters" shows every room. The filters are
written into the address bar, so /rooms?view=sea&guests=3 can be shared; values the menus don't offer are ignored.
Room page#
/room?room=<id> shows one room. The generated HTML shows the first room (Sea View Room); the room from the URL is
filled in as soon as the page loads, along with the page title. An unknown id shows the first room. Adult counts above
the room's capacity are disabled, and the page lists three other rooms.
Photo lightbox#
The main photo and the three thumbnails ([data-gallery-open]) open a native <dialog data-lightbox> with the
bedroom, terrace and bathroom pictures. ← and → and the Previous and Next buttons move between photos, Esc, the close
button or a click on the backdrop closes it, and the dialog keeps focus inside while it's open and returns it to the
button that opened it. The caption ("Photo 2 of 3: private terrace") is read out as the photo changes.
Prices#
quote(stay) in app/utils/booking.ts prices a stay. The rules are constants at the top of the file:
| Constant | Default | Meaning |
|---|---|---|
WEEKEND_UPLIFT |
0.15 |
Friday and Saturday nights cost 15% more |
EXTRA_ADULT |
45 |
Per night, for each adult beyond two |
EXTRA_CHILD |
25 |
Per night, for each child once the room already has two guests |
VAT_RATE |
0.14 |
Estimated tax added to the total: Rhode Island sales tax (7%), state hotel tax (5%) and local hotel tax (2%), as of 2026. Check current rates with the Rhode Island Division of Taxation. Shown as "Estimated sales and hotel tax (14%)"; change that label in PriceBreakdown.vue too |
MAX_NIGHTS |
21 |
Longest stay bookable online |
How a total is built:
- Each night costs the room
rate, plusWEEKEND_UPLIFTon Fridays and Saturdays (rounded to whole dollars). - Extra guests are added per night.
- Extras are added: breakfast per guest per night, every other extra once per stay.
- A valid offer code takes its percentage off the room total only (not extras or extra guests).
- Tax (
VAT_RATE) is worked out on that subtotal and added. Rates and extras are shown before tax, and the breakdown says so.
For example, the Sea View Room ($360) for three nights from a Wednesday with two adults is $1,134 (one Friday at
$414), plus $158.76 tax, for a total of $1,292.76. PriceBreakdown.vue shows the result on the room page and in the
booking summary. To change the currency, edit money() (it formats with en-US and a $ sign).
Availability calendar#
The room page shows two months ([data-calendar]), from the arrival month. Booked nights are struck through and
can't be an arrival; click an arrival date, then a departure date, and the price updates. A stay that runs into a
booked night is refused with a message in [data-cal-status], which screen readers announce. The arrows move a month
at a time (not before this month), and typing an arrival date moves the calendar to its month.
Availability comes from isBlocked(roomId, iso) in app/utils/booking.ts, a deterministic demo pattern. Replace
it with your reservations system or channel manager. The calendar renders synchronously, so the simplest approach is to
fetch the booked nights once into a Set and check it:
const BOOKED = new Set<string>(); // fill from your API, e.g. "sea:2026-11-02"
export function isBlocked(roomId: string, iso: string) {
return BOOKED.has(`${roomId}:${iso}`);
}firstBlocked() checks every night of a stay with isBlocked(), so the room page and the booking flow follow the
same data.
Booking flow#
/booking (app/pages/booking.vue) is one <form data-booking-form> with five <fieldset data-step> steps:
- Dates and guests, checked with
checkDates(). If the guests change and the chosen room no longer sleeps them,fitRoomToGuests()switches to the first room that does and the summary says so ("The Sea View Room sleeps up to 2 guests, so we've switched you to the Marea Suite…"). If no room sleeps that many, the summary shows no price and asks for fewer guests. - Room: every room with its price for the dates; rooms that are too small or booked on any night are disabled with the reason.
- Extras: the checkboxes rendered from
EXTRAS. - Guest details: first and last name, email and phone (validated inline), arrival time and special requests.
- Review: the stay, the offer code field with "Apply" and the cancellation policy checkbox.
The summary beside the form shows the full price. The step list marks the current step with aria-current="step",
focus moves to each step's legend, and Back keeps everything filled in. Arriving with a room in the URL (from a
room page) starts on step 2. Every change is saved to the draft (see Saved data).
The last step shows "Booking received" with a reference such as CM-7K2Q (made by makeRef("CM"): four characters
with no 0/O or 1/I) and clears the draft. Nothing is booked until you connect it: confirm() in
app/pages/booking.vue is the place to send the stay (state.value) and the guest details (form) to your booking
engine and show the reference it returns. Check prices and availability again on your server: anything calculated in
the browser can be changed by the visitor. Change the "within a day" wording to match how you confirm bookings.
Offer codes#
Each entry in PROMOS has:
| Field | Meaning |
|---|---|
code |
What guests type (any case) |
name |
Shown in messages and the price breakdown |
percent |
Discount off the room total |
minNights |
Minimum stay |
advanceDays |
Minimum days between today and arrival (0 for none) |
rooms |
Room ids the code applies to ([] for all rooms) |
summary, terms |
The offer card's text |
checkPromo(code, stay) explains why a code doesn't apply ("Stay longer needs a stay of at least 4 nights."). A code
can come in the URL (/booking?promo=LONGSTAY) and is checked on the last step. The offers page renders one card per
code; its picture is set in ART in app/pages/offers.vue.
Copy offer codes#
"Copy code" copies the code to the clipboard and says so in the toast (toast() in app/utils/toast.ts, shown for
2.6 seconds). The clipboard needs HTTPS (or localhost); elsewhere, or if the browser refuses, the toast shows the code
instead.
Dining menu tabs#
app/components/MenuTabs.vue renders MENUS as an accessible tab set. ← and → move between tabs (wrapping), Home and
End jump to the first and last, and only the selected tab is in the Tab order. Add a key to MENUS to add a tab.
Experiences filter#
The All / Spa / Sea / Land / Events buttons (aria-pressed) filter the cards and update the count. A hash opens a
type directly: /experiences#sea. Each card's "Ask the concierge" opens the contact form with the experience named.
Contact prefill#
/contact?topic=events&about=boat selects the topic and starts the message ("Hello, I’d like to ask about a private
boat to Jamestown."). The about keys and their wording are ABOUT in app/pages/contact.vue; topic must match one
of the topic select's <option> values.
Header, mobile menu and scroll reveals#
- The header gains a border and a shadow once the page scrolls.
- The menu button (
#menu-toggle) opens#mobile-menu, updatesaria-expandedand its label, and stops the page behind from scrolling. Esc closes it and returns focus to the button; following a link or widening the window to desktop size closes it too. - Elements with the
revealclass fade in as they scroll into view (app/plugins/reveal.client.ts). Revealing one also reveals everything above it, so jumping to the end of a page never leaves content hidden. With reduced motion, or without JavaScript, everything shows straight away. Never putrevealon an element with a:classbinding: the plugin addsis-visiblewithclassList.
Links to the same page#
In the HTML version every link loads a page. Here links move between pages without a reload, so
app/plugins/same-page.client.ts makes them behave the same: a link to the page already showing (the Rooms link on a
filtered rooms page) starts it again from the top, and another room from a room page opens at the top.
Back to top#
app/components/BackToTop.vue appears after the visitor scrolls about one screen. It scrolls to the top (instantly
with reduced motion), moves keyboard focus to <main>, lifts itself above the toast, stays hidden while the mobile
menu is open, and hides while it would cover elements marked data-back-to-top-hide (the room page's price card and
the booking summary). Mark your own fixed elements at the bottom of the screen with data-back-to-top-avoid to keep
the button above them. To remove it, delete <BackToTop /> from app/layouts/default.vue and the .back-to-top
rules in main.css.
Copyright year#
The footer shows the year written in the source (2026) and replaces it with the current year when the page loads.
Forms#
| Form | Page | Validates |
|---|---|---|
Booking bar (data-booking-bar) |
/ |
Dates |
Room price (data-room-price) |
/room |
Dates, capacity, availability |
Booking (data-booking-form) |
/booking |
Each step (see above) |
Table request (data-form="table") |
/dining |
Date from today, name, email |
Contact (data-form="contact") |
/contact |
Name, email, a message of at least 10 characters |
Newsletter (data-form="newsletter") |
Footer of every page |
The table, contact and newsletter forms use useValidatedForm() (app/composables/useValidatedForm.ts): fields are
checked when you leave them and again as you type once they show an error; on submit, errors appear under each field,
focus moves to the first one, and the form's [data-form-success] message is shown. The table and contact messages
include a reference (CM- and four characters, in [data-form-ref]) and say the reply comes within a day; the
newsletter message asks the subscriber to look out for a confirmation email.
The forms don't send anything until you connect them. To use a form service such as
Formspree, replace the marked comment in the submit handler of useValidatedForm():
// To send for real: fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(el) })
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(el),
headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, your message could not be sent. Please try again or email us.");(make the submit listener async), and keep the lines after it that reset the form and show the message. To send each
form somewhere else, branch on el.dataset.form (table, contact or newsletter). If the service returns a
reference, show it in [data-form-ref] instead of makeRef("CM").
To run your own code instead, add a server route such as
server/api/booking.post.ts, build with pnpm build (a Node.js server) and post to /api/booking.
Saved data (localStorage)#
| Key | Shape |
|---|---|
casa-marea:booking:v1 |
{ checkin, checkout, adults, children, room, extras, promo }: the booking in progress |
The booking bar, the room page and the booking flow share this draft. Every read goes through sanitizeDraft():
- dates must be real
YYYY-MM-DDdates; adultsis kept to 1–4 andchildrento 0–2, whole numbers only;roomandextrasmust be known ids (duplicates are dropped);promomust be 3–20 letters or digits.
Anything invalid (including text that isn't JSON) is dropped and the stored value is repaired, so a corrupt or edited
value never breaks a page. A draft with dates in the past starts two weeks from today again. Values in the URL take
priority over the draft. When the booking flow restores a room that can't sleep the restored guests, guests from the
URL keep their number and get the first room that sleeps them; otherwise the guests are reduced to the room's
capacity (adults first). The draft is cleared after a confirmed booking. To reset it by hand, run
localStorage.removeItem("casa-marea:booking:v1") in the browser console.
Storage goes through safeStorage (app/utils/safeStorage.ts): when the browser blocks it (a sandboxed frame, strict
privacy settings) or it is full, the draft is kept in memory for the rest of the visit instead.
SEO and sharing#
-
Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which writes the title, the meta description,og:title,og:description,og:image, asummary_large_imageTwitter card,og:urland a canonical link. The room page's title follows the room (Marea Suite — Casa Marea). -
Canonical,
og:urlandog:imageare absolute:SITE_URL(defaulthttps://casamarea.example) plus the base path. Set it to your domain when you build:shSITE_URL=https://www.your-hotel.com pnpm generate -
The 404 page passes
noindex: robotsnoindex, and no canonical orog:url. -
For a sitemap, add
public/robots.txtand asitemap.xml(or the@nuxtjs/sitemapmodule). ConsiderHotelstructured data (JSON-LD, withuseHead()) on the home page with your address, phone and price range. The room page is one URL with?room=; to give each room its own indexable page, createapp/pages/rooms/[id].vueand list each room inroutes.
Accessibility#
Built in:
- a "Skip to content" link and landmarks (
header,nav,main,footer) on every page; - visible focus rings (
:focus-visibleinmain.css); - one
h1per page; - form errors linked with
aria-describedbyandaria-invalid, and focus moved to the first error; - tabs, the photo dialog and the step list follow the standard patterns (
role="tab", native<dialog>,aria-current="step"), and the filter buttons usearia-pressed; - live regions for filter counts, the price, calendar status, the lightbox caption and the toast;
- reduced motion respected by reveals, smooth scrolling, the hero zoom and the back-to-top button;
- text colors that meet WCAG AA contrast.
When editing, keep one h1 per page, give every new input a <label>, keep alt text meaningful, and check contrast
if you change colors.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route in routes in nuxt.config.ts as <page>.html (rooms.html, linked as
/rooms), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt
loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub
Pages, S3, Vercel). Hosts that don't map /rooms to rooms.html on their own need that rewrite (Vercel:
"cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/hotel pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load;
replace it with typescript@7 once they support it.
Troubleshooting#
- The room page always shows the Sea View Room. The URL needs
?room=with anidfromROOMS; unknown ids show the first room. The generated HTML always shows the first room; the right one appears once the page has loaded. - An offer code says it doesn't apply. Check its
minNights,advanceDaysandroomsinPROMOS; the message names the rule that failed. - Prices on the room cards don't match the booking total. The cards on the home and rooms pages are markup; the
booking uses
rateplus weekend, guest and extra charges. Update the cards when you change rates. - The booking flow starts with old dates or guests. It restores the saved draft; remove
casa-marea:booking:v1(see Saved data). - Copying a code shows the code instead. The clipboard needs HTTPS or
localhost. - Images or links break in a sub-folder. Build with
DEMO_BASE_PATH, and write files inpublic/withuseAsset()(NuxtLinkadds the base path itself). - A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.
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. Bodoni Moda and
Jost are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Casa Marea, its staff and its
guests are fictional.