178 lines
8.7 KiB
Markdown
178 lines
8.7 KiB
Markdown
# Phase 2C: Public Site Live Data — Design
|
|
|
|
**Status:** Approved
|
|
**Supersedes:** N/A. Builds on the Phase 2A Supabase backend
|
|
(`docs/superpowers/specs/2026-08-08-phase2-backend-foundation-supabase-design.md`)
|
|
and the Phase 2B admin panel
|
|
(`docs/superpowers/specs/2026-08-12-phase2b-admin-panel-design.md`), both
|
|
already live and deployed.
|
|
|
|
## Context
|
|
|
|
Phase 2A built the backend; Phase 2B gave Mira an admin panel to manage it.
|
|
But the public site — the pages actual customers visit — still reads from
|
|
static mock-data files (`src/data/products.ts`, `services.ts`,
|
|
`availability.ts`) and never persists anything a visitor submits. The result:
|
|
anything Mira changes in the admin panel has zero effect on what customers
|
|
see, and no real booking or inquiry a customer submits is actually saved
|
|
anywhere. This spec closes that gap — the last piece needed for the whole
|
|
system to function end-to-end, referred to earlier in this project as
|
|
"Sub-project C."
|
|
|
|
## Scope
|
|
|
|
In scope:
|
|
- `Shop.tsx` and `Home.tsx` read `products`/`services` from Supabase instead
|
|
of the static mock-data files
|
|
- `Book.tsx` computes real available time slots from `schedule_template` +
|
|
`schedule_exceptions` + `booked_slots`, instead of `src/data/availability.ts`'s
|
|
pseudo-random mock
|
|
- Booking submission (`useBooking.ts`) actually persists to the `bookings`
|
|
table
|
|
- Inquiry submission (`InquiryModal.tsx`) actually persists to the
|
|
`inquiries` table
|
|
- Product/service images resolve to real Supabase Storage URLs; the
|
|
existing local image files are uploaded to Storage so real photos render
|
|
- Loading and error states for every page now doing a live fetch
|
|
|
|
Out of scope (no schema exists for these, not part of the original Phase 2A
|
|
data model):
|
|
- The Rules page's content — stays static, matches how it was deliberately
|
|
kept static and French-only from Phase 1
|
|
- Homepage testimonials — stays a static hardcoded array
|
|
- The "About Mira" portrait and other purely decorative static images
|
|
|
|
## Architecture
|
|
|
|
The public site switches from importing static mock-data files to querying
|
|
Supabase directly, using the same `useState`/`useEffect` + `supabase-js`
|
|
pattern already established throughout the admin panel (Phase 2B) — no new
|
|
data-fetching library, matching this project's existing conventions
|
|
throughout.
|
|
|
|
All RLS policies this spec needs already exist from Phase 2A:
|
|
`products_public_read`, `services_public_read`,
|
|
`schedule_template_public_read`, `schedule_exceptions_public_read`,
|
|
`booked_slots_public_read` (all `select`), and `bookings_public_insert`,
|
|
`inquiries_public_insert` (both `insert`). No new migrations are needed for
|
|
RLS — this phase is purely a frontend change consuming an already-complete
|
|
backend.
|
|
|
|
## Availability computation
|
|
|
|
A new pure function, `computeAvailableSlots(template, exceptions,
|
|
bookedSlots, date)`, replaces `getSlotsForDate`. Logic:
|
|
|
|
1. If an exception row exists for `date` with `closed: true`, return no
|
|
slots (the schema's `schedule_exceptions` table only supports fully
|
|
closing a day, not overriding hours — there's no start/end-time override
|
|
column, so that's the full extent of what "exception" can express today).
|
|
2. Otherwise, if `date`'s day-of-week (`0`=Sunday..`6`=Saturday, matching
|
|
`Date.getDay()`) isn't in `template.working_days`, return no slots.
|
|
3. Otherwise, generate slots from `template.start_time` to
|
|
`template.end_time` stepped by `template.slot_minutes`, formatted to
|
|
match the existing display convention used throughout this project
|
|
(`"9:00 AM"` — no leading zero on the hour, uppercase AM/PM, matching the
|
|
literal strings already stored in `bookings.time`, which is a `text`
|
|
column, not a real time type).
|
|
4. Mark any generated slot present in `bookedSlots` for that date as
|
|
`available: false`.
|
|
|
|
`getNextAvailableDate` is reimplemented the same way it works today (search
|
|
forward day-by-day for the first date with any available slot), just backed
|
|
by the real computation instead of the mock.
|
|
|
|
`Book.tsx` fetches `schedule_template` once on mount (single row) and
|
|
`schedule_exceptions` once on mount (small table, unlikely to ever be large
|
|
enough to need date-range filtering), then fetches `booked_slots` filtered
|
|
by the selected date each time the date changes — mirroring how
|
|
`getSlotsForDate` is already called reactively today, just with real data
|
|
underneath.
|
|
|
|
## Booking & inquiry submission
|
|
|
|
`useBooking.ts`'s `submit()` becomes async: after existing client-side
|
|
validation passes, it calls
|
|
`supabase.from('bookings').insert({ service_id, date, time, contact_name,
|
|
contact_phone, contact_email, contact_notes })` via the anon client (already
|
|
permitted by RLS). On success, transitions to the `'confirmed'` step exactly
|
|
as today. On failure — most likely SQLSTATE `23505` (unique-constraint
|
|
violation on `bookings_active_slot_idx`) if another visitor books the same
|
|
slot in the gap between the user selecting it and submitting — the hook
|
|
surfaces an error and returns the user to slot selection instead of
|
|
advancing, since the slot they picked is no longer real. Any other failure
|
|
(network, RLS misconfiguration) surfaces the raw Postgres error message,
|
|
matching the admin panel's existing unmapped-error fallback pattern (no
|
|
`friendlyError`-style mapping needed here — a customer hitting an obscure
|
|
Postgres error is already a rare edge case, and the existing "please try
|
|
again" framing is enough without inventing new copy for cases that don't
|
|
realistically occur on the public flow).
|
|
|
|
`InquiryModal.tsx`'s `handleSubmit` becomes async: after existing
|
|
validation passes, it calls
|
|
`supabase.from('inquiries').insert({ product_id, contact_name,
|
|
contact_email, contact_notes })`. On success, shows the existing "submitted"
|
|
thank-you state. On failure, shows an inline error and keeps the form open
|
|
so the visitor can retry.
|
|
|
|
## Images
|
|
|
|
Product/service `image` columns store storage-relative paths (e.g.
|
|
`products/edge-control.jpg`) — this was already fixed in Phase 2B's final
|
|
review, which changed `uploadSalonImage()` to return the path rather than a
|
|
public URL, but nothing yet derives a public URL from that path at render
|
|
time. This phase adds:
|
|
|
|
- `src/lib/resolveImageUrl.ts` exporting `resolveImageUrl(path: string):
|
|
string`, wrapping `supabase.storage.from('salon-images').getPublicUrl(path).data.publicUrl`
|
|
- `ProductCard.tsx` and `ServiceCard.tsx` call this on the `image` field
|
|
from a live-fetched product/service before rendering `<img src=...>`
|
|
|
|
Since the actual photo files today only exist locally under
|
|
`public/products/*.jpg` and `public/styles/*.jpg` (referenced directly by
|
|
the old mock-data imports, never uploaded anywhere), this phase also
|
|
extends the seed step to upload each file to the `salon-images` Storage
|
|
bucket under the same relative key already stored in the `image` column
|
|
(`products/edge-control.jpg`, `styles/knotless.jpg`, etc.) — so
|
|
`resolveImageUrl()` resolves to a real, already-uploaded photo rather than a
|
|
404. This is a one-time upload step added to `scripts/seed.ts`, using the
|
|
same service-role client the script already holds.
|
|
|
|
## Loading & error states
|
|
|
|
Every page doing a live fetch (`Shop`, `Home`, `Book`) gets a loading state
|
|
for the initial fetch (data is no longer synchronously available at render,
|
|
unlike the old static imports) and a plain-language inline error on fetch
|
|
failure — matching the admin panel's existing pattern
|
|
(`setError(error.message)`, rendered as `{error && <p>...</p>}`). No global
|
|
loading spinner or skeleton library; simple conditional rendering
|
|
(`{loading && <p>Chargement…</p>}`), consistent with how the rest of this
|
|
codebase stays deliberately simple.
|
|
|
|
## Testing
|
|
|
|
- New unit tests for `computeAvailableSlots` covering: a normal working
|
|
day's slots, a fully-closed exception day, a non-working weekday (per
|
|
`working_days`), and a slot correctly excluded because it already appears
|
|
in `booked_slots`.
|
|
- New unit tests for `resolveImageUrl`.
|
|
- Existing `Shop`/`Home`/`Book`/`InquiryModal` component tests are updated
|
|
to mock `supabase-js` calls (matching the pattern already used throughout
|
|
every admin-panel test) instead of importing the static mock-data files
|
|
directly.
|
|
- `useBooking`'s existing tests are extended to cover the async `submit()`
|
|
path: successful insert, and a `23505` failure returning the user to slot
|
|
selection with an error shown.
|
|
|
|
No new pgTAP tests are needed — this phase doesn't touch RLS policies or
|
|
add new database behavior; it only consumes what Phase 2A already tested
|
|
and shipped.
|
|
|
|
## Open questions / risks
|
|
|
|
None outstanding — the one architecture decision (client-side vs.
|
|
server-side availability computation) was resolved during brainstorming in
|
|
favor of client-side, matching this project's established pattern of a thin
|
|
client with no custom SQL functions beyond the private-schema helpers
|
|
Phase 2A already has for unrelated purposes (Edge Function auth).
|