Overview#
Fitness is a bold, dark website for a gym or fitness studio, shown as Forge Athletic Club, a fictional club with two locations (Harbor and Northgate), six class types, six coaches and 36 weekly sessions. It has a filterable weekly timetable with a booking dialog and bookings saved in the browser, class and coach pages built from the data, membership plans with a monthly/yearly switch, a BMI and calorie calculator, a three-step free-trial form and live open-now hours in the clubs' time zone. This is the Next.js version (1.0.0): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site.
Live demo: freeframe.dev/templates/fitness
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm build |
Static site in out/ |
pnpm start |
Serve out/ locally (npx serve out) |
pnpm lint |
ESLint (Next.js core-web-vitals and TypeScript rules) |
pnpm typecheck |
Generate route types (next typegen), then type-check with tsc (TypeScript 7) |
pnpm build:demo |
Build for /demos/nextjs/fitness into demo/ (the freeframe.dev demo; you can delete it) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with count-up stats, class-name ticker, "next up today", six class cards, results, membership teaser, three coaches, member stories, facilities, free-week banner |
/classes |
src/app/classes/page.tsx |
Weekly timetable: filters by class, intensity and coach, day tabs on phones, spots-left badges, booking dialog |
/class?type=<id> |
src/app/class/page.tsx |
One class: summary, duration, calories, intensity, what to expect, equipment, coaches, upcoming sessions |
/trainers |
src/app/trainers/page.tsx |
All coaches with a specialty filter, personal training call to action |
/trainer?id=<id> |
src/app/trainer/page.tsx |
One coach: portrait, quote, bio, years, classes a week, certifications, classes coached |
/membership |
src/app/membership/page.tsx |
Three plans with a monthly/yearly switch, comparison table, add-ons, BMI and calorie calculator, FAQ |
/trial |
src/app/trial/page.tsx |
Three-step free-trial form: goal, details, review |
/contact |
src/app/contact/page.tsx |
Two clubs with stylised maps, open-now status and opening hours, contact form |
| 404 | src/app/not-found.tsx |
"Looks like a rest day" with links home and to the timetable |
Classes and coaches each use one route with a query string (/class?type=box, /trainer?id=lena) rather than a
dynamic segment, so the whole site can be a static export. The footer (with the newsletter form) is on every page.
Project structure#
src/
├── app/
│ ├── layout.tsx fonts, site metadata, header, footer, toast, reveal observer, back-to-top
│ ├── page.tsx home
│ ├── <route>/page.tsx one folder per page (classes, class, trainers, trainer, membership, trial, contact)
│ ├── not-found.tsx 404 page
│ ├── globals.css Tailwind import, design tokens (@theme) and component classes
│ └── icon.svg favicon
├── components/
│ ├── Timetable.tsx timetable, filters, day tabs and booking dialog
│ ├── ClassView.tsx class page (ClassFromUrl reads ?type=)
│ ├── TrainerView.tsx coach page (TrainerFromUrl reads ?id=)
│ ├── CoachGrid.tsx coach cards with the specialty filter
│ ├── Cards.tsx ClassCard, CoachCard, PageHero, stagger()
│ ├── NextUp.tsx home page "next up today"
│ ├── OpenStatus.tsx OpenStatus (open now / closed) and HoursList
│ ├── SpotsBadge.tsx "Booked", "Full · waitlist", "2 left", "9 spots"
│ ├── Billing.tsx monthly/yearly switch, Price, BillingNote
│ ├── Calculator.tsx BMI and calorie calculator
│ ├── TrialForm.tsx three-step free-trial form
│ ├── ContactForm.tsx contact form (ContactFromUrl reads ?topic=)
│ ├── NewsletterForm.tsx footer sign-up
│ ├── FieldError.tsx inline error message for a field
│ ├── SiteHeader.tsx header and mobile menu
│ ├── SiteFooter.tsx footer links, club status, newsletter
│ └── CountUp, Toast, RevealObserver, BackToTop, Icons, Year
├── data/
│ ├── forge.ts TIMEZONE, CLASSES, COACHES, LOCATIONS, SESSIONS
│ ├── site.ts PLANS, COMPARE, ADDONS, MEMBERSHIP_FAQ, STORIES, FACILITIES, CLUBS
│ └── nav.ts header and footer links
├── fonts/ Anton and Inter (woff2) with their OFL licences
└── lib/
├── club.ts club time, opening status, isClass()/isCoach(), DAY_NAMES
├── client.ts useClubNow(), bookings in localStorage, toast()
├── validation.ts useValidation(), fieldMessage(), fieldsIn()
├── title.ts useDocumentTitle()
├── asset.ts asset(): prefixes public files with the base path
└── metadata.ts pageMetadata(): title, description and Open Graph per page
public/img/ SVG artwork, og-image.svg and og-image.png
next.config.ts static export and optional base path
template.json template metadata used by freeframe.devCustomising#
Brand and colours#
Design tokens live in the @theme block at the top of src/app/globals.css:
| Token | Used for |
|---|---|
--color-bg, --color-surface, --color-raised |
Page, card and raised panel backgrounds |
--color-line |
Borders |
--color-ink, --color-muted |
Text and muted text |
--color-volt |
Main accent (#c8ff2e): buttons, highlights, focus rings, "booked" |
--color-volt-dim |
Accent hover |
--color-heat |
High intensity and "few spots left" |
--color-cool |
Low intensity |
For example, an orange accent:
@theme {
--color-volt: #ff8a1f;
--color-volt-dim: #e5730a;
}The accent sits behind dark text (buttons, the toast, selected chips), so keep it bright. Tailwind compiles in
pnpm dev and pnpm build, so there's no separate CSS step.
The accent also appears as a literal value in a few places; search for c8ff2e and 200 255 46 when you change it:
globals.css (focus glow, selected option cards, .glow), the SVG figures in TrialForm.tsx, not-found.tsx and
the map pins in src/app/contact/page.tsx, the gauge in Calculator.tsx, accent-[#c8ff2e] on the trial terms
checkbox, and the artwork in public/img/ and src/app/icon.svg. The browser theme colour is viewport.themeColor
in src/app/layout.tsx.
The design is dark by design: viewport.colorScheme is "dark" and there is no light theme or theme toggle.
Typography#
Headlines use Anton (--font-display, a tall condensed face, via the .display class) and text uses Inter
(--font-sans). Both are self-hosted with next/font/local in src/app/layout.tsx, from the same Latin files Google
Fonts serves, so the visitor's browser never contacts Google:
const anton = localFont({ src: "../fonts/Anton-latin.woff2", variable: "--font-anton", weight: "400", display: "swap" });
const inter = localFont({ src: "../fonts/Inter-latin.woff2", variable: "--font-inter", weight: "400 800", display: "swap" });The files and their SIL Open Font License texts (Anton-OFL.txt, Inter-OFL.txt) are in src/fonts/. To swap a
font, add its .woff2 to src/fonts/ and point src at it (or import a family from next/font/google), keep the
same variable name, and update the fallback names in --font-display / --font-sans in globals.css.
Content#
Classes, coaches, clubs and the timetable are typed data in src/data/forge.ts:
| Export | What it holds | Used by |
|---|---|---|
TIMEZONE |
The clubs' IANA time zone ("America/New_York") |
Open-now status, today's hours, "next up", today in the timetable |
CLASSES |
Class types keyed by id (strength, ignite, ride, box, flow, engine): name, short, desc, intensity (1–3), duration (minutes), kcal, expect, equipment, who, tag |
Home cards, timetable, /class |
DEFAULT_CLASS |
Class shown on /class without a valid ?type= ("strength") |
/class |
COACHES |
Coaches keyed by id (maya, dev, jonah, lena, sami, chloe): name, role, tags, classes, years, certs, quote, bio, handle |
Coach cards, filter, /trainer, timetable |
DEFAULT_COACH |
Coach shown on /trainer without a valid ?id= ("maya") |
/trainer |
LOCATIONS |
Clubs keyed by id (harbor, northgate): name and hours, one [open, close] pair per weekday, Monday first |
Open status, hours lists |
SESSIONS |
The weekly timetable: id, day (0 = Monday … 6 = Sunday), time, type, coach, loc, cap, booked |
Timetable, "next up", class pages |
src/data/site.ts holds the rest: PLANS (id, name, blurb, monthly and annual price per month, annualNote,
features, popular), COMPARE (the comparison table rows; true/false render as included/not included),
ADDONS, MEMBERSHIP_FAQ, STORIES (member stories on the home page), FACILITIES and CLUBS (address and phone
per location).
To add a session, add an object to SESSIONS. TypeScript checks that type, coach and loc are existing ids:
{ id: "s36", day: 6, time: "12:00", type: "box", coach: "jonah", loc: "northgate", cap: 16, booked: 3 },Keep id unique and stable: it's what visitors' saved bookings refer to.
To add a class, add an entry to CLASSES (its key is the id used in /class?type=), add
public/img/class-<id>.svg, add its id to the classes of the coaches who teach it and add sessions. The home cards,
ticker, filters and class page pick it up automatically. To add a coach, add an entry to COACHES and
public/img/coach-<id>.svg; use specialties from the FILTERS list in CoachGrid.tsx in tags (or add a filter).
Some content is written directly in components and pages and must be edited there:
- the hero stats ("2,400+ members", "36 classes / week", "4.9") and results figures in
src/app/page.tsx, and the three home-page coaches (FEATURED_COACHES); - the plan options and prices in the trial form (
PLANSinTrialForm.tsx, keep them in sync withPLANSinsite.ts), its home club options, and the "what's included" list (INCLUDED) insrc/app/trial/page.tsx; - the email addresses (
EMAILS) and map drawings (MAPS, one per club id) insrc/app/contact/page.tsx, and the topics (TOPICS) and club options inContactForm.tsx; - the footer's fallback hours, social links and legal links in
SiteFooter.tsx, and the "Open from 05:30" line in the mobile menu inSiteHeader.tsx; - the personal training price on
/trainers.
To add a location, add it to LOCATIONS (hours) and CLUBS (address), add a MAPS entry on the contact page, and
update the footer, trial and contact form options.
Images and artwork#
Artwork is SVG in public/img/:
| Files | Size (aspect ratio) | Where |
|---|---|---|
hero.svg |
640 × 680 | Home hero |
class-<id>.svg |
640 × 480 (4:3) | Class cards and class page |
coach-<id>.svg |
400 × 480 (5:6) | Coach cards and coach page |
og-image.svg |
1200 × 630 | Source of the social image |
og-image.png |
1200 × 630 | Social image used by pageMetadata() |
favicon.svg |
32 × 32 | Copy of src/app/icon.svg |
src/app/icon.svg is the favicon (Next.js file convention). The contact page maps, the trial goal figures and the
404 figure are inline SVG in their components. After editing og-image.svg, export it again as a 1200 × 630
og-image.png.
Always reference files in public/ through asset(), so they keep working when the site is served from a sub-folder:
<img src={asset(`/img/class-${type}.svg`)} alt="" width={640} height={480} className="w-full rounded-2xl border border-line" />To use photos, add .jpg/.webp files at the same aspect ratio and change the paths in Cards.tsx, ClassView.tsx,
TrainerView.tsx and src/app/page.tsx. The site uses plain <img> on purpose: with a static export, next/image
can't optimise images, so the @next/next/no-img-element lint rule is turned off in eslint.config.mjs.
Navigation, header and footer#
- Header links are
NAVinsrc/data/nav.ts.SiteHeader.tsxrenders them in the desktop menu and the numbered mobile menu and marks the current section witharia-current="page"(a class page counts as Classes, a coach page as Trainers, through itsSECTIONSmap). - The footer link columns are
FOOTER_TRAINandFOOTER_CLUBin the same file;SiteFooter.tsxrenders them with the club status and the newsletter form. - To add a page, create
src/app/<route>/page.tsx, exportmetadata = pageMetadata(title, description), and add aNAVor footer entry if it belongs in a menu.
Features#
Club time and opening hours#
Everything that depends on "now" uses the time at the clubs, not on the visitor's device: businessNow() in
src/lib/club.ts reads the date, weekday and minutes in TIMEZONE with Intl.DateTimeFormat. Set TIMEZONE in
src/data/forge.ts to your club's IANA zone (for example "Europe/London").
The static HTML can't know the time, so useClubNow() in src/lib/client.ts returns null on the server and during
hydration, then the club time in the browser, updated every minute. Until then components show a neutral fallback:
OpenStatusshows "Open now · until 22:00", "Closed · opens 05:30" or "Closed · opens 06:00 tomorrow" (fromopenStatus()), or itsfallbacktext before hydration. Withbadgeit renders the pill on the contact page, highlighted while open.HoursListlists a club's hours, Monday first, and highlights today's row witharia-current="date".
Timetable#
Timetable.tsx renders every session in SESSIONS on /classes:
- On large screens all seven days show side by side; on phones day tabs show one day at a time, starting on today. The tabs follow the ARIA tabs pattern: Left/Right arrows move between days, Home and End jump to the ends, and only the selected tab is in the tab order.
- Three selects filter by class, intensity and coach; Reset clears them, and a polite live region says how many classes match ("24 classes this week"). Days with no match say "No matching classes."
- Each session shows its time, an intensity meter, coach, club and a
SpotsBadge: "9 spots", "2 left" (3 or fewer), "Full · waitlist" or "Booked". - Today's column is marked "Today" once the browser knows the club time.
Links can preselect filters and the day: /classes?type=box, /classes?coach=lena, /classes?day=4 (0 = Monday).
TimetableFromUrl reads them with useSearchParams() inside <Suspense> in src/app/classes/page.tsx; the
fallback is the unfiltered timetable, so the static HTML is complete. Ids match in any letter case, and only the
data's own keys count (isClass()/isCoach() in src/lib/club.ts), so unknown values are ignored. With JavaScript
off, a plain list of the week is shown in a <noscript> block.
Booking dialog#
Clicking a session opens a native <dialog id="booking-modal"> with the day and time, coach, club, duration, spots
left and the class summary, plus one action: Book my spot, Cancel booking, or Join waitlist when the class
is full. Booking adds the session id to the visitor's bookings (see Saved data) and shows
a toast; the session then shows "Booked" everywhere. The waitlist only shows a message. Esc, the close button or a
click on the backdrop closes the dialog, and focus returns to the session. Clear my bookings below the timetable
removes them all.
cap and booked in SESSIONS are demo numbers that never change for other visitors. For real bookings, connect a
class-booking or gym-management service: replace act() in Timetable.tsx with a call to its API, or link each
session to its booking page.
Next up today#
NextUp.tsx on the home page lists the next four sessions later today at the clubs (or tomorrow's first four once
today's are over), each linking to that day on the timetable (/classes?day=<n>). Before hydration it shows a link to
the timetable instead.
Class and coach pages#
/class and /trainer are one static page each. src/app/class/page.tsx renders ClassFromUrl inside
<Suspense> with <ClassView type={DEFAULT_CLASS} /> as the fallback, so the exported HTML contains the full default
class (Strength Lab); in the browser ClassFromUrl reads ?type= with useSearchParams() and shows that class.
/trainer?id= works the same way with TrainerFromUrl, TrainerView and DEFAULT_COACH (Maya Torres).
Ids are case-insensitive (?type=BOX works like ?type=box) and must be the data's own keys; anything else shows the
default. useDocumentTitle() in src/lib/title.ts sets the page title to the chosen class or coach (and re-applies
it if Next.js rewrites the head); the class page also updates the meta description.
The class page lists the coaches who teach it (from each coach's classes), and once the club time is known, this
week's sessions in order from today, each linking to /classes?type=<id>&day=<n>. The coach page counts the coach's
weekly sessions and links to /classes?coach=<id>.
Coach filter#
CoachGrid.tsx on /trainers has filter buttons (aria-pressed, from its FILTERS list) that show the coaches whose
tags include that specialty (case-insensitive); All shows everyone. A polite live region announces the count.
Membership pricing switch#
BillingToggle in Billing.tsx is a role="switch" button with aria-checked. It shares the choice through
BillingProvider with every Price (monthly or annual from PLANS) and BillingNote ("Billed monthly · cancel
anytime" or the plan's annualNote). The choice isn't saved.
BMI and calorie calculator#
Calculator.tsx (on /membership#calculator) takes units (metric or imperial), height, weight, age, sex and activity
level and shows:
- BMI = weight (kg) ÷ height (m)², with a category (under 18.5, 18.5–25, 25–30, 30 and over) and a marker on a 15–40 scale;
- BMR with the Mifflin-St Jeor equation, and maintenance calories (BMR × 1.2, 1.375, 1.55 or 1.725);
- targets for fat loss (maintenance − 450 kcal) and muscle gain (+ 300 kcal), set in
onSubmit().
Results are in a polite live region. The figures are estimates, not medical advice; keep the disclaimer.
Toasts#
toast(message) from src/lib/client.ts works from any client component: it dispatches a toast event that the
<Toast /> in the layout shows for about three seconds in a polite live region.
Header, mobile menu, reveals and counters#
SiteHeader.tsx turns the header solid after scrolling. The menu button toggles the full-screen mobile menu
(aria-expanded, Esc closes it and returns focus to the button, and it closes on navigation or on wide screens).
RevealObserver.tsx fades in .reveal elements as they scroll into view, revealing everything above too; with
reduced motion, or before JavaScript runs (the js class is set by a small script in the layout), content is visible
straight away. CountUp.tsx counts numbers up once when visible; the final value is in the HTML, and with reduced
motion it isn't animated. The class-name ticker is hidden from screen readers, pauses on hover and stops with reduced
motion.
Back to top#
BackToTop.tsx, mounted once in the layout, shows a button after about one screen of scrolling. It scrolls to the top
(instantly with reduced motion), moves focus to <main>, lifts above the toast and elements marked
data-back-to-top-avoid, and hides while it would cover a pinned element marked data-back-to-top-hide. To remove
it, delete <BackToTop /> from the layout and the .back-to-top block at the end of globals.css.
Forms#
| Form | Where | Validates |
|---|---|---|
Free trial (data-trial) |
TrialForm.tsx |
Step 1: a goal; step 2: first and last name, email, phone (at least 7 digits, spaces or + ( ) -), start date from today; step 3: terms checkbox |
| Contact | ContactForm.tsx |
Name, email, message of at least 10 characters |
| Newsletter | NewsletterForm.tsx |
|
Calculator (data-calc) |
Calculator.tsx |
Height, weight and age within range (nothing to send) |
Validation comes from useValidation() in src/lib/validation.ts: spread field(id) on an input and render
<FieldError id={id} message={errors[id]} /> after it. It uses the fields' own HTML constraints (required,
type="email", pattern, min, max, minLength), shows messages from fieldMessage() on blur and on submit,
links them with aria-describedby/aria-invalid, and focuses the first invalid field. The trial start date's min
is today at the clubs.
The trial form moves focus to each step's heading and marks the current step with aria-current="step". ?plan=
preselects a plan (/trial?plan=elite; the value must be offpeak, unlimited or elite), and the membership
page's plan buttons link there. ?topic= preselects the contact topic (/contact?topic=pt; membership, pt,
corporate or other). Both match in any letter case (?plan=Elite, ?topic=PT), and only the listed values count;
both are read with useSearchParams() inside <Suspense>.
Nothing is sent until you connect the forms; the success messages say so. Each submit handler marks the place:
// To send for real: await fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(form) })To post to Formspree (or a similar service), make the handler async and replace that comment.
In ContactForm.tsx:
onSubmit={async (e) => {
e.preventDefault();
const form = e.currentTarget;
if (!v.validate(fieldsIn(form))) return;
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
form.reset();
flushSync(() => setDone(true));
success.current?.focus();
}}Do the same in NewsletterForm.tsx (or send to your email provider) and in onSubmit() in TrialForm.tsx, before
setDoneName(...) (use new FormData(e.currentTarget); every field has a name). Update the success messages, which
currently say the form is a demo.
If you remove the static export (see Building and deploying), you can post to your own
Route Handler instead (for example src/app/api/contact/route.ts exporting POST), or turn a form into a
Server Action. Validate again on the server either way.
Saved data (localStorage)#
| Key | Shape |
|---|---|
forge-bookings (BOOK_KEY) |
Array of session ids, for example ["s04", "s17"] |
The helpers are in src/lib/client.ts. getBookings() validates the value on every read: anything that isn't an
array of known, unique session ids is dropped and the stored copy is repaired, so corrupt or tampered data can't break
the timetable. setBookings() saves a list and notifies every component; useBookings() keeps the timetable, badges,
"next up" and class pages in sync (and across tabs through the storage event). On the server it returns an empty
list, so bookings appear after hydration. spotsLeft() counts the visitor's own booking against cap.
Bookings are per browser. Visitors clear them with Clear my bookings on /classes; developers can run
localStorage.removeItem("forge-bookings"). If you remove or rename a session id, old bookings for it are ignored and
cleaned up.
SEO and sharing#
- Site-wide defaults are
metadatainsrc/app/layout.tsx. Each page exportsmetadata = pageMetadata(title, description)fromsrc/lib/metadata.ts, which sets the title as is, the description, Open Graph tags (site name "Forge Athletic Club", imageasset("/img/og-image.png")) and a large Twitter card. metadataBaseisprocess.env.NEXT_PUBLIC_SITE_URL(falling back tohttps://example.com). Set it when you build so social images get absolute URLs:
NEXT_PUBLIC_SITE_URL=https://www.your-gym.com pnpm build/classand/trainerare single static pages, so their HTML metadata is the default class and coach; the title for other ids is set in the browser. Search engines that don't run JavaScript only see the defaults. If class or coach pages matter for search, switch to dynamic routes (/classes/[id],/trainers/[id]) withgenerateStaticParams().
For launch, consider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export), and
ExerciseGym structured data with each club's address and opening hours as JSON-LD.
Accessibility#
Built in:
- a "Skip to content" link (in
layout.tsx), labelled landmarks and oneh1per page; - visible focus styles (
:focus-visibleinglobals.css); - the ARIA tabs pattern for day tabs, a native
<dialog>with focus return,aria-pressedfilter buttons,role="switch"witharia-checkedfor billing,aria-currentfor navigation, steps and today's hours; - intensity shown as a labelled meter ("Intensity: High") and in words, not by colour alone;
- form errors linked with
aria-describedby/aria-invalid, focus moved to the first error and to each trial step; - live regions for timetable and coach counts, calculator results, success messages and toasts;
- reduced motion respected by reveals, counters, the ticker, smooth scrolling and the back-to-top button;
- WCAG AA contrast on the dark background.
When editing, keep one h1 per page, label every input, keep alt text meaningful, and only put the accent behind
dark text.
Building and deploying#
pnpm build writes a fully static site to out/ (output: "export" in next.config.ts). Deploy it anywhere:
- Vercel: import the repository; no settings needed.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directoryout. - Any static host: upload the contents of
out/.
To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/gym pnpm build. next.config.ts
passes it to basePath and to NEXT_PUBLIC_BASE_PATH, which asset() uses. pnpm build:demo does this for the
freeframe.dev demo and writes demo/, which you can delete.
Need server features (a Route Handler for forms or bookings, Server Actions, middleware)? Remove
output: "export" and the images.unoptimized setting from next.config.ts and deploy to a Node-capable host such
as Vercel. Everything else keeps working.
TypeScript setup#
package.json installs @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and
typescript as an alias of @typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain
still need the TypeScript 6 compiler API. Once typescript-eslint supports TypeScript 7, replace both with a single
typescript@7. eslint.config.mjs also pins settings.react.version, because eslint-plugin-react can't detect it
under ESLint 10.
Troubleshooting#
- A Tailwind class has no effect. Tailwind only generates classes it finds as complete strings in the source.
Write
"text-heat", not`text-${color}`, and inclassNametemplate literals keep a space before each${...}(as in`badge border border-line ${tone}`) so class names don't run together. - Images break when deployed to a sub-folder. Build with
DEMO_BASE_PATHset, and reference public files withasset()(links made withnext/linkget the base path automatically). - Social previews use
example.com. SetNEXT_PUBLIC_SITE_URLwhen building. - Open/closed status or "next up" looks wrong. Times are club time, not the visitor's: set
TIMEZONEinsrc/data/forge.tsto the club's IANA zone. A visitor in another zone sees the clubs' day and hours. - A class or coach page shows Strength Lab or Maya Torres instead of the one I linked. The
?type=/?id=value must be a key ofCLASSES/COACHES. useSearchParams() should be wrapped in a suspense boundaryduring build. Any component that reads the query string must render inside<Suspense>, as on/classes,/class,/trainer,/trialand/contact.- Bookings vanished or show old sessions. They're stored per browser; private windows and cleared site data reset
them. Use Clear my bookings or
localStorage.removeItem("forge-bookings")to start over. - Forms "send" but nothing arrives. Connect them as described in Forms.
License#
MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this
template, including the SVG artwork. Anton and Inter are licensed under the SIL Open Font License (src/fonts/).
Forge Athletic Club, its coaches and its members are fictional.