Overview#
Smile is a bright, friendly website for a family and cosmetic dental practice. It's built for Grin Lane Dental, a
fictional practice in Boise, Idaho, with four dentists, three hygienists and a patient care team. It includes a
five-step booking flow whose time slots come from each clinician's schedule and the length of the visit, a pricing page
with a cost estimator and a payment-plan calculator, an emergency symptom guide, a multi-step new patient form and
filterable treatments and team pages. Rounded Fredoka and Nunito type, a berry, bubble-pink, lemon and mint palette and
Pip the tooth mascot keep it warm and reassuring. This is the Next.js version: Next.js 16 (App Router, static export),
React 19, TypeScript and Tailwind CSS v4, with no other dependencies. This guide covers version 1.0.0 (see
template.json). Live demo: freeframe.dev/templates/smile.
All health content is general information written for a fictional practice. Have a qualified professional review every treatment description, triage rule and price 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 build |
Static export to out/ |
pnpm start |
Serve out/ locally |
pnpm lint |
ESLint |
pnpm typecheck |
Generate route types and run TypeScript |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with "Book an appointment" and "Dental emergency?" buttons, live open-now badge, next available checkup card, emergency strip, $99 new patient offer, six treatment cards, why-us cards, four dentists, review carousel, insurance and Grin Club teaser, hours with map, call-to-action band |
/treatments |
src/app/treatments/page.tsx |
16 treatments filterable by category (?category=), each with what to expect, duration, from-price and a booking link; general-information note; FAQ |
/pricing |
src/app/pricing/page.tsx |
Fee table (stacks into cards on phones), three Grin Club plans, cost estimator, illustrative payment-plan calculator, insurance help |
/team |
src/app/team/page.tsx |
Nine team members filterable by role (?role=) and by language, with training, specialities, days in the practice and "Book with" links |
/new-patients |
src/app/new-patients/page.tsx |
First-visit timeline, what to bring, nervous-patient guide in tabs, five-step patient form with review screen, privacy summary (#privacy) |
/book |
src/app/book/page.tsx |
Five-step booking flow with a summary sidebar; accepts ?reason= and ?clinician= |
/emergency |
src/app/emergency/page.tsx |
Call and book buttons, "call 911 first" box, three-question symptom guide, first-aid tips, emergency hours |
/contact |
src/app/contact/page.tsx |
Contact cards, validated contact form, map, hours, getting here and accessibility (#accessibility) |
| 404 | src/app/not-found.tsx |
Friendly error page with links (noindex) |
Project structure#
smile/
src/
app/
layout.tsx <html>, skip link, header, footer, toast, page effects, back to top, site metadata
fonts.ts Fredoka and Nunito (next/font/local)
globals.css Tailwind entry: @theme tokens and component classes
icon.svg favicon (the tooth logo)
opengraph-image.png social sharing image (1200 x 630)
page.tsx home page
not-found.tsx 404 page
treatments/ pricing/ team/ new-patients/ book/ emergency/ contact/ one page.tsx per route
components/
SiteHeader.tsx sticky header, main menu, mobile menu
SiteFooter.tsx footer with contact details, links and opening hours
Logo.tsx the tooth logo mark
Icon.tsx line icons (<Icon name="tooth" />)
Hours.tsx open-now badge, opening hours list, "Next checkup" card
Reviews.tsx home page review carousel
Treatments.tsx treatments list with category filter
Team.tsx team grid with role and language filters
useFilter.ts shared filter state (chip value in the URL)
FeeTable.tsx fee table on /pricing
Estimator.tsx cost estimator and the EstimateProvider it shares with the payment plan
PaymentPlan.tsx payment-plan calculator
Booking.tsx booking flow
Triage.tsx emergency symptom guide
Tabs.tsx accessible tabs (nervous-patient guide)
IntakeForm.tsx new patient form
ContactForm.tsx contact form
useValidation.tsx form validation hook and messages
Toast.tsx, BackToTop.tsx, PageEffects.tsx, Year.tsx small site-wide helpers
data/
practice.ts PRACTICE, HOURS, CLOSED_DATES, LUNCH, DAY_NAMES, CLINICIANS
booking.ts REASONS (visit types) and BOOKING (slot rules)
pricing.ts FEE_GROUPS, ESTIMATE_ITEMS, MEMBERSHIP, START_MEMBERS, FINANCE
treatments.ts TREATMENT_CATEGORIES and TREATMENTS
team.ts TEAM_ROLES, LANGUAGES and TEAM
triage.ts SYMPTOMS, FLAGS and OUTCOMES for the symptom guide
reviews.ts REVIEWS
nav.ts NAV (main menu)
lib/
time.ts dates and times in the practice's time zone, money()
schedule.ts openStatus(), hoursText(), slotsFor(), firstAvailable()
client.ts useNow() and toast()
metadata.ts pageMeta() for per-page titles and descriptions
asset.ts asset() prefixes public/ files with the base path
fonts/ Fredoka and Nunito woff2 files and their licence (OFL.txt)
public/img/ hero, map, portraits and the og-image.svg source
next.config.ts static export and base path
template.json, README.md, CHANGELOG.md, DOCS.mdCustomising#
Brand and colours#
All design tokens live in the @theme block at the top of src/app/globals.css. Tailwind turns each token into
utilities, so --color-berry-600 becomes bg-berry-600, text-berry-600 and so on.
| Token group | Used for |
|---|---|
--color-berry-50 to --color-berry-950 |
Brand blue-violet: buttons and links (600), dark sections and the footer (950) |
--color-bubble-50 to --color-bubble-800 |
Playful pink: emergency buttons and badges, tags |
--color-lemon-50 to --color-lemon-700 |
Sunny highlight: offer cards, squiggle underline, icon dots |
--color-mint-50 to --color-mint-800 |
Calm green: open-now badge, "included" tags, self-care states |
--color-ink, --color-body, --color-muted |
Headings, body text and secondary text |
--color-line, --color-cream, --color-cloud |
Borders, page background and soft panels |
To change the brand colour, replace the berry scale:
@theme {
--color-berry-50: #eef6ff;
--color-berry-100: #d9eaff;
/* ...keep 50 to 950 from light to dark... */
--color-berry-600: #1f6fd1;
--color-berry-950: #0c1f3d;
}Keep the 600 shade dark enough for white button text (at least 4.5:1 contrast). A few colours are written as hex
values in the SVG files and inline SVGs (for example #5146d4 for berry and #1d1a4d for ink, also in
src/components/Logo.tsx), so search and replace those too. The theme colour (#fffaf3, the cream background) is
viewport.themeColor in src/app/layout.tsx.
Component classes (.btn, .btn-primary, .btn-lemon, .btn-pink, .btn-outline, .card, .chip, .field,
.option, .slot, .cal-day, .faq, .open-pill, .outcome, .squiggle and more) are defined in
@layer components in the same file.
Typography#
Fredoka is the rounded display face for headings (font-display, also applied to h1 to h4 in the base layer) and
Nunito is the body face (font-sans). Both are self-hosted with next/font/local in src/app/fonts.ts, from the
exact woff2 files Google Fonts serves (a latin and a latin-ext file per family), so text sets exactly like the HTML
version and no request goes to Google. Each face keeps its plain family name, so globals.css refers to them by name:
--font-display: "Fredoka", "Nunito", ui-rounded, "SF Pro Rounded", system-ui, sans-serif;
--font-sans: "Nunito", ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;To swap a font, put its woff2 files in src/fonts/, point the localFont() calls in fonts.ts at them (with the new
family name in declarations), and change the two tokens.
Content#
Page copy lives in the page files (src/app/**/page.tsx). Practice data that drives the interactive features lives in
typed files in src/data/:
| File and export | What it holds |
|---|---|
practice.ts PRACTICE |
Name, time zone (America/Boise), time zone label, phone, email and address |
practice.ts HOURS |
Opening hours per weekday (0 = Sunday, null = closed), used by every hours list, the open-now badges and the booking slots |
practice.ts CLOSED_DATES, LUNCH |
Whole-practice closures (YYYY-MM-DD) and the lunch break on days longer than six hours |
practice.ts CLINICIANS |
Each clinician's name, short name, role, portrait and working weekdays |
booking.ts REASONS |
Visit types: label, length in minutes, price note, the line shown on step 1, icon, who offers it and minimum notice |
booking.ts BOOKING |
Slot interval, how far ahead people can book and the demo "taken" share |
pricing.ts FEE_GROUPS |
The fee table on /pricing |
pricing.ts ESTIMATE_ITEMS, MEMBERSHIP, START_MEMBERS, FINANCE |
Cost estimator treatments and prices, Grin Club prices and discount, payment-plan terms |
treatments.ts |
Category chips and the 16 treatment cards |
team.ts |
Role chips, languages and the nine team cards |
triage.ts |
Symptom guide symptoms, follow-up questions, red flags, tips and outcome wording |
reviews.ts |
Home page reviews |
The opening hours lists are generated from HOURS, so they always match the badges and the booking calendar. The
contact page's closure sentence is plain text; update it when you change CLOSED_DATES.
Add a clinician. Add an entry to CLINICIANS, list their id in the who array of each visit type they offer, add
their portrait to public/img/ and a card to TEAM:
// src/data/practice.ts
park: { name: "Dr. Grace Park", short: "Dr. Park", role: "Dentist", img: "/img/dr-park.svg", days: [1, 3, 5] },
// src/data/booking.ts
checkup: { label: "Checkup & clean", /* ... */ who: ["kowalski", "haddad", "brooks", "park"], notice: 240 },Team cards use role for the role filter (dentist, hygienist or care) and langs for the language filter
(values from LANGUAGES). A card whose href starts with tel: gets a "Call" button; otherwise it's a booking link
such as /book?clinician=park.
Add a treatment. Add an object to TREATMENTS with a category from TREATMENT_CATEGORIES (general,
cosmetic, orthodontics, implants, kids, emergency) and a reason from REASONS for its booking link. The
chip counts update automatically. If the treatment should be in the estimator, add it to ESTIMATE_ITEMS:
{ id: "veneer", name: "Porcelain veneer", price: 1150, qty: 0 },Add included: { per: "adult", count: 2 } (or per: "kid") when Grin Club covers it. Keep FEE_GROUPS, the
treatment prices and the estimator in step.
Images and artwork#
All artwork is SVG, so it stays sharp and needs no image optimisation:
public/img/hero.svg(560 x 520) is Pip the tooth mascot on the home page. The two floating cards over it are HTML.- Team portraits are 240 x 240 SVGs with a rounded square background. To use photos, keep a square crop (at least
480 x 480 pixels), save them in
public/img/and change theimgpaths inTEAMandCLINICIANSso the booking flow shows the same picture. Images are referenced withasset("/img/...")so they keep working under a base path. public/img/map.svgis an illustrated map. You can replace it with a static map image or an embedded map<iframe>(that adds an external request).- Smaller illustrations (why-us cards, page headers, 404, nervous-patient card) are inline SVG in the page files.
src/app/icon.svgis the favicon; the same mark isLogoMarkinsrc/components/Logo.tsx.- The social image is
src/app/opengraph-image.png(1200 x 630). Its source ispublic/img/og-image.svg. After editing the SVG, export it to PNG at 1200 x 630, because social sites don't display SVG.
Navigation, header and footer#
The main menu is NAV in src/data/nav.ts; SiteHeader.tsx adds Home and "Dental emergency" to the mobile menu and
marks links to the current page with aria-current="page". The "Emergency?" pill shows from 1280 px wide. Footer link
lists are EXPLORE and PATIENTS at the top of SiteFooter.tsx. To add a page, create src/app/<route>/page.tsx
with export const metadata = pageMeta(...) and add it to NAV and the footer lists.
Features#
Booking flow#
/book has five steps: visit type, clinician, date and time, details and confirmation. The code is
src/components/Booking.tsx; the slot rules are in src/lib/schedule.ts.
- Visit type lists
REASONSin order. - Clinician shows "First available" plus the visit type's
wholist with each clinician's role, working days and next opening. If a preselected clinician doesn't offer the chosen visit type, "First available" is used. - Date and time.
slotsFor(reasonId, clinician, dateKey, now)builds start times everyBOOKING.slotStepminutes inside each clinician's working windows (practiceHOURSon theirdays, minusLUNCHon long days), keeping only times where the whole visit (minutes) fits before the window ends. Times before now plus the visit'snoticeare skipped, as areCLOSED_DATESand anything beyondBOOKING.daysAhead. With "First available", each time is offered by the first free clinician inwhoorder and the slot shows their name. Days with times get a green dot; "First available" jumps to the earliest day and selects its first time. - Demo availability.
BOOKING.demoBusyShare(0.55) marks a stable, pseudo-random share of slots as taken (hash()of clinician, date and time) so the demo looks realistic. Set it to0when you load real availability. - Details are validated (see Forms). The confirmation shows a summary, a clear demo notice and an "Add to calendar (.ics)" button that downloads a tentative event converted from Boise time to UTC.
- Links in.
/book?reason=emergencypreselects a visit type;/book?clinician=reyespreselects a clinician.
All times are practice times (PRACTICE.timeZone), because patients come in person, whatever the visitor's own time
zone. To move the practice, change timeZone and zoneName. The calendar and slots are worked out in the browser
(useNow() in src/lib/client.ts), so the static export never shows stale times.
Accessibility: each step's heading receives focus when it opens; the progress bar marks the current step with
aria-current="step"; the calendar uses a roving tabindex (arrow keys move by day and week, Home and End jump to
the start and end of the week, Page Up and Page Down move four weeks) and each day has a full label such as
"Thursday, October 15, 2026, 6 times available". Unavailable days use aria-disabled. Errors are announced next to
the step's buttons.
Cost estimator#
src/components/Estimator.tsx renders a row with a stepper for each entry in ESTIMATE_ITEMS, plus steppers for the
number of adults and kids in the plan (starting values in START_MEMBERS). estimate() compares:
- Pay as you go: quantity x price for every item.
- With Grin Club: yearly membership (
MEMBERSHIP.adultYearper adult,MEMBERSHIP.kidYearper child), items covered byincludedfree up to their yearly count per member, and everything else atMEMBERSHIP.discountoff. With nobody in the plan it shows "—".
The cheaper option gets a "Best value" tag and a sentence explains the saving. Typed quantities apply when the field
is left or Enter is pressed and are kept between 0 and 32 (members 0 to 8). Results are in an aria-live region; each
stepper is a labelled group with "Fewer" and "More" buttons and a number input. EstimateProvider (wrapping both
sections in src/app/pricing/page.tsx) shares the chosen quantities with the payment plan.
Payment-plan calculator#
src/components/PaymentPlan.tsx: visitors enter a cost between FINANCE.min and FINANCE.max (or use the slider,
or "Use my estimate from above") and choose a term from FINANCE.terms. planFor() uses the standard amortised loan
formula, or a simple division for 0% terms. An amount out of range shows an error and keeps the last result. The rates
are deliberately labelled as made-up examples; replace them with your financing provider's published terms or remove
the section.
Emergency symptom guide#
src/components/Triage.tsx, with data in src/data/triage.ts:
- The main problem (one of
SYMPTOMS). - Red flags (
FLAGS; "None of these" clears the others). A flag with level"911"sends people straight to the "Call 911" outcome;"call"sends them to "Call us now". - A yes or no follow-up question from the symptom's
ask. Symptoms withask: nullgo straight to theiroutcome.
Outcomes are 911, call, today and self (OUTCOMES). Each shows its own steps plus the symptom's tip and the
matching buttons (Ctas in Triage.tsx). The wording is intentionally cautious and the page repeats that it's general
information, not a diagnosis. If you change the rules, keep a route to emergency services for breathing problems,
spreading swelling and head injuries. Each question's heading receives focus as it opens.
New patient form#
src/components/IntakeForm.tsx has five fieldsets (data-intake-step) with Back and Next buttons, a progress bar, a
"None of these" option that clears the other medical history boxes, a typed signature that must match the first and
last name from step 1 (case and spacing ignored), and a review screen with an "Edit" button per section. Each step is
validated before moving on; Enter in a field moves to the next step. See Forms.
Filters#
/treatments (Treatments.tsx) and /team (Team.tsx) share useFilter(): chips with aria-pressed and live
counts, an optional "Speaks" select on the team page, an aria-live status ("Showing 3 treatments."), an empty state
with a reset button that returns focus to the first chip. The chip value is kept in the URL (?category= or
?role=), so filtered views can be shared; unknown values show everything. Items are hidden with the hidden
attribute rather than removed, so their ids (/treatments#whitening) always exist.
Opening hours and open-now badge#
src/components/Hours.tsx: HoursList renders HOURS (Monday first) and highlights today's row, adding "(today)"
for screen readers; OpenStatus shows "Open now · until 5:00 pm" or "Closed · opens tomorrow 8:00 am" from
openStatus(); NextCheckup finds the first open checkup slot for the home page card. All of them use the practice's
time zone, update every minute and are filled in by the browser (the badge is hidden until then), so a static export
never shows the build time.
Tabs, FAQ and carousel#
- The nervous-patient guide uses
Tabs.tsx(ARIA tabs pattern: arrow keys, Home and End). Panels are passed in from the page as JSX. - FAQ items on
/treatmentsare native<details>elements styled with.faq. - The review carousel (
Reviews.tsx) is a horizontally scrolling list with scroll snapping and previous and next buttons that disable at each end. It's keyboard-scrollable and never moves on its own.
Scroll reveals, motion and back to top#
Elements with .reveal fade up as they enter the viewport (PageEffects.tsx; stagger with
style={{ "--d": "120ms" }}). Revealing one element also reveals everything before it, so jumping with the End key or
an anchor never leaves gaps. Floating cards, the open-now pulse and the card hovers stop for visitors who prefer reduced
motion. To remove the back-to-top button, delete <BackToTop /> from layout.tsx and the .back-to-top styles in
globals.css.
Forms#
Every form is a demo: it validates in the browser and shows a success state, but nothing is sent, booked or stored until you connect it. The confirmation screens say so.
Validation is the useValidation() hook in src/components/useValidation.tsx. 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, ticked or chosen (data-required-msg sets a custom message) |
type="email" |
Must look like an email address |
data-phone |
At least 10 digits |
data-past-date |
A valid date in the past (date of birth) |
minLength |
Minimum length |
data-match |
Must equal data-match-value (set by the patient form for the signature) |
data-label |
The field name used in messages ("First name is required.") |
Errors appear under the field (or under the group for radio buttons), are linked with aria-describedby, set
aria-invalid and focus the first problem; they clear as the visitor fixes them.
| Form | File | Validates |
|---|---|---|
| Contact form | src/components/ContactForm.tsx |
Name, email, optional phone, topic, message of 10+ characters |
| Booking details | src/components/Booking.tsx |
First and last name, email, mobile, new or returning, consent |
| New patient form | src/components/IntakeForm.tsx |
Name, date of birth, phone, email, contact preference, visit reason, last visit, how nervous, medical history, consent and signature |
Connecting the contact form#
The contact form is a good fit for a form service such as Formspree. In onSubmit() in ContactForm.tsx, send the
data before showing the thank-you message:
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!v.validate(e.currentTarget)) return;
const data = new FormData(e.currentTarget);
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 us instead.");
setName(String(data.get("name")).trim().split(" ")[0]);
setFocus("done");
}(toast comes from @/lib/client.) Then remove the "This is a demo" sentence from the success panel. Ask people not
to include medical details in this form (the page already does).
If you remove the static export (see Building and deploying), you can post to a Route
Handler instead (src/app/api/contact/route.ts with export async function POST(request: Request)) or call a Server
Action from onSubmit.
Connecting the booking flow#
Bookings belong in your practice-management system (PMS). Most offer an online-scheduling API or an embeddable
booking widget. Either replace <Booking /> in src/app/book/page.tsx with the widget, or keep this flow and:
- Load real open times from the PMS (through your own server, so API keys stay private) and return them from
slotsFor()insrc/lib/schedule.tsin the same{ mins, who }shape. SetBOOKING.demoBusyShareto0. - In
onSubmit()inBooking.tsx, POST the visit type, clinician, date, time and details to your server (or a Route Handler), which creates the appointment in the PMS. - Show step 5 only after the PMS confirms, and change the confirmation copy to match.
Booking details are health information. Use HTTPS and a vendor that will sign a Business Associate Agreement (BAA) in the US or a Data Processing Agreement (DPA) under the GDPR in the EU and UK.
Connecting the new patient form#
The patient form collects health information, so a general-purpose form service or plain email isn't appropriate:
- US (HIPAA): post it only to a HIPAA-eligible form or intake service that signs a BAA, or use your PMS's own patient intake forms and link to them instead.
- EU and UK (GDPR): health data is special category data. Use a processor with a DPA, keep the explicit consent checkbox in step 4, collect only what you need and state your retention period in your privacy notice.
Replace the demo branch in onSubmit() in IntakeForm.tsx with a POST of new FormData(e.currentTarget) to that
service. The answers are deliberately never written to localStorage, cookies or anywhere else; keep it that way.
SEO and sharing#
Each route exports metadata built with pageMeta(title, description) from src/lib/metadata.ts, which sets the
exact <title>, the description, Open Graph tags and a Twitter card; the image comes from
src/app/opengraph-image.png. The 404 page adds robots: { index: false }. Social networks need absolute image URLs,
so set your domain when building; it becomes metadataBase in layout.tsx:
SITE_URL=https://www.your-domain.com pnpm buildConsider adding src/app/sitemap.ts and src/app/robots.ts listing your routes, and Dentist structured data
(schema.org JSON-LD) on the home page with your real name, address, phone, hours and geo-coordinates. The template
doesn't include structured data because it would describe a fictional practice.
Accessibility#
Built in:
- A skip link, landmarks (
header,nav,main,footer), one<h1>per page and a logical heading order. - Visible focus outlines on every interactive element and colour contrast that meets WCAG AA.
- An accessible mobile menu (
aria-expanded,aria-controls; Escape closes it and returns focus). - ARIA patterns: tabs,
aria-pressedfilter chips and calendar days,aria-currentfor the current page and booking step,aria-liveregions for filter counts, estimates and slot headings. - Forms with real labels, grouped radio buttons and checkboxes in
fieldsetandlegend, inline errors linked witharia-describedby, and focus moved to the first error or the next step's heading. - Decorative SVGs are hidden with
aria-hidden="true"; meaningful images have alt text. - Reduced motion turns off reveals, floating cards, the pulse and hover lifts.
When you edit:
- Keep exactly one
<h1>per page and don't skip heading levels. - Give every new input a
<label>and adddata-validatewhen it needs checking. - Check contrast when you change colours, especially white text on the 600 shades.
- Keep the emergency guidance visible and plain: it's the content people need most when they're stressed.
Building and deploying#
pnpm build # static site in out/
pnpm start # preview out/next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH. To host the site
in a sub-folder, build with that folder (DEMO_BASE_PATH=/smile pnpm build); links, scripts and asset() image URLs
all pick it up. Deploy out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the
repository into Vercel. Most hosts serve 404.html for missing pages automatically. demo/ is the freeframe.dev demo
build and can be deleted. Serve the site over HTTPS, especially once forms are connected.
To use server features (Route Handlers, Server Actions), remove output: "export" from next.config.ts and deploy to
Vercel or any Node.js host with next build and next start.
TypeScript 7 setup#
@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to
@typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6 API.
Replace both with typescript@7 once typescript-eslint supports it.
Troubleshooting#
| Problem | Fix |
|---|---|
| The open-now badge looks wrong | It uses PRACTICE.timeZone, HOURS and CLOSED_DATES in src/data/practice.ts, not the visitor's clock. Check the zone is an IANA name such as America/Denver |
| The booking calendar shows no times | Check that the visit type's who clinicians work on days that are open in HOURS, that the visit minutes fit inside the opening hours, and that the dates aren't in CLOSED_DATES or beyond BOOKING.daysAhead. Late in the day, today may have no times because of the visit's notice |
| Times in the downloaded .ics file are an hour off | wallToInstant() in src/lib/time.ts converts practice time to UTC with PRACTICE.timeZone; make sure it's a valid IANA zone |
| A filter link opens unfiltered | The parameter must match a chip value exactly, for example /treatments?category=orthodontics or /team?role=hygienist |
| A new treatment or team member never shows | Its category or role must be one of the chip values, and team langs must use LANGUAGES values |
| The social preview has no image | Build with SITE_URL set to your domain so og:image is an absolute URL |
| Images or styles are missing under a sub-folder | Build with DEMO_BASE_PATH set to that folder and use asset() for files in public/ |
| Content stays invisible after editing | .reveal elements are hidden until PageEffects reveals them; check the browser console for errors |
License#
MIT, see the repository license (included as LICENSE.txt in the download). All artwork (the mascot, portraits, map,
icons and social image) is original to this template and covered by the same license. Fredoka and Nunito are under the
SIL Open Font License 1.1 (src/fonts/OFL.txt). The practice, people, reviews and prices are fictional.