TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt into Lovable Build mode and get a multi-step booking wizard where customers pick a service, choose a staff member and time slot, and pay a deposit. The DB-level EXCLUDE constraint prevents double-bookings — without it the system is structurally broken. Full build runs ~100-200 credits in about 1 day.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores services, staff, availability rules, and bookings with the double-booking prevention constraint.
- 1Click the + button next to Preview in the top toolbar to open the Cloud tab
- 2Click Database — you'll see an empty Table Editor
- 3Leave it empty — the starter prompt creates the migration including the critical EXCLUDE constraint and btree_gist extension
Auth
Magic-link email for customers (lower friction for one-off bookings) and email+password for staff/admin.
- 1Cloud tab → Users & Auth
- 2Confirm Email sign-in is enabled (default)
- 3Enable Magic Links: Users & Auth → Auth settings → Enable magic link login
- 4Set Site URL to your production domain before going live — magic link redirects fail if Site URL is still the preview domain
Edge Functions
Two booking functions: get-available-slots (server-side slot computation) and create-booking (advisory lock + INSERT). Plus stripe-webhook and send-booking-email for deposits and reminders.
- 1Cloud tab → Edge Functions — starter prompt creates all four function files
- 2After the starter prompt runs, deploy each function from the Edge Functions panel
- 3Register the stripe-webhook function URL in Stripe Dashboard → Developers → Webhooks → Add endpoint
Secrets (Cloud tab → Secrets)
STRIPE_SECRET_KEYPurpose: Used by create-deposit-session Edge Function to create Stripe Checkout sessions for deposits.
Where to get it: stripe.com → Dashboard → Developers → API keys → Secret key (sk_test_... for test mode).
STRIPE_WEBHOOK_SECRETPurpose: Used by stripe-webhook Edge Function in constructEventAsync to verify requests are genuinely from Stripe.
Where to get it: stripe.com → Dashboard → Developers → Webhooks → your endpoint → Signing secret (whsec_...). Only available after registering the webhook URL.
RESEND_API_KEYPurpose: Used by send-booking-email Edge Function to send booking confirmations and 24h reminders.
Where to get it: resend.com → API Keys → Create API Key. Free tier covers 3,000 emails/month.
Preflight checklist
- You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
- You're on Pro $25/mo — the full chain runs ~100-200 credits; Free plan's ~30/mo is exhausted by the first follow-up
- Decide whether deposits are required (reduces no-shows) or optional (lower friction for first bookings). The starter prompt builds the booking flow without Stripe; deposits are added in follow-up #2
- Collect your services list and staff names before prompting — you'll add them as seed data via the admin panel the starter builds
The starter prompt — paste this first
Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.
Build a customer-facing booking platform. Stack assumptions: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6, Supabase JS client against Lovable Cloud.
## Database schema (create one migration)
Create four tables in the public schema. CRITICAL: enable btree_gist first.
Run this first in the migration:
```sql
CREATE EXTENSION IF NOT EXISTS btree_gist;
```
1. `services` — id (uuid pk default gen_random_uuid()), name (text not null), description (text), duration_min (int not null check(duration_min > 0)), price_cents (int not null), deposit_cents (int default 0), is_active (bool default true), created_at (timestamptz default now()). RLS: public SELECT WHERE is_active=true; admin write via JWT app_metadata.role='admin'.
2. `staff` — id (uuid pk default gen_random_uuid()), user_id (uuid references auth.users(id)), display_name (text not null), photo_path (text), timezone (text not null default 'America/New_York'), is_active (bool default true). RLS: public SELECT WHERE is_active=true; admin write.
3. `availability` — id (uuid pk default gen_random_uuid()), staff_id (uuid references staff(id) on delete cascade), weekday (int check(weekday between 0 and 6)), start_time (time not null), end_time (time check(end_time > start_time)). RLS: public SELECT; admin write.
4. `bookings` — id (uuid pk default gen_random_uuid()), customer_id (uuid references auth.users(id)), staff_id (uuid references staff(id)), service_id (uuid references services(id)), starts_at (timestamptz not null), ends_at (timestamptz not null), status (text default 'pending' check in ('pending','confirmed','cancelled','no_show','completed')), stripe_payment_intent_id (text), deposit_paid (bool default false), intake_notes (text), customer_email (text), created_at (timestamptz default now()).
ADD THIS CONSTRAINT — it is the double-booking prevention:
```sql
ALTER TABLE bookings ADD CONSTRAINT no_double_booking
EXCLUDE USING gist (
staff_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
) WHERE (status IN ('pending','confirmed'));
```
Enable RLS on all four tables. Bookings RLS: customer reads own WHERE customer_id = auth.uid(); staff reads WHERE staff_id IN (SELECT id FROM staff WHERE user_id = auth.uid()); admin reads all via JWT app_metadata.role='admin'. INSERT: any authenticated user.
Create a SECURITY DEFINER plpgsql function `compute_open_slots(p_staff_id uuid, p_service_id uuid, p_date date)` that:
- Gets the staff member's availability for the weekday of p_date
- Gets the service duration in minutes
- Generates all possible slot start times (e.g., every 30 min from start_time to end_time - duration_min)
- Excludes times where a booking already exists with status IN ('pending','confirmed') using tstzrange overlap
- Returns a TABLE(slot_start timestamptz)
## Layouts
- `PublicLayout.tsx` — sticky header with logo, business name, 'Book Now' CTA button linking to /book. Footer.
- `AdminLayout.tsx` — sidebar with tabs: Services, Staff, Availability, Bookings. AuthGuard reads JWT app_metadata.role='admin'.
## Pages
- / (Home) — services grid (ServiceCard components), brief about section, 'Book your appointment' CTA
- /book — 5-step BookingWizard:
Step 1: pick service (ServiceCard grid)
Step 2: pick staff (StaffPicker — shows photo, name, available days)
Step 3: pick date + slot (DateGridSlotPicker — month view calendar, click date to load slots from compute_open_slots RPC, click slot to select)
Step 4: enter info (name, email, optional intake notes — send magic link OTP to email for identity)
Step 5: review + confirm (shows summary, Confirm button — no Stripe yet, added in follow-up #2)
- /book/confirmation — 'Booking confirmed' page with booking details and calendar download link
- /my-bookings — customer's own bookings list (filter by upcoming/past), Cancel button (sets status='cancelled')
- /admin/services — CRUD table for services
- /admin/staff — CRUD table for staff + photo upload
- /admin/availability — AvailabilityWeekEditor: 7-day grid, click cell to add/remove availability window for each staff member
- /admin/bookings — upcoming bookings list, status filter (pending/confirmed/cancelled), BookingDetailDrawer on row click
- /admin/settings — placeholder
## Key components
- `ServiceCard.tsx` — card with name, duration, price, 'Select' button
- `StaffPicker.tsx` — row or grid of staff cards with photo (or initials avatar), display_name, available weekdays summary
- `DateGridSlotPicker.tsx` — shadcn Calendar month view; on day click, calls supabase.rpc('compute_open_slots', params), renders slot time buttons (min 48×48px touch targets) in a grid below the calendar
- `BookingSummary.tsx` — shows selected service, staff, slot, customer info for review
- `AvailabilityWeekEditor.tsx` — 7-column grid (Mon-Sun), each cell has staff + time window, click to toggle
- `BookingDetailDrawer.tsx` — Sheet with full booking info + status change dropdown
## Styling
Light mode default. Primary color: teal-600. Mobile-first (most bookings happen on phones). Slot time buttons large tap targets. Friendly empty states on /my-bookings when no upcoming bookings.
Generate migration (with EXTENSION and EXCLUDE constraint) first, then layouts, then pages in order above.What this prompt generates
- Creates a SQL migration with 4 tables, the btree_gist EXCLUDE constraint for double-booking prevention, RLS policies, and the compute_open_slots SECURITY DEFINER function
- Scaffolds the 5-step BookingWizard with DateGridSlotPicker that calls the server-side slot function
- Builds the AvailabilityWeekEditor for admin staff schedule management
- Generates the admin panel with Services, Staff, Availability, and Bookings CRUD
- Wires the magic-link customer identity flow so customers can book without creating a full password account
Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0001_booking_schema.sql4 tables + btree_gist EXTENSION + EXCLUDE constraint + RLS + compute_open_slots function
src/layouts/PublicLayout.tsxSticky header with logo and Book Now CTA
src/layouts/AdminLayout.tsxSidebar for admin tabs with AdminGuard role check
src/components/ServiceCard.tsxService card with name, duration, price, Select button
src/components/StaffPicker.tsxStaff selection grid with photo/avatar and availability summary
src/components/DateGridSlotPicker.tsxMonth-view calendar with server-loaded slot time buttons
src/components/BookingSummary.tsxReview card showing selected service, staff, slot, and customer info
src/components/AvailabilityWeekEditor.tsx7-column weekly grid editor for admin staff schedules
src/components/BookingDetailDrawer.tsxSheet with full booking detail and status change dropdown
src/pages/Home.tsxServices grid + Book Now CTA landing page
src/pages/Book.tsx5-step BookingWizard component
src/pages/BookConfirmation.tsxPost-booking confirmation page
src/pages/MyBookings.tsxCustomer's own booking history with cancel button
src/pages/admin/Services.tsxServices CRUD
src/pages/admin/Staff.tsxStaff CRUD with photo upload
src/pages/admin/Availability.tsxAvailabilityWeekEditor wrapped in admin page
src/pages/admin/Bookings.tsxUpcoming bookings list with status filter and detail drawer
supabase/functions/get-available-slots/index.tsCalls compute_open_slots RPC and returns available slot timestamps
supabase/functions/create-booking/index.tsAdvisory lock + booking INSERT + returns conflict error if slot taken
Routes
Landing page with services grid and Book Now CTA
5-step booking wizard (service → staff → slot → info → confirm)
Post-booking confirmation with details
Customer's own booking history with cancel button
Services CRUD
Staff CRUD with availability link
Weekly availability grid editor per staff member
Upcoming and past bookings with status filter
Business settings placeholder
DB Tables
services| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| name | text not null |
| description | text |
| duration_min | int not null check(duration_min > 0) |
| price_cents | int not null |
| deposit_cents | int default 0 |
| is_active | bool default true |
| created_at | timestamptz default now() |
RLS: Public SELECT WHERE is_active=true; admin INSERT/UPDATE/DELETE via JWT app_metadata.role='admin'
staff| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| user_id | uuid references auth.users(id) |
| display_name | text not null |
| photo_path | text |
| timezone | text not null default 'America/New_York' |
| is_active | bool default true |
RLS: Public SELECT WHERE is_active=true; admin write
availability| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| staff_id | uuid references staff(id) on delete cascade |
| weekday | int check(weekday between 0 and 6) |
| start_time | time not null |
| end_time | time check(end_time > start_time) |
RLS: Public SELECT; admin write
bookings| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| customer_id | uuid references auth.users(id) |
| staff_id | uuid references staff(id) |
| service_id | uuid references services(id) |
| starts_at | timestamptz not null |
| ends_at | timestamptz not null |
| status | text default 'pending' |
| stripe_payment_intent_id | text |
| deposit_paid | bool default false |
| intake_notes | text |
| customer_email | text |
| created_at | timestamptz default now() |
RLS: EXCLUDE USING gist(staff_id WITH =, tstzrange(starts_at,ends_at) WITH &&) WHERE status IN ('pending','confirmed'). Customer reads own; staff reads own; admin reads all.
Components
ServiceCardService name, duration badge, price, and Select button
StaffPickerStaff photo/avatar grid with available-days summary
DateGridSlotPickerMonth-view calendar + server-loaded time slot buttons
AvailabilityWeekEditor7-column weekly grid for managing staff availability windows
BookingDetailDrawerRight Sheet with full booking detail and status change
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Move slot check to Edge Function with advisory lock
Server-side booking creation with advisory lock + EXCLUDE constraint catch, returning 409 on conflict
Replace the current client-side slot availability check with an Edge Function that uses a Postgres advisory lock to prevent race conditions.
Update get-available-slots/index.ts:
1. Accept POST with { staff_id, service_id, date } in the request body
2. Call supabase.rpc('compute_open_slots', { p_staff_id, p_service_id, p_date }) to get available timestamps
3. Return the list of slot timestamps as JSON
Create create-booking/index.ts:
1. Accept POST with { staff_id, service_id, starts_at, service_duration_min, customer_email, intake_notes }
2. Verify caller is authenticated via Authorization header
3. Run: SELECT pg_advisory_xact_lock(hashtext('booking:' || staff_id::text)) — this holds a transaction-scoped advisory lock per staff member so two concurrent requests queue instead of racing
4. Compute ends_at = starts_at + duration interval
5. INSERT into bookings. If the INSERT fails with Postgres error code 23P01 (exclusion violation), return HTTP 409 with body { error: 'Slot was just taken by someone else — please pick another time' }
6. Return the new booking id and confirmation details
Update the BookingWizard Step 5 Confirm button to call supabase.functions.invoke('create-booking', {...}) instead of a direct Supabase INSERT.When to use: IMMEDIATELY after the starter prompt, before any real bookings — the client-side INSERT is not safe under concurrent load
Add Stripe deposit checkout
Stripe deposit collection with raw-body webhook updating booking status to confirmed
Add Stripe deposit collection to the booking flow.
1. Cloud tab → Secrets: confirm STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set.
2. Create supabase/functions/create-deposit-session/index.ts:
- Accept POST with { booking_id }
- Fetch the booking + service from the database
- If service.deposit_cents = 0, return { skip: true } — no deposit needed
- Create Stripe Checkout session: mode='payment', line_items with service.deposit_cents, metadata.booking_id, success_url='/book/confirmation?booking_id={booking_id}', cancel_url='/book'
- Return { url: session.url }
3. Update supabase/functions/stripe-webhook/index.ts:
- Read raw body: const body = await req.text();
- Verify: const event = await stripe.webhooks.constructEventAsync(body, req.headers.get('Stripe-Signature'), Deno.env.get('STRIPE_WEBHOOK_SECRET'));
- On checkout.session.completed: read metadata.booking_id, UPDATE bookings SET deposit_paid=true, status='confirmed' WHERE id=booking_id
- Return 200
4. In BookingWizard Step 5: if service.deposit_cents > 0, after create-booking succeeds, immediately call create-deposit-session with the new booking_id and redirect to session.url. If deposit_cents = 0, go straight to /book/confirmation.When to use: When you want to reduce no-shows by requiring a deposit at booking time
Send Resend confirmation and reminder emails
Booking confirmation emails on create, plus 24h and 1h reminder emails via hourly pg_cron
Add booking confirmation and reminder emails via Resend.
1. Create supabase/functions/send-booking-email/index.ts. Accept POST with { booking_id, type } where type is one of: 'confirmation', 'reminder_24h', 'reminder_1h', 'cancelled'.
- Fetch booking + service + staff from the database using the service role client
- Build the email subject and body based on type:
- confirmation: 'Your booking is confirmed — [Service] with [Staff] on [Date] at [Time]'
- reminder_24h: 'Reminder: [Service] with [Staff] tomorrow at [Time]'
- reminder_1h: '[Service] with [Staff] in 1 hour'
- cancelled: 'Your booking has been cancelled'
- POST to Resend API with RESEND_API_KEY
- Return { success: true }
2. Call send-booking-email with type='confirmation' from the stripe-webhook handler on checkout.session.completed (if deposit required) and from the create-booking Edge Function for free bookings.
3. Set up reminder scheduling via pg_cron:
In Cloud tab → Database → SQL Editor:
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule('hourly-reminders', '0 * * * *', $$
SELECT net.http_post(
url := '[your-edge-function-url]/send-booking-email',
body := row_to_json(b)::text
)
FROM bookings b
JOIN services s ON s.id = b.service_id
WHERE b.status = 'confirmed'
AND b.starts_at BETWEEN now() + interval '23 hours' AND now() + interval '25 hours'
AND b.reminder_24h_sent = false;
$$);When to use: When you have real bookings flowing and want to reduce no-shows with reminders
Add cancellation policy and deposit refund flow
24-hour cancellation policy with automatic Stripe refund for eligible cancellations
Add a cancellation policy that refunds the deposit if the customer cancels more than 24 hours before the appointment.
1. Create supabase/functions/cancel-booking/index.ts:
- Accept POST with { booking_id }
- Verify caller owns the booking (customer_id = auth.uid())
- Check hours until appointment: EXTRACT(EPOCH FROM (starts_at - now())) / 3600
- If > 24 hours AND deposit_paid = true:
- Call stripe.refunds.create({ payment_intent: stripe_payment_intent_id })
- UPDATE bookings SET status='cancelled', deposit_paid=false
- Send cancellation email via send-booking-email
- If <= 24 hours: return 400 with message 'Cancellations within 24 hours of the appointment are non-refundable per our policy'
- If deposit not paid: just UPDATE status='cancelled' and send cancellation email
2. On /my-bookings, the Cancel button should call this Edge Function instead of doing a direct Supabase UPDATE. Show the 24-hour policy text next to the button: 'Cancel for free if more than 24h before appointment. Cancellations within 24h are non-refundable.'
3. Show the policy text on the /book Step 5 review page too, before the Confirm button.When to use: Once deposits are live and you want to enforce a cancellation policy
Add timezone-aware slot display
Correct timezone-aware slot display with staff TZ for admin and browser TZ for customers
Fix slot display to correctly handle staff and customers in different timezones.
1. Confirm the compute_open_slots function works in the staff member's timezone:
- In the function, convert p_date to the staff's timezone: p_date AT TIME ZONE staff.timezone
- Generate slot start times in staff timezone: availability.start_time, availability.end_time
- Return slots as timestamptz (UTC) — Postgres will handle the conversion
2. In DateGridSlotPicker, display slot times in the customer's browser timezone using Intl.DateTimeFormat:
const formatted = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(slotDate);
This automatically shows times in the customer's local time with the timezone abbreviation.
3. On the admin /admin/bookings page, show times in the staff member's timezone (use staff.timezone string with Intl.DateTimeFormat({ timeZone: staff.timezone })).
4. Add a visible label below the slot picker: 'All times shown in your local timezone ([detected TZ])'. Detect browser TZ with: Intl.DateTimeFormat().resolvedOptions().timeZone.
5. Add a test: create a staff member in EST, load the slot picker as a browser in PST, and verify the slot times display correctly offset (an EST 9am slot should show as 6am PST).When to use: When you onboard your first out-of-state customer or staff member in a different timezone
Add admin blocked times
Staff blocked-time management so vacation/sick/lunch days are excluded from the slot picker
Add a blocked-time feature so staff can block specific date ranges for vacations, sick days, or lunch breaks.
1. Create a blocked_times table: id (uuid pk), staff_id (uuid references staff(id) on delete cascade), starts_at (timestamptz not null), ends_at (timestamptz not null), reason (text). RLS: public SELECT (needed for slot computation); admin write.
2. Update compute_open_slots to subtract blocked_times from the generated slots:
In the function body, after generating candidate slots, filter out any slot where starts_at OVERLAPS with a blocked_times row for that staff member:
AND NOT EXISTS (
SELECT 1 FROM blocked_times bt
WHERE bt.staff_id = p_staff_id
AND tstzrange(bt.starts_at, bt.ends_at) && tstzrange(slot_start, slot_start + (p_duration_min || ' minutes')::interval)
)
3. On /admin/availability, add a 'Block time' button per staff member. Clicking opens a Dialog with: start datetime picker, end datetime picker, reason text input, and Save button. The blocked time appears as a red chip on the availability grid.
4. Allow staff members to add their own blocked times: add a /my-availability page accessible to users where staff.user_id = auth.uid(), showing upcoming blocked times + an 'Add block' button.When to use: When staff start asking for personal time-off management
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
Two customers booked the same slot simultaneously and both got confirmation emailsThe slot-availability check ran in client JavaScript — two browsers raced past the check before either INSERT landed. Without a DB-level EXCLUDE constraint, both writes succeed.
Confirm the EXCLUDE constraint exists: SELECT conname, contype FROM pg_constraint WHERE conrelid = 'bookings'::regclass AND contype = 'x'; If it's missing, run: CREATE EXTENSION IF NOT EXISTS btree_gist; ALTER TABLE bookings ADD CONSTRAINT no_double_booking EXCLUDE USING gist (staff_id WITH =, tstzrange(starts_at, ends_at) WITH &&) WHERE (status IN ('pending','confirmed')); Then in create-booking Edge Function, catch Postgres error code 23P01 and return HTTP 409 with message 'Slot was just taken — please pick another time'.function tstzrange(timestamp with time zone, timestamp with time zone) does not existThe starts_at or ends_at column is defined as timestamp without time zone instead of timestamptz. The tstzrange function requires both arguments to be timestamptz.
Alter the bookings table: ALTER TABLE bookings ALTER COLUMN starts_at TYPE timestamptz USING starts_at AT TIME ZONE 'UTC'; ALTER TABLE bookings ALTER COLUMN ends_at TYPE timestamptz USING ends_at AT TIME ZONE 'UTC'; Then drop and recreate the EXCLUDE constraint: ALTER TABLE bookings DROP CONSTRAINT no_double_booking; ALTER TABLE bookings ADD CONSTRAINT no_double_booking EXCLUDE USING gist (staff_id WITH =, tstzrange(starts_at, ends_at) WITH &&) WHERE (status IN ('pending','confirmed'));permission denied for table bookings when customer tries to view their ownCustomer booked via magic link but the booking was inserted with customer_id=null (the magic link OTP hadn't resolved to a user ID yet when the INSERT ran), so auth.uid() doesn't match the null customer_id.
In create-booking Edge Function, after calling supabase.auth.getUser() from the Authorization header, set customer_id = user.id on the INSERT. Also write customer_email = the email from the booking form as a fallback for lookup. Add a query in /my-bookings: WHERE customer_id = auth.uid() OR customer_email = user.email so both linked and unlinked bookings appear.
Slot picker shows slots that overlap into the next day or wraps past midnight in the wrong timezonecompute_open_slots generated slots in UTC but the availability.start_time and end_time were entered as local-time values without timezone conversion. Slots near midnight EST are shown as early-morning slots in UTC.
In compute_open_slots, convert the input date to the staff member's timezone before computing availability windows: SELECT start_time, end_time FROM availability WHERE staff_id = p_staff_id AND weekday = EXTRACT(DOW FROM (p_date AT TIME ZONE staff.timezone)). Then generate slot timestamps in staff timezone and return as timestamptz. Client formats display in browser TZ via Intl.DateTimeFormat.
Stripe webhook fires but booking.deposit_paid stays falseEither the STRIPE_WEBHOOK_SECRET in Cloud Secrets doesn't match the signing secret in Stripe Dashboard for your endpoint, or metadata.booking_id was not passed when creating the Checkout session.
Check Stripe Dashboard → Developers → Webhooks → your endpoint → Recent deliveries → look at the response body. If you see signature failure, update STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets with the current whsec_... value. If signature passes but deposit_paid is still false, add a console.log(event.data.object.metadata) to the webhook and redeploy — confirm booking_id is in the metadata. It must be passed in create-deposit-session via { metadata: { booking_id: bookingId } }.Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo recommended. The full chain runs ~100-200 credits — comfortably within Pro's 100 monthly + 5 daily credits if you don't loop on the timezone or EXCLUDE constraint issues. One top-up ($15 for 50 credits) if you do.
Monthly run cost breakdown
~100-200 credits (starter ~50-70, six follow-ups ~305 if all done; most readers ship after 3 follow-ups for ~150 total) total| Item | Cost |
|---|---|
| Lovable Pro Drop after build — app runs on Lovable Cloud even on Free plan | $25/mo |
| Lovable Cloud (Supabase) 500MB DB easily holds 100K+ bookings. 50K MAU more than enough for an SMB booking page. | $0/mo at MVP |
| Stripe Plus 1% for instant payouts if you use them. At 30 bookings/mo × $25 deposit = $37 in Stripe fees. | 2.9% + 30¢ per deposit |
| Resend A 10-booking-per-day shop sends ~600 emails/mo (confirmation + reminder × 30 days) — stays free. | $0/mo up to 3K emails |
| Custom domain Required for a professional booking page | ~$10-15/yr |
Scaling notes: Resend 3K/mo free tier covers ~50 bookings/day with confirmation + 24h reminder (2 emails each). DB 500MB holds ~500K bookings. The actual scaling ceiling is Stripe fees — at 1,000 bookings/mo with $30 deposit average, Stripe takes ~$898 in fees.
Production checklist
Steps to take before you share the URL with real users.
Domain & SSL
Connect a custom domain so booking confirmation emails and OAuth links point to your brand
Lovable top-right → Publish → Custom domain → enter domain → copy CNAME → add to your DNS provider. Then Cloud tab → Users & Auth → URL Configuration: set Site URL to your custom domain so magic-link emails redirect to the right URL.
Cancellation policy
Display your cancellation policy prominently on the booking flow BEFORE taking deposits
Add a visible text block on BookingWizard Step 5 (the review screen) before the Confirm button: 'Cancellation policy: full deposit refund if cancelled more than 24 hours before appointment. No refund for cancellations within 24 hours.' This is legally required in most jurisdictions before charging a deposit.
Backups
Verify daily backups are running before taking real deposits
Lovable Cloud (Supabase Free) includes daily automated backups with 7-day retention. Confirm by checking Cloud tab → Database — if you see 'Daily backups enabled', you're covered. For booking data with financial records, consider migrating to your own Supabase Pro project for point-in-time recovery.
Monitoring
Check the pg_cron reminder schedule is actually running once bookings go live
Cloud tab → Database → SQL Editor: SELECT * FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10; If you see recent rows with status='succeeded', the cron is running. If empty, the schedule may have failed — rerun the cron.schedule() call from follow-up #3.
Frequently asked questions
Will this actually prevent double-bookings under real traffic?
Yes, if you implement the starter prompt correctly. The EXCLUDE USING gist constraint on the bookings table is enforced at the Postgres level — two concurrent INSERTs for the same staff + overlapping time range will result in one succeeding and one getting Postgres error code 23P01 (exclusion violation). The create-booking Edge Function catches that error and returns a 409 so the second customer sees 'slot just taken' instead of a silent duplicate. The only remaining race is the very narrow window between the slot check and the INSERT — the advisory lock in follow-up #1 closes that.
How do I handle timezones for staff and customers in different states?
The rules are simple: store everything in UTC (timestamptz columns do this automatically in Postgres), compute availability in the staff member's timezone (using staff.timezone field in the compute_open_slots function), and display to customers in their browser's local timezone (using Intl.DateTimeFormat with no timezone override, which defaults to the browser's detected TZ). The follow-up #5 prompt walks through implementing this correctly. Always label slot times with the timezone abbreviation so customers know what timezone they're booking in.
Do I really need to take a deposit, or can I just ask people to show up?
Deposits reduce no-show rates dramatically (typically from 20-30% to 2-5% for service businesses). If you're just validating your booking flow or your customers are regulars who always show up, skip deposits for the first month — the starter prompt builds the full booking flow without Stripe and deposits are an optional follow-up. Add deposits when you see actual no-shows causing revenue loss.
How do recurring bookings (every Tuesday at 6pm) work?
Recurring bookings are not in this prompt kit — they're a significant add-on. The simplest approach for a Lovable build is to create a 'recurring booking' record that stores the pattern (weekday, time, service, staff) and a script or pg_cron job that creates individual booking rows for the next 4-8 weeks on a weekly schedule. The alternative is to let customers manually re-book each week, which works fine for low-volume studios. If your entire business model is recurring appointments (gym memberships, weekly therapy), consider the subscription-system kit instead.
Can customers book without creating an account?
Yes — the starter prompt uses Supabase magic-link OTP at Step 4 of the booking wizard. The customer enters their email, receives a magic link, clicks it, and their session is established. This is lower friction than password creation for one-off bookings. Their booking is associated with their auth.uid() once they verify. If they book again without clicking the magic link, you can look up bookings by customer_email as a fallback.
What happens if Stripe is down during peak booking hours?
If Stripe is down, the create-deposit-session Edge Function will return an error and the customer won't be redirected to checkout. The booking row is already created with status='pending' and deposit_paid=false at this point. Handle this gracefully: show the customer a message like 'Booking saved — complete your deposit payment within 2 hours to confirm your slot.' Then add a link to a /pay/:booking_id route that retries the Stripe session creation. The EXCLUDE constraint ensures the slot is held as 'pending' until either the deposit is paid or the booking expires (add a pg_cron that cancels unpaid pending bookings after 2 hours).
When does it stop making sense to keep building this in Lovable?
Export to GitHub and finish in Cursor when you need: multi-location support (separate availability per location), complex recurring rules (every other Tuesday, skip holidays), package-based booking (buy 10 sessions upfront), or waiting-list logic when slots are full. If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.