Overview#
Kinetic is a complete website for an independent personal trainer or a small personal training studio. The demo
content is for Foothill Performance, a fictional two-coach studio in Old Colorado City, Colorado Springs,
Colorado, run by Ffion Beckett and Yusuf Dacre. It has a goal picker that recommends a program, programs and client
stories filterable by goal, progress charts, a pricing calculator, a booking flow with a week view and PAR-Q style
health questions, and free training tools (a plan builder, a one-rep max estimator and a pace calculator). Italic
condensed Sofia Sans headlines, a cobalt and flare palette on warm paper and SVG-drawn athletes give it an
athletic-editorial feel. This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a
static site, with no other dependencies. This guide covers version 1.0.0 (see template.json). Live demo:
freeframe.dev/templates/kinetic.
The copy uses American English, US dollars, a 12-hour clock and the America/Denver time zone (Mountain Time). All
health content is general and carefully worded; review it with a qualified professional before you publish your own
site.
Quick start#
Requirements: Node.js 24 and pnpm.
pnpm install
pnpm devOpen http://localhost:3000. Pages reload as you edit. Other scripts:
| Script | What it does |
|---|---|
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 lint |
ESLint |
pnpm typecheck |
nuxt prepare and vue-tsc |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with a live "next free consult" card, goal ticker, goal picker with five recommendation panels, programs teaser, three client progress charts, how it works, testimonials, pricing teaser with the Kickstart offer and a health note |
/programmes |
app/pages/programmes.vue |
Seven programs filterable by goal (?goal=), with what's included, prices, facts and booking links; comparison table |
/pricing |
app/pages/pricing.vue |
Kickstart intro offer (#kickstart), session packs, memberships (#memberships), cost calculator (#calculator), monthly vs pay-per-session table, FAQ (#faq), small print (#terms) |
/results |
app/pages/results.vue |
Six anonymized progress stories with SVG charts, filterable by goal (?goal=), review wall |
/tools |
app/pages/tools.vue |
Plan builder (#plan), one-rep max estimator (#one-rep-max), pace calculator (#pace) |
/about |
app/pages/about.vue |
Studio story, coach profiles (#ffion, #yusuf), approach, milestones, studio facts and safety note |
/contact |
app/pages/contact.vue |
Contact form, map, getting there, studio hours with open-now status, social links (#social), privacy note (#privacy) |
/book |
app/pages/book.vue |
Four-step booking flow with a week view, health questions and a confirmation with .ics download; accepts ?type=, ?coach= and ?goal= |
| 404 | app/error.vue |
Friendly error page with links back into the site (noindex), generated as 404.html |
Project structure#
kinetic/
app/
app.vue <NuxtLayout> and <NuxtPage> (pages are keyed so a link to the open page restarts it)
error.vue 404 page
router.options.ts writes query strings the way the HTML version does
layouts/default.vue skip link, header, the page, footer, toast and back-to-top button
pages/ one .vue file per page (index, programmes, pricing, results, tools, about, contact, book)
components/
SiteHeader.vue announcement bar, sticky header, main menu, mobile menu
SiteFooter.vue footer with contact details, links and studio hours
OpenStatus.vue open-now badge
HoursList.vue studio hours list with today's row marked
GoalPicker.vue home page goal picker
NextConsult.vue the hero's "next free consult" card
GoalFilter.vue goal chips over a list (programs and client stories)
ResultChart.vue a progress chart figure (the home page)
PricingCalculator.vue pricing calculator
PlanBuilder.vue training plan builder
OneRepMax.vue one-rep max estimator
PaceCalculator.vue running pace calculator
BookingFlow.vue booking flow
ContactForm.vue contact form
FieldError.vue an inline form error
AppIcon.vue, LogoMark.vue, ToastMessage.vue, BackToTop.vue icons, logo and small site-wide parts
composables/
useNow.ts the studio's date and time, updated every minute
useValidation.ts form validation and messages
usePageSeo.ts per-page title, description, canonical and Open Graph tags
useAsset.ts, useCurrentPage.ts, useMounted.ts, useYear.ts small helpers
data/
studio.ts STUDIO, HOURS, CLOSED_DATES, DAY_NAMES
coaches.ts COACHES, GROUP_CLASSES, GROUP_SIZE
booking.ts SESSIONS, BOOKING, SESSION_OPTIONS, COACH_OPTIONS, HEALTH_QUESTIONS
goals.ts GOALS, GOAL_KEY and GOAL_PICKS (goal picker panels)
programmes.ts PROGRAMMES
pricing.ts PRICING (calculator prices)
results.ts RESULTS (chart data), STORIES and HOME_STORIES
plan.ts EQUIPMENT, FOCUS, PLAN_DAYS, SPLITS, SCHEMES, PLAN_KEY, ORM_TABLE, DISTANCES
nav.ts NAV (main menu)
icons.ts ICONS (line icons for <AppIcon>)
utils/
time.ts dates and times in Mountain Time, money(), hash()
schedule.ts openStatus(), hoursText(), slotsFor(), firstAvailable()
chart.ts chartSvg(): the progress charts as SVG
safeStorage.ts localStorage with an in-memory fallback, store() and read()
client.ts makeRef(), param() and toast()
plugins/
clock.client.ts starts the studio clock once the page has loaded
reveal.client.ts scroll reveals and chart drawing
same-page.client.ts links to the page already showing restart it at the top
stable-build.server.ts reproducible builds
assets/css/main.css Tailwind entry: fonts, @theme tokens and component classes
assets/fonts/ Sofia Sans woff2 files and their license (OFL.txt)
public/img/ athlete pictograms, coach portraits, studio, map, favicon and the social image
nuxt.config.ts base path, head defaults, SITE_URL and the prerendered routes
template.json, README.md, CHANGELOG.md, DOCS.mdCustomizing#
Brand and colors#
All design tokens live in the @theme block in app/assets/css/main.css. Tailwind turns each token into utilities,
so --color-cobalt-600 becomes bg-cobalt-600, text-cobalt-600 and so on.
| Token | Used for |
|---|---|
--color-cobalt-50 to --color-cobalt-950 |
Brand blue: buttons, links, heading accents, dark bands (900/950) |
--color-flare-50 to --color-flare-800 |
Energy color: highlights, button shadows, stripes; 700/800 for text on light backgrounds |
--color-sun-* |
Small highlights (today's hours row, chart markers) |
--color-mint-* |
Success and "included" check marks, positive outcomes |
--color-ink, --color-body, --color-muted |
Heading, body and secondary text |
--color-paper, --color-mist, --color-line |
Page background, quiet fills and borders |
To change the brand color, replace the cobalt scale (keep 600 dark enough for white text, at least 4.5:1):
@theme {
--color-cobalt-600: #0f766e; /* buttons and links */
--color-cobalt-700: #115e59; /* hover */
--color-cobalt-950: #042f2e; /* footer and dark bands */
}A few drawings use hex values directly (#2b3be0 cobalt, #ff5a33 flare, #0f1330 ink): the SVG files in
public/img/, the inline hero art in the pages, app/components/LogoMark.vue and the chart colors in
app/utils/chart.ts. Update them too if you change the palette.
Typography#
The fonts are self-hosted from app/assets/fonts/, the same woff2 files Google Fonts serves: Sofia Sans Extra
Condensed (weights 600 to 900, upright and italic) and Sofia Sans (400 to 900, italic 400 to 700), each split into
latin and latin-ext files. They are declared with @font-face at the top of main.css under their real family
names, so the tokens refer to them directly:
--font-display: "Sofia Sans Extra Condensed", "Arial Narrow", "Roboto Condensed", "Helvetica Neue", sans-serif;
--font-sans: "Sofia Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--font-display is used by .display, .section-title, .card-title and buttons; --font-sans for everything else.
To swap a font, put its woff2 files in app/assets/fonts/, update the @font-face rules and the token. Condensed
display faces such as Saira Extra Condensed or Barlow Condensed suit the layout best; wider faces need smaller
heading sizes.
Content#
Page text lives in the page files (app/pages/*.vue). Everything the interactive parts use, and the repeated lists,
live in typed data files in app/data/:
| Export | File | What it holds |
|---|---|---|
STUDIO |
studio.ts |
Name, time zone (America/Denver), zone name, phone, email and address (used in .ics files) |
HOURS |
studio.ts |
Opening hours by weekday (0 = Sunday, null = closed), 24-hour HH:MM strings |
CLOSED_DATES |
studio.ts |
One-off closures as YYYY-MM-DD. December 24–28 and US federal holidays are worked out by rule in isClosedDate() (app/utils/schedule.ts), so they stay right every year |
COACHES |
coaches.ts |
Each coach's name, short name, role, portrait and shifts (1:1 hours by weekday) |
GROUP_CLASSES, GROUP_SIZE |
coaches.ts |
Small-group classes (weekday, start time, coach, name) and the class size |
SESSIONS |
booking.ts |
Bookable session types: label, minutes, price, price label, coaches, notice and whether health answers trigger a call-back |
BOOKING |
booking.ts |
Slot spacing, weeks shown and the demo "busy" share |
SESSION_OPTIONS, COACH_OPTIONS |
booking.ts |
The cards in steps 1 and 2 of the booking flow |
HEALTH_QUESTIONS |
booking.ts |
The seven health questions |
GOALS, GOAL_PICKS |
goals.ts |
The five goal ids and labels, and the goal picker's cards and panels |
PROGRAMMES |
programmes.ts |
The programs on /programmes |
PRICING |
pricing.ts |
Calculator prices: pay per session, packs, membership rates |
RESULTS, STORIES, HOME_STORIES |
results.ts |
Chart data and the client stories on /results and the home page |
FOCUS, PLAN_DAYS, SPLITS, SCHEMES, EQUIPMENT |
plan.ts |
Plan builder rules and exercises |
The hours lists are built from HOURS, so they always match. Prices also appear as text on the pricing page, the home
page and in PROGRAMMES: keep them in step with SESSIONS and PRICING.
Add a coach. Add an entry to COACHES (and its id to the CoachId type), list them in the coaches of each
session they offer, add a card to COACH_OPTIONS and a profile in app/pages/about.vue:
maya: {
name: "Maya Lin",
short: "Maya",
role: "Coach",
img: "img/coach-maya.svg",
shifts: { 2: [["16:00", "20:30"]], 4: [["16:00", "20:30"]], 6: [["08:00", "12:00"]] },
},Add a session type. Add it to SESSIONS (and its id to SessionId) and a card to SESSION_OPTIONS:
assessment: { label: "Fitness assessment", minutes: 60, price: 85, priceLabel: "$85", coaches: ["yusuf"], notice: 1440, screen: true },Add a program. Add an object to PROGRAMMES. Its goals decide which filters it appears under and which "Good
for" tags it shows; the filter counts update automatically:
{
id: "teens",
img: "img/pose-sport.svg",
format: "Max 4 · In studio",
title: "Teen athletes",
text: "Safe, coached strength training for 14 to 17-year-olds.",
included: ["Technique first", "Parent updates every month"],
goals: ["sport", "strength"],
price: "$99",
per: "a month",
facts: [["Length", "45 min"], ["Coach", "Yusuf"], ["Where", "Studio, Old Colorado City"]],
book: { href: "/book?type=consult&goal=sport", label: "Book a free consult" },
ask: "sport",
},Add a client story chart. Add data to RESULTS (and the id to ChartId), then a story to STORIES:
plank: { label: "Plank hold", unit: "reps", x: ["Wk 1", "Wk 4", "Wk 8"], y: [20, 45, 70], better: "up" },unit can be lb, kg, in, reps, steps or time (values in seconds, shown as m:ss). With better: "down" the
axis is flipped so improvement still rises.
Images and artwork#
All artwork is SVG in public/img/ and drawn for this template:
pose-strength.svg,pose-fatloss.svg,pose-mobility.svg,pose-sport.svgandpose-postnatal.svgare 120 x 120 pictograms. On selected goal cards and program headers they are turned white with a CSS filter (brightness-0 invert), so keep them single-color plus flare accents.coach-ffion.svgandcoach-yusuf.svgare square portraits. Replace them with square photos (at least 480 x 480) and keep thealttext on the about page describing the person.studio.svgis 16:10 andmap.svgis 10:7. You can swap the map for an embedded map, but that adds external requests and cookies.- The home hero, page heroes and 404 art are inline SVG in the page files.
Files in public/ need the base path: in templates use useAsset() (const asset = useAsset(), then
:src="asset('img/map.svg')"). The social image is public/img/og-image.png (1200 x 630), rendered from
public/img/og-image.svg; export the SVG to PNG at 1200 x 630 after editing it. The favicon is
public/img/favicon.svg, linked in nuxt.config.ts.
Navigation, header and footer#
The header lives in app/components/SiteHeader.vue (announcement bar, logo, main menu, "Book a free consult" button
and mobile menu) and the footer in app/components/SiteFooter.vue. The main menu comes from NAV in
app/data/nav.ts; the mobile menu adds Home first and Book online last. To add a page, create app/pages/<name>.vue,
add its route to routes in nuxt.config.ts (so it's generated) and add it to NAV. The current page gets
aria-current="page" from useCurrentPage(). The desktop menu appears from 1280 px (xl); below that the menu button
is shown. The footer's link columns are the TRAIN and STUDIO_LINKS arrays in SiteFooter.vue.
Features#
Goal picker#
app/components/GoalPicker.vue shows five radio cards (input[name="goal"]) and the matching panel
([data-goal-panel="strength"] and so on) from GOAL_PICKS in app/data/goals.ts. The choice is saved under
kinetic-goal and preselected next time; /?goal=sport selects a goal too and wins over the saved one. The panels are
in an aria-live="polite" region and the cards are a native radio group (arrow keys move between goals).
Program and story filters#
/programmes and /results use app/components/GoalFilter.vue: chip buttons ([data-filter-value], aria-pressed)
over <article>s with data-goals. Each chip shows its count, and a status line (role="status") reads "Showing 2
for strength". The choice is written to ?goal= with history.replaceState, so /programmes?goal=postnatal opens
filtered; unknown values fall back to "All". Choosing a goal also updates kinetic-goal. The page passes the items
(each with an id or key and its goals), the article classes and the content through the #item and #empty
slots:
<GoalFilter label="Filter programs by goal" list-class="mt-8 grid gap-6" item-class="…" :labelled-by="(p) => `${p.id}-title`" :items="items">
<template #item="{ item }">…</template>
<template #empty>No programs match that goal yet.</template>
</GoalFilter>Progress charts#
chartSvg() in app/utils/chart.ts draws a line chart from RESULTS as inline SVG when the site is generated, with
grid lines, axis labels, an area fill and the final value highlighted. Each SVG has role="img" and an aria-label
summary such as "5K time: 31:12 at Wk 1, 26:28 at Wk 12 (lower is better)." The line draws itself in when the chart
scrolls into view (the figure has data-reveal); with reduced motion it appears immediately.
Next free consult#
app/components/NextConsult.vue shows the first bookable consultation from the booking engine on the home page, for
example "Tomorrow 7:00 AM with Ffion". It is filled in by the browser, so the static page is never out of date.
Booking flow#
app/components/BookingFlow.vue has four steps and a confirmation:
- Session: cards from
SESSION_OPTIONS, one perSESSIONSkey. - Coach: no preference, Ffion or Yusuf (
COACH_OPTIONS). Coaches who don't offer the chosen session are disabled, and each card shows its next opening ([data-coach-next]). - Time: a Monday-to-Sunday week view with previous and next week buttons, for this week and the next
BOOKING.weeksAhead - 1weeks. It opens on the week of the first available time. - Your details: name, email, cell phone, optional goal and notes, seven yes/no health questions and a consent box.
- Confirmation: a summary with a reference (like FP-7K2Q), the coach's portrait, what happens next and an Add
to calendar (.ics) button; the
.icsfile carries the reference too.
How times are built, in slotsFor() in app/utils/schedule.ts:
- A 1:1 session can start every
BOOKING.slotStepminutes inside the coach'sshiftsand the studioHOURS, and must finish by the end of the shift. - It can't overlap a small-group class the coach is leading (
GROUP_CLASSES, 60 minutes). - It must start at least
noticeminutes from now (2 hours for consults, 12 hours for training), in Mountain Time. - Federal holidays, December 24–28,
CLOSED_DATESand closed weekdays show "Studio closed" or "Closed". groupsessions use the fixedGROUP_CLASSEStimes and show the spaces left.- With "No preference", each time goes to the first coach in the session's
coacheslist who is free. BOOKING.demoBusySharehides a stable, pseudo-random share of times so the demo looks realistic. Set it to0when you connect real availability.
URL parameters preselect choices: /book?type=pt60&coach=yusuf&goal=sport. type must be a SESSIONS key, coach a
COACHES key that offers that session, and goal a GOALS key (it fills the goal field; otherwise the saved
kinetic-goal is used).
All dates and times are calculated and shown in STUDIO.timeZone (America/Denver), whatever the visitor's own time
zone. Time-dependent parts (next openings, the week view, the summary) are filled in by the browser after the page
loads (useNow() in app/composables/useNow.ts), so the generated pages never show stale times. The .ics file
(foothill-session.ics) stores the start and end in UTC, so calendars convert it correctly.
Accessibility: each step heading receives focus when the step opens, the progress bar marks the current step with
aria-current="step", step errors use role="alert", each day in the week view is a labeled group and every time is
a radio button with a full label (for example "Tuesday, October 20, 2026, 7:30 AM, Ffion").
Health questions#
Step 4 asks the seven PAR-Q style yes/no questions in HEALTH_QUESTIONS (name="parq1" to parq7). They are
required. As soon as they are answered, [data-health-outcome] shows one of three messages:
| Outcome | When | Result |
|---|---|---|
clear |
All answers are no | Booking confirms as normal |
talk |
Any yes, for a session with screen: true |
The button changes to "Send call-back request" and the confirmation explains that it is a request, not a booking, with no .ics file |
consult |
Any yes, for the free consultation (screen: false) |
The booking goes ahead, with a note that the consult is the right place to talk it through |
The wording is deliberately cautious: it asks people to talk to the coach first and to check with their doctor. Keep it that way, and have a professional review the questions before you publish. Health answers are sensitive personal data, and privacy laws vary by state: if you connect this form, store them securely, keep them only as long as you need and check which rules apply to you.
Pricing calculator#
app/components/PricingCalculator.vue has a sessions-a-week stepper (1 to 4) and a 45/60-minute toggle. It compares,
per month (PRICING.weeksPerMonth = 52 / 12):
- Pay per session:
PRICING.payg[length]x sessions a month. - Session pack: the largest pack in
PRICING.packs[length]you'd use withinPRICING.packWeeks, priced per session. - Monthly membership:
PRICING.member[length][perWeek - 1]x sessions a week x 52 / 12, rounded to the dollar.
The cheaper of pack and membership is marked "Best value", and the saving against pay per session is shown per month
and per year (two 60-minute sessions a week: $780, $667 and $641, a saving of $139 a month). The results list is
aria-live="polite". Change prices in PRICING and in the tables on the page.
Plan builder#
app/components/PlanBuilder.vue builds an illustrative week from four radio groups: days a week (2 to 5), goal,
equipment (gym, dumbbells, bodyweight) and session length (30, 45 or 60 minutes, showing 3, 4 or 5 exercises).
Training days come from PLAN_DAYS, the session for each day from SPLITS[goal][days], exercises from FOCUS (each
exercise is [gym, dumbbells, bodyweight]) and the sets and reps from SCHEMES or the session's own scheme. Rest
days are filled in automatically. The postpartum goal shows an extra safety note.
The choices are saved under kinetic-plan and restored on the next visit; without a saved plan, the goal comes from
?goal= or kinetic-goal. Print opens the browser's print dialog (the print styles hide the header, footer and
buttons); Reset clears the saved plan.
One-rep max and pace calculators#
app/components/OneRepMax.vue estimates a one-rep max with the Epley formula (weight x (1 + reps / 30), or the weight
itself for one rep), rounded to 2.5 kg or 5 lb, and lists 60 to 95% training weights from ORM_TABLE. Reps are
limited to 1 to 12; 185 lb for 5 reps gives 215 lb.
app/components/PaceCalculator.vue turns a distance (5K, 10K, half marathon, marathon or a custom distance up to 155
miles) and a finish time into pace per mile, pace per km and speed in miles per hour, plus finish times for the
standard distances at the same pace (5K in 27:30 is 8:51 per mile).
Studio hours and open-now status#
app/components/HoursList.vue and app/components/OpenStatus.vue highlight today's row and show "Open now · until
8:30 PM" or "Closed · opens tomorrow 6:00 AM", from HOURS and CLOSED_DATES in Mountain Time. They are filled in by
the browser (the generated HTML has no "today") and refresh every minute.
Small extras#
- Mobile menu:
[data-menu-button]toggles#mobile-menu, updatesaria-expandedand its label, and closes on Escape (focus returns to the button), outside clicks, link clicks and when the window grows past 1280 px. - Scroll reveals:
app/plugins/reveal.client.tsfades in.revealand.reveal-xelements once; revealing one also reveals everything above it. Turned off with reduced motion. - Links to the open page:
app/plugins/same-page.client.tsmakes a link to the page already showing (the menu link on a filtered programs page, "Book a free consult" during a booking) start that page afresh at the top, like a page load. - Ticker: the scrolling goal band on the home page is decorative (
aria-hidden) and stops with reduced motion. - Back to top:
<BackToTop />inapp/layouts/default.vue; delete it,BackToTop.vueand the.back-to-topstyles inmain.cssto remove it.
Forms#
There are two forms, plus the one-rep max inputs. They validate in the browser with inline errors and don't send anything until you connect them.
Validation is useValidation() in app/composables/useValidation.ts. A field is checked when it has data-validate;
the rules come from its attributes, and the wording is in fieldMessage():
| Attribute | Rule |
|---|---|
required |
Must be filled, checked or chosen (data-required-msg sets a custom message) |
type="email" |
Must look like an email address |
data-phone |
At least 10 digits |
minlength |
Minimum length |
data-label |
The field name used in messages ("Your name is required.") |
Errors appear under the field (or under the group for radio buttons) with <FieldError>, are linked with
aria-describedby, set aria-invalid and focus the first problem; they clear as the visitor fixes them.
| Form | File | Validates |
|---|---|---|
| Contact | app/components/ContactForm.vue |
Name, email format, optional phone (at least 10 digits if filled), interest, reply method, message of 10+ characters, consent |
| Booking | app/components/BookingFlow.vue |
Session, time, name, email, cell phone, all seven health questions, consent |
The confirmations read as if the message or booking went through: they show a short reference (FP- and four
characters) made in the browser by makeRef() in app/utils/client.ts, and say what happens next (a reply within
one business day, the coach confirming a booking by email and text, a call-back within one business day after a
health "yes"). If your form service or booking system returns its own reference, show that instead, and change those
promises to match how you actually work.
Connecting the contact form#
To use Formspree (or any service that accepts a POST), send the data in onSubmit() in
app/components/ContactForm.vue before showing the thank-you message:
async function onSubmit() {
if (!v.validate()) return;
const data = new FormData(form.value!);
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
if (!res.ok) return toast("Sorry, that didn’t send. Please call (719) 555-0146.");
name.value = String(data.get("name") ?? "").trim().split(" ")[0] ?? "";
reference.value = makeRef("FP");
done.value = true;
await nextTick();
success.value?.focus();
}(toast and makeRef are auto-imported from app/utils/client.ts.) With pnpm build (a Node.js server build) you
can post to your own server route instead (server/api/contact.post.ts with defineEventHandler).
Connecting a booking system#
Connect a real booking system rather than a form service. Either replace <BookingFlow /> in app/pages/book.vue
with your booking platform's embed, or keep this flow and:
- Load real open times (through your own server, so API keys stay private) and return them from
slotsFor()inapp/utils/schedule.tsin the same{ key, mins, coach }shape. SetBOOKING.demoBusyShareto0. - In
onSubmit()inBookingFlow.vue, POST the session,state.slot, the health answers and the form data to your server before showing step 5. - Show the confirmation only after your system confirms, with its reference if it returns one.
Saved data (localStorage)#
| Key | Shape | Written by | Validation on read |
|---|---|---|---|
kinetic-goal |
JSON string, one of "strength", "fatloss", "mobility", "sport", "postnatal" |
Goal picker and goal filters | Ignored unless it is a string that matches a GOALS key (isGoal()) |
kinetic-plan |
{ "days": 3, "goal": "strength", "equipment": "gym", "minutes": 45 } |
Plan builder | validPlan() in PlanBuilder.vue checks every field against PLAN_DAYS, GOALS, EQUIPMENT and 30/45/60; an invalid value is removed |
Nothing else is stored; booking and contact details are never saved. To reset, use Reset on the plan builder or
clear the site's storage in the browser's developer tools. safeStorage, store() and read() in
app/utils/safeStorage.ts wrap every access in try/catch and keep an in-memory copy, so the site works when
storage is blocked or full.
SEO and sharing#
Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the exact <title>
and description copied from the HTML version, a canonical link, Open Graph tags (with en_US locale; og:image is
public/img/og-image.png) and a Twitter card. The 404 page passes noindex: true. Social networks need absolute
URLs, so set your domain when building; it becomes runtimeConfig.public.siteUrl in nuxt.config.ts:
SITE_URL=https://www.your-domain.com pnpm generateAlso consider adding a sitemap.xml and robots.txt to public/ listing your routes, and ExerciseGym structured
data (schema.org JSON-LD, with useHead()) on the home page with your real address, phone and opening hours. The
template doesn't include structured data because it would describe a fictional studio.
Accessibility#
Built in:
- Skip link, landmarks (
header,nav,main,footer), one<h1>per page and a logical heading order. - Visible focus styles on every control; the mobile menu exposes
aria-expandedand closes with Escape. - Native radio groups for the goal picker, session, coach, time and health questions; filters use
aria-pressedbuttons; the calculator, filters, booking and plan builder announce changes with live regions. - Charts have text summaries; decorative SVG is
aria-hidden; meaningful images havealttext. - Color contrast meets WCAG AA for text; flare (
#ff5a33) is only used behind dark text or as decoration. prefers-reduced-motionturns off reveals, the ticker, floating cards, chart drawing and smooth scrolling.
When editing, keep one h1 per page, give every new input a <label>, keep aria-labelledby and
aria-describedby pointing at ids that exist, 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 listed in routes in nuxt.config.ts as <page>.html (pricing.html,
linked as /pricing), 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 /pricing to pricing.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=/kinetic 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#
| Problem | Fix |
|---|---|
| The week view says "No times left this week" | Times need notice minutes of warning and are limited to coach shifts. Check COACHES[...].shifts, HOURS and CLOSED_DATES, and remember the demo hides about 45% of times (BOOKING.demoBusyShare) |
| Times look an hour out | The site always uses STUDIO.timeZone. If your studio isn't in Mountain Time, change it to your IANA zone (for example America/Chicago) and update STUDIO.zoneName and the "Mountain Time" notes in the pages |
| The goal picker or plan builder opens with an old choice | It's restored from localStorage. Use Reset on the plan builder, or remove kinetic-goal / kinetic-plan in the browser's storage settings |
?goal= links don't filter |
The value must be one of the GOALS keys (strength, fatloss, mobility, sport, postnatal). Anything else shows all items |
| A new program or story never shows under a filter | Its goals must use GOALS keys |
| The social preview has no image | Build with SITE_URL set to your domain so og:image is an absolute URL |
| A hydration warning appears in the console | Anything that reads the clock, the URL or storage must run in onMounted() or an event handler, never while rendering |
| Images or styles are missing under a sub-folder | Build with DEMO_BASE_PATH set to that folder and use useAsset() for files in public/ |
| Content stays invisible after editing | .reveal elements are hidden until app/plugins/reveal.client.ts reveals them, and Vue drops the is-visible class it adds when an element's :class binding changes, so keep .reveal off elements with changing classes; check the browser console for errors |
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. Sofia Sans and
Sofia Sans Extra Condensed are licensed under the SIL Open Font License 1.1 (app/assets/fonts/OFL.txt).
Foothill Performance, its coaches, clients, reviews and prices are fictional.