Overview#
Bloom is a nine-page website for a hair salon and day spa. The demo brand is Bloom Hair & Spa, a fictional independent salon at 48 Orchard Lane, Bristol. Highlights are a five-step booking wizard that works out real time slots from opening hours, staff working days and treatment length, level-based pricing (stylist, senior, director), a gift card builder with a live preview, filterable service and team pages, a gallery with saved looks and a lightbox, a before/after slider and a live "Open now" status. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.2. It builds to static HTML files with no UI framework and can be hosted anywhere. See it live at freeframe.dev/templates/bloom.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/bloom into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | Notable sections and features |
|---|---|---|
/ |
src/pages/index.astro |
Hero, signature treatments, before/after slider, team, reviews, gift cards, newsletter |
/services |
src/pages/services.astro |
Full menu by category, category filter with count, "Book" links per treatment |
/team |
src/pages/team.astro |
Specialty filter, working days, pricing levels, "Book with" links |
/book |
src/pages/book.astro |
Five-step booking wizard with a running summary |
/gift-cards |
src/pages/gift-cards.astro |
Amount, design, message and delivery date with a live card preview |
/gallery |
src/pages/gallery.astro |
Look filters, saved looks, lightbox with "Book with" link |
/about |
src/pages/about.astro |
Story, values and milestones |
/contact |
src/pages/contact.astro |
Map, opening hours with today highlighted, open-now status, contact form |
| 404 | src/pages/404.astro |
Not-found page (most hosts show 404.html automatically) |
Every page shares the header, the footer (with an open-now badge and the newsletter form) and the back-to-top button.
Project structure#
bloom/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro wordmark, desktop navigation, "Book now", mobile menu
│ │ └─ SiteFooter.astro contact details, open-now badge, newsletter, links
│ ├─ data/
│ │ └─ bloom.ts salon details, hours, services, levels, team, looks, gift card options
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts all interactivity, in numbered sections
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (@theme tokens) and component styles
├─ public/img/
│ ├─ looks/ gallery looks, one SVG per look id
│ ├─ team/ team portraits, one SVG per team member id
│ ├─ before.svg, after.svg, salon.svg, map.svg
│ ├─ favicon.svg
│ └─ og-image.svg, og-image.png
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonmain.ts is organised in numbered sections (listed in its header comment): data, helpers, header, reveal, opening
hours, before/after, services, team, gallery, booking, gift cards and forms. Layout.astro imports it and
back-to-top.ts, and Astro bundles them into one small module.
Customising#
Brand and colours#
The design tokens are in the @theme block at the top of src/styles/global.css. Tailwind turns each --color-*
token into utilities such as bg-shell, text-plum and border-line.
| Token | Default | Used for |
|---|---|---|
--color-shell |
#faf6f2 |
Page background |
--color-cream |
#fffdfb |
Cards |
--color-blush |
#f3e4dd |
Soft panels |
--color-rose |
#e8cfc4 |
Deeper panels |
--color-line |
#e3d3ca |
Borders |
--color-ink |
#2a2127 |
Text |
--color-mute |
#685a5f |
Secondary text (meets AA on shell, cream and blush) |
--color-plum, --color-plum-dark |
#7a3e5b, #5e2c45 |
Accent: buttons, links, selected states |
--color-sage |
#5a6954 |
Secondary accent, open-now badge |
--color-gold |
#a5804f |
Decorative details |
--color-error |
#a23a3a |
Form errors |
To change the accent:
@theme {
--color-plum: #2f5d62;
--color-plum-dark: #1f4044;
}The gift card designs (.gift-petal, .gift-sage, .gift-noir in global.css) and the SVG artwork have their
own colours; update them too, along with accent in template.json and themeColor in src/lib/site.ts.
Typography#
Two Google Fonts are loaded by the <link rel="stylesheet"> in src/layouts/Layout.astro:
--font-displayis Bodoni Moda (headings, prices, the wordmark), used through thefont-displayclass.--font-sansis Jost (body text).
To swap a font, change the Google Fonts URL in the layout and the family name in @theme:
@theme {
--font-display: "Cormorant Garamond", "Didot", ui-serif, Georgia, serif;
}Content#
Page text is markup in src/pages/. The data the scripts use lives in src/data/bloom.ts (the same data as the
Next.js version), typed with Service, Member, Look and Hours:
| Export | What it holds | Used by the script for |
|---|---|---|
SALON |
Name, address, phone, email, currency (ISO code, e.g. GBP), locale (e.g. en-GB) |
Price formatting in the booking wizard and gift cards |
HOURS |
Seven entries, day 0 (Sunday) to 6, with open/close as "HH:MM" or null when closed |
Open-now status, booking slots |
CATEGORIES |
Service categories: id, label, blurb |
Services filter count label |
SERVICES |
id, cat, name, mins, price, desc, optional signature |
Booking durations, prices, summary |
LEVELS |
stylist (×1), senior (×1.2), director (×1.4) with label and blurb |
Level-based pricing |
TEAM |
id, name, role, level, cats, days, bio, specialties |
Booking stylists, availability, lightbox credit |
LOOKS |
id, cat, title, by (team id), palette, kind |
Gallery lightbox |
LOOK_CATEGORIES |
Gallery filter ids and labels | Lightbox category label |
GIFT_AMOUNTS, GIFT_DESIGNS |
Preset amounts and designs | Design labels in the gift card preview |
main.ts gathers them into one BLOOM object (BLOOM.services, BLOOM.team, …). The pages are written out as
markup, not generated from the data: the service menu, the booking checkboxes, the team cards, the gallery and the
hours tables are in the .astro files. When you change the data, change the markup to match.
To add a treatment, for example a fringe trim:
- Add it to
SERVICESinsrc/data/bloom.ts:
{
"id": "fringe-trim",
"cat": "hair",
"name": "Fringe trim",
"mins": 15,
"price": 12,
"desc": "A quick tidy between cuts."
},- Add a row to the matching
data-service-groupsection inservices.astro, with a link tourl("/book?service=fringe-trim"). - Add a checkbox to step 1 of
book.astroinside the right category<fieldset>, copying an existing one:
<label class="option"><input type="checkbox" name="services" value="fringe-trim" class="option-input"><span class="option-body">…</span></label>The checkbox value must equal the service id; the script reads name, duration and price from the data.
To add a team member, add an entry to TEAM (days uses 0 for Sunday to 6 for Saturday; cats lists the
categories they can do), add public/img/team/<id>.svg (the booking wizard loads the portrait from that path), and
add their card to team.astro with data-team-member data-cats="hair colour".
Contact details are in SALON, the footer (SiteFooter.astro) and the contact and about pages. Search src/ for
+44 117 496 0123, +441174960123 (used in tel: links), hello@bloomhairspa.example and 48 Orchard Lane.
Images and artwork#
All artwork is SVG in public/img/. Replace any file with a photo (JPG or WebP) at the same aspect ratio:
| Files | Size | Ratio |
|---|---|---|
looks/<look id>.svg (12 files) |
480×600 | 4:5 |
team/<member id>.svg (6 files) |
400×480 | 5:6 |
before.svg, after.svg |
640×520 | Both must be the same size |
salon.svg |
640×480 | 4:3 |
map.svg |
640×420 | Illustrative map on the contact page |
favicon.svg |
32×32 | 1:1 |
In pages, image paths are written with url(), for example src={url("/img/salon.svg")}. Two paths are built in
main.ts: the lightbox loads BASE + '/img/looks/' + look.id + '.svg' and the stylist list in the booking wizard
loads BASE + '/img/team/' + o.id + '.svg'. If you switch to JPGs, change those two strings as well as the markup.
og-image.png (1200×630) is the social sharing image; its editable source is og-image.svg. Export it again as a
1200×630 PNG after changing the SVG. Replace map.svg with a real map embed or a static map image of your location.
Navigation, header and footer#
The header and mobile menu are src/components/SiteHeader.astro and the footer is
src/components/SiteFooter.astro; every page gets them from the layout. A header link is marked
aria-current="page" when the page's current prop matches, for example
aria-current={current === "team" ? "page" : undefined}.
To add a page, create src/pages/prices.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Price list — Bloom Hair & Spa" description="Every treatment and price." page="prices" current="prices">
<section class="mx-auto max-w-6xl px-5 py-20">
<h1 class="font-display text-5xl">Price list</h1>
</section>
</Layout>Then add links to the desktop <nav> and the #mobile-menu list in SiteHeader.astro, and to the footer. Write
internal links as href={url("/prices")} (from src/lib/site.ts) so they work under a base path.
The menu button (#menu-toggle) toggles the hidden attribute on #mobile-menu and aria-expanded on itself.
Escape closes the menu and returns focus to the button; the menu also closes on link click and at the desktop
breakpoint.
Features#
All feature code is in src/scripts/main.ts.
Booking wizard#
/book holds [data-booking] with <form id="booking-form">, five <section data-step="1"> to data-step="5"
steps, a progress list (.steps with data-step-indicator) and a running summary
(<aside class="booking-summary">).
| Step | What happens | Validation |
|---|---|---|
| 1. Treatments | Checkboxes name="services" |
At least one |
| 2. Stylist | Radio list built by renderStylists(): "Anyone available" plus everyone who can do all the chosen treatments |
None |
| 3. Date and time | 21 day buttons from today (renderDates()), then time buttons (renderSlots()) |
A time is chosen |
| 4. Your details | Name, email, mobile, notes, first visit, remember details, cancellation policy | Name 2+ characters, valid email, 10–15 digit phone, policy ticked |
| 5. Review | Rows with "Change" buttons that jump back to a step | None |
"Continue" validates the current step and moves on; on step 5 it becomes "Confirm booking" and calls confirm(),
which hides the wizard and shows [data-booking-done] with the first name, a summary and a demo reference (BLM-
plus four random characters). Each step's heading gets focus when it opens, and the active indicator has
aria-current="step". "Book something else" ([data-restart]) starts again.
Preselect from links:
/book?service=balayageticks a treatment (must be a serviceid)./book?stylist=amarapreselects a team member (must be a teamid). If they can't do the chosen treatments, the wizard falls back to "Anyone available".
Changing the treatments clears the chosen date and time.
Time slots and availability#
slotsFor(date, serviceIds, stylist, now) in section 2 of main.ts returns the bookable start times. It gives
exactly the same times as the HTML and Next.js versions for the same data and clock. The rules:
- Opening hours: from
BLOOM.hours[date.getDay()]. A day withopen: nullhas no slots (Monday in the demo). - Length: the sum of the chosen services'
mins. A slot must finish by closing time, so a 180-minute balayage on a Thursday (09:00–20:00) can start at 17:00 at the latest. - Step: start times every 15 minutes.
- Today: the earliest start is at least an hour from now, rounded up to the next 15 minutes (at 10:07 the first slot is 11:15).
- Who can do it:
eligibleMembers()returns team members whosecatsinclude every chosen category. With "Anyone available", each start time is offered if any eligible member is free; the slot remembers who (data-member), and the summary and confirmation name them. - Working days: a member is only considered on days listed in their
days. - Diary:
isBusy(memberId, key, block)marks about one in five 30-minute blocks as already booked, using a deterministic hash so the same diary appears on every visit. A member is free only if every block the treatment touches is free. - Mixed treatments: if nobody can do all the chosen categories (for example a cut and a gel manicure), the
wizard books "Best available team" back to back.
teamPlan()takes the treatments in menu order and gives each one to the first team member (inTEAMorder) who does that category, works that day and is free for the whole treatment; a start time is only offered if every treatment can be covered. The slot keeps the plan inteam(one member id per treatment).
To connect real availability, replace isBusy() (or memberFree()) with a lookup of your booking system's busy
times:
// busy = { "theo|2026-10-08": [20, 21, 26] } -> 30-minute block numbers (block 20 = 10:00)
const busy: Record<string, number[]> = {};
function isBusy(memberId: string, key: string, block: number) {
return (busy[memberId + "|" + key] ?? []).includes(block);
}Fill busy before the wizard renders step 3, for example from a JSON endpoint. To change the slot interval, edit
the t += 15 loop in slotsFor(); to change the notice period, edit the + 60 in the earliest line; to show more
days, change the 21 in renderDates().
Level-based pricing#
priceFor(service, levelId) multiplies the base price by the team member's level multiplier, but only for the
hair and colour categories. Nails, skin and massage cost the same with anyone. Results are rounded to whole
pounds.
| Treatment | Stylist | Senior (×1.2) | Director (×1.4) |
|---|---|---|---|
| Signature cut & finish (£68) | £68 | £82 | £95 |
| Balayage (£210) | £210 | £252 | £294 |
Until a stylist or a time (and so a team member) is chosen, the summary shows hair and colour prices and the total
as "from" the stylist price. To change the levels, edit LEVELS; to make another category scale with level, add it
to the check in priceFor() and to the matching scaled checks in paintSummary(). The prices printed on
services.astro and team.astro are markup; update them by hand.
Opening hours and open now#
Section 5 of main.ts reads BLOOM.hours:
- Every
[data-open-now]badge gets text such as "Open now · until 19:00" or "Closed · opens tomorrow at 09:00" in its[data-open-text]child, and the classis-closedwhen closed. - In any
.hours-table, the<tr data-day="N">for today gets the classis-todayandaria-current="date".
It runs on load and every minute, using the visitor's clock and time zone. The rows in the hours tables are markup;
keep them in step with HOURS.
Services filter#
On /services, [data-services] contains filter buttons with data-filter (all, hair, colour, nails,
skin, massage) and one <section data-service-group="hair"> per category. Choosing a filter hides the other
groups, sets aria-pressed and updates [data-services-count] ("5 treatments in Hair"), a polite live region. Link
to /services?category=colour to open with a category selected.
Team filter#
On /team, [data-team] has buttons with data-team-filter and cards with data-team-member and
data-cats="hair colour" (space-separated). The count ([data-team-count]) is a live region. Each card links to
/book?stylist=<id>.
Gallery and saved looks#
On /gallery, [data-gallery] contains:
- filter buttons with
data-look-filter(all,colour,cuts,bridal,nails,skin), - a "Saved" toggle (
data-saved-toggle) and its count (data-saved-count), - one
<li data-look="<id>" data-cat="<cat>">per look, with an open button (data-look-open="N", the index intoLOOKS) and a heart button (data-save="<id>",aria-pressed), - a count (
data-gallery-count, live region) and an empty message (data-gallery-empty) for when no saved looks match, - a
<dialog data-lightbox>.
Saved looks are stored in localStorage (see Saved data) and stay in sync across tabs
through the storage event. The lightbox shows the look, who did it, its category, "N of M" and a "Book with" link
to /book?stylist=<id>. The left and right arrow keys and the buttons step through the visible looks; Escape or a
backdrop click closes it and focus returns to the look.
To add a look, add it to LOOKS (with by set to a team id and cat to a LOOK_CATEGORIES id), add
public/img/looks/<id>.svg, and add a <li> to gallery.astro with data-look-open set to its index in LOOKS.
Gift cards#
On /gift-cards, [data-gift] contains <form id="gift-form"> and a preview ([data-gift-preview]):
- Amount: radios
name="amount"(50, 75, 100, 150, 250 orcustom). A custom amount must be a whole number from £25 to £500. - Design: radios
name="design"(petal,sage,noir); the preview gets the classgift-<design>. - To, recipient email, from, your email, message: the message has a 160-character counter.
- Delivery:
noworlater; later shows a date input limited to today through one year ahead.
The preview and the total update as you type, and [data-preview-desc] gives screen readers a text description of
the card. On a valid submit, the script shows [data-gift-done] with a demo code (BLOOM-XXXX-XXXX) and a summary.
Nothing is charged; see Forms for taking payment. The preset amounts are radios in gift-cards.astro
(not read from GIFT_AMOUNTS); edit the radios to change them.
Before/after slider#
On the home page, [data-ba] stacks the before and after images with a native range input ([data-ba-range]) on
top. Its value sets --pos, and aria-valuetext reads "Showing 50% before". It works with mouse, touch and the
arrow keys.
Header and scroll reveals#
The header gets is-scrolled after 8px. Elements with reveal fade in as they enter the viewport, with a short
stagger; anything scrolled past is revealed too, so fast jumps never leave content hidden. Reveals are skipped with
reduced motion.
Back to top#
src/scripts/back-to-top.ts adds a round button after about one screen of scrolling. It scrolls to the top
(instantly with reduced motion) and moves focus to the main content. It lifts above toasts and anything marked
data-back-to-top-avoid, hides while it would cover a fixed or sticky element marked data-back-to-top-hide, and
hides while an in-page link whose text contains "Back to top" is on screen. Restyle it with .back-to-top in
global.css; to remove it, delete the file, its import in Layout.astro and those styles.
Forms#
| Form | Where | Validation |
|---|---|---|
Booking (#booking-form) |
/book |
Per step, see Booking wizard |
Gift card (#gift-form) |
/gift-cards |
Amount, names, both emails, delivery date |
Contact (#contact-form) |
/contact |
Name 2+ characters, valid email, message 20+ characters |
Newsletter ([data-newsletter]) |
Every page footer | Valid email |
Errors are written into the element with the matching data-error="<field name>" and the field gets
aria-invalid="true"; the first invalid field receives focus. Errors clear as you type. The contact form accepts
/contact?topic=careers to preselect a topic (appointments, gift-cards, events, careers, other).
None of the forms send anything. Each has a comment in main.ts at the point to connect it:
- booking: "Demo only. Send { services, stylist, date, time, details } to your booking system here." in
confirm(), - gift card: "Demo only: create a payment session with your provider (e.g. Stripe Checkout) here.",
- contact: "Send new FormData(contact) to your endpoint here.",
- newsletter: before
nf.reset().
Connecting to Formspree#
For the contact form, add an action in contact.astro and post it where the comment is:
<form id="contact-form" class="mt-8 grid gap-5 sm:grid-cols-2" action="https://formspree.io/f/your-form-id" method="POST" novalidate>// Send new FormData(contact) to your endpoint here.
const ok = $('[data-form-success]', contact);
fetch(contact.action, { method: "POST", body: new FormData(contact), headers: { Accept: "application/json" } })
.then((res) => {
if (!res.ok) throw new Error("Send failed");
contact.reset();
ok.hidden = false;
ok.focus();
})
.catch(() => cErr("message", "Sorry, that did not send. Please call or email us."));Remove the original contact.reset() and success lines that follow the comment, since the then does that.
For a booking request by email, post the wizard state from confirm():
const data = new FormData(form);
data.append("treatments", selected().map((id) => serviceById[id].name).join(", "));
data.append("with", state.member ? memberById[state.member].name : state.stylist);
data.append("date", dateKey(state.date!));
data.append("time", hhmm(state.slot!));
fetch("https://formspree.io/f/your-form-id", { method: "POST", body: data, headers: { Accept: "application/json" } });A form service only delivers the request; it doesn't block the time in a diary. For real bookings, send the same
data to your booking system's API and use its availability in isBusy(). For gift cards, create a checkout session
on your server (for example Stripe Checkout) and redirect to it instead of showing the demo code. To run that server
code in this project, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel)
and an API endpoint such as src/pages/api/checkout.ts with
export const prerender = false.
Saved data (localStorage)#
| Key | Shape | Written |
|---|---|---|
bloom-saved-looks |
Array of look ids, for example ["honey-balayage", "french-bob"] |
When a heart is toggled |
bloom-details |
{"name": "…", "email": "…", "phone": "…"} |
On booking confirmation, only if "Remember my details on this device" is ticked |
Validation on read:
readSaved()keeps only strings that are ids inLOOKS, without duplicates, and writes the cleaned list back if anything was dropped.readDetails()requires an object, keepsname(up to 80 characters),email(up to 120, and only if it looks like an email) andphone(up to 30), and removes the key if nothing valid is left.
Confirming a booking with "remember" unticked removes bloom-details. All storage access is wrapped in
try/catch (the store helper), so private browsing just disables saving. To reset, delete the keys in your
browser's developer tools (Application, Local Storage).
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>, which turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://bloom.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm build -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
There is no structured data or sitemap. For a salon, consider a
HairSalonorBeautySalonJSON-LD block on the home page with your address, phone, opening hours and booking URL, plus@astrojs/sitemapand apublic/robots.txt.
Accessibility#
Built in:
- A "Skip to content" link, landmarks (
header,nav,main,footer) and one<h1>per page. - Visible focus styles.
- Menu button with
aria-expanded; Escape closes the menu. - Toggle buttons with
aria-pressedfor filters, saved looks, dates and time slots; unavailable dates are disabled and labelled "unavailable". - Live regions for the services, team and gallery counts.
- Wizard steps with focusable headings,
aria-current="step"on the progress list and focus on the confirmation. - Form errors next to each field with
aria-invalid, focus on the first error. - Today's opening hours row marked with
aria-current="date". - Native
<dialog>lightbox with focus return, and a native range input for the before/after slider. - Reduced motion: no reveal animations and instant scrolling.
When editing, keep one <h1> per page, label every new input, give new error messages a data-error element, and
write alt text for meaningful photos. --color-mute is tuned for AA contrast on the light backgrounds; check
contrast if you darken the panels.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as book.html rather than book/index.html
(build.format: "file" in astro.config.mjs), and links point to /book without a trailing slash.
compressHTML: false keeps whitespace exactly as written, so text that continues on the next line before an inline
element keeps its space. Tailwind also scans main.ts, so classes written in script strings (such as the stylist
list) are included.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/bookservesbook.html. - GitHub Pages: use the official Astro GitHub Action. For
a project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/booktobook.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/bloom pnpm build.
It becomes Astro's base: links written with url() include it, and so do the paths the script builds (BASE in
main.ts).
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides the
tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6 API that
astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a single
typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- A new treatment doesn't appear in the booking wizard. Add both the
SERVICESentry and the checkbox inbook.astro; the checkboxvaluemust equal theid. - The booking breaks when a new treatment is ticked. The checkbox
valuehas no matching service inSERVICES. Check spelling. - No times appear for a day. The salon is closed that day in
HOURS, nobody eligible works it (days), or every slot is taken in the demo diary. Try "Anyone available". - A stylist link opens with "Anyone available". The
?stylist=id doesn't exist, or that person can't do the chosen treatments. - A look or portrait image is missing in the lightbox or wizard. The file must be named after the id:
public/img/looks/<id>.svgorpublic/img/team/<id>.svg. - Links or images break under a sub-folder. Build with
DEMO_BASE_PATHand write paths withurl()in pages andBASEinmain.ts. /bookshows a 404 on my host. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Social previews show the wrong domain or no image. Set
SITE_URLto your domain at build time.
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. Bloom Hair & Spa, its team and its clients are fictional.