WeWeb includes a Calendar component (Vue Cal-based) and a Date Picker (VueDatePicker) out of the box. Connect them to Supabase to build a full booking system: fetch available time slots from your backend, let users pick a slot, create a booking record, optionally charge with Stripe, and send a confirmation. No calendar library installation needed.
Build an Events Calendar and Booking System in WeWeb
WeWeb ships with two purpose-built scheduling components: the Calendar (based on Vue Cal) supports year, month, week, and day views with drag-and-drop events, and the Date Picker (based on VueDatePicker) supports single dates, date ranges, and multi-date selection with timezone awareness. This tutorial covers the practical path from zero to a working booking system: designing your Supabase database schema for bookings, fetching events into the Calendar component, handling user slot selection, building the booking form with validation, processing payment via Stripe, and confirming the reservation. You will also learn how to handle recurring events, blocked-out dates, and calendar event color-coding.
Prerequisites
- A WeWeb project with the Supabase plugin installed and connected
- A Supabase project with at least a 'bookings' table (created in Step 1)
- Basic familiarity with WeWeb workflows and the Data panel
- Stripe plugin installed if adding payment (Step 6)
Step-by-step guide
Design the Supabase Bookings Database Schema
Design the Supabase Bookings Database Schema
This step happens entirely in the Supabase Dashboard, not in WeWeb. Open your Supabase project → Table Editor → New table. Create a table named 'bookings' with these columns: id (uuid, primary key, default: gen_random_uuid()), service_id (uuid, foreign key to a services table), provider_id (uuid, foreign key to users/providers), customer_id (uuid, nullable, foreign key to auth.users), start_time (timestamptz), end_time (timestamptz), status (text, default: 'available' — values: 'available', 'booked', 'cancelled', 'blocked'), customer_name (text, nullable), customer_email (text, nullable), stripe_payment_id (text, nullable), notes (text, nullable), created_at (timestamptz, default: now()). Add an RLS policy so customers can only read 'available' slots and their own bookings. Enable Row Level Security on the table.
1-- Injection point: Supabase Dashboard → SQL Editor2-- Creates bookings table and RLS policies34CREATE TABLE public.bookings (5 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),6 service_id UUID,7 provider_id UUID,8 customer_id UUID REFERENCES auth.users(id),9 start_time TIMESTAMPTZ NOT NULL,10 end_time TIMESTAMPTZ NOT NULL,11 status TEXT NOT NULL DEFAULT 'available'12 CHECK (status IN ('available', 'booked', 'cancelled', 'blocked')),13 customer_name TEXT,14 customer_email TEXT,15 stripe_payment_id TEXT,16 notes TEXT,17 created_at TIMESTAMPTZ DEFAULT NOW()18);1920ALTER TABLE public.bookings ENABLE ROW LEVEL SECURITY;2122-- Anyone can view available slots23CREATE POLICY "Public can view available bookings"24ON public.bookings FOR SELECT25USING (status = 'available');2627-- Authenticated users can view their own bookings28CREATE POLICY "Customers can view own bookings"29ON public.bookings FOR SELECT30USING ((SELECT auth.uid()) = customer_id);3132-- Authenticated users can update their own available bookings to booked33CREATE POLICY "Customers can book available slots"34ON public.bookings FOR UPDATE35USING (status = 'available')36WITH CHECK (status = 'booked' AND (SELECT auth.uid()) = customer_id);Expected result: The bookings table exists in Supabase with RLS enabled and policies applied. Seed it with a few test 'available' slots.
Create the Bookings Collection in WeWeb
Create the Bookings Collection in WeWeb
In the WeWeb editor, open the Data panel → click + New → select Supabase → choose your bookings table. Name the collection 'bookings'. In the Filter section, add a filter: status → equals → 'available'. This ensures only open slots are fetched by default (your Realtime subscription will also catch updates). Set the fetch mode to Dynamic. Add a second collection named 'myBookings' using the same bookings table but with a different filter: customer_id → equals → bind to the logged-in user's ID (if using Supabase Auth, this is: plugins['supabase-auth'].user.id). The Calendar component will use the 'bookings' collection for displaying available slots and 'myBookings' to show the current user's reservations in a different color.
Expected result: Two collections in the Data panel: 'bookings' (available slots) and 'myBookings' (current user's reservations).
Add and Configure the Calendar Component
Add and Configure the Calendar Component
Click the Add panel (+) in the left sidebar, search for 'Calendar', and drag it onto your page. Select the Calendar element and open its Settings panel. Under Data, click the plug icon (🔌) next to 'Events' and bind it to a formula that combines both collections and maps them to the Calendar's expected event format. The Calendar expects each event to have a 'start' (ISO datetime string), 'end' (ISO datetime string), 'title' (display text), and optionally 'class' (CSS class for color-coding). In the Settings panel, set the default view to 'month' or 'week'. Enable the display of all-day events if needed. Under Configuration, set 'min date' to today to prevent past slot selection. The Calendar component supports year, month, week, and day views — add view toggle buttons to the page that use a Change variable value action to update a 'calendarView' variable bound to the Calendar's active view property.
1// Injection point: Calendar element → Settings → Events property → Formula (plug icon)2// Maps both collections to Calendar event format3const available = map(collections['bookings'].data, slot => ({4 start: slot.start_time,5 end: slot.end_time,6 title: 'Available',7 class: 'slot-available',8 id: slot.id9}));1011const myBookings = map(collections['myBookings'].data, booking => ({12 start: booking.start_time,13 end: booking.end_time,14 title: booking.notes || 'My Booking',15 class: 'slot-booked',16 id: booking.id17}));1819return concat(available, myBookings);Expected result: Calendar renders with available slots in green and existing user bookings in purple, in month view.
Handle Slot Selection and Open the Booking Popup
Handle Slot Selection and Open the Booking Popup
The Calendar emits an event when a user clicks on a slot. In the Calendar's Settings panel, scroll to Events → On event click. Add a workflow here: Action 1 → Change variable value → set a page variable named 'selectedSlot' (Object type) to the formula: event (the clicked event object, which includes the slot id, start, end). Action 2 → Open popup → select a 'Booking Popup' that you create now. To create the popup: in the Add panel, add a Dialog element, name it 'Booking Popup', and set it to not open on load. Inside the popup, add a text element showing the selected date/time (bound to selectedSlot.start, formatted using a date formula), a Form Container with fields for customer name and email (if not logged in), and a Confirm Booking button. Set the Dialog's close trigger to a button with an On click workflow: Close popup.
Expected result: Clicking an available slot opens a popup showing the selected time and a booking form.
Submit the Booking to Supabase
Submit the Booking to Supabase
In the Confirm Booking button's On click workflow, add these actions in sequence. Action 1 → Supabase Database Update → table: bookings → set status to 'booked', customer_id to the logged-in user ID (or null for guest), customer_name and customer_email to the form values, updated_at to now() → where: id equals selectedSlot.id. This atomically claims the slot. Action 2 → Fetch collection → 'bookings' (to remove the claimed slot from the available list). Action 3 → Fetch collection → 'myBookings' (to show the new booking in the calendar). Action 4 → Close popup. Action 5 → Change variable value → set a 'bookingConfirmed' boolean to true to show a success banner. Add error handling: use the On error trigger on the workflow to detect if the slot was already booked by another user (Supabase UPDATE will return 0 rows if status is no longer 'available') and show an error message.
Expected result: Clicking Confirm Booking updates the record in Supabase, refreshes collections, closes the popup, and shows a success state.
Add Stripe Payment Before Confirming (Optional)
Add Stripe Payment Before Confirming (Optional)
If your booking requires payment, insert payment steps before the Supabase update. In the Confirm Booking workflow, restructure as: Action 1 → Stripe Checkout → redirect to a Stripe-hosted checkout page. Configure the Checkout action in the Plugins → Extensions → Stripe settings with your Price ID. After successful payment, Stripe redirects to a success URL — create a page named 'booking-success' in WeWeb and set it as the success URL. On that page's On page load workflow: read the URL query parameter 'session_id' (add a Query variable named 'session_id' in the Data panel → Variables → New → Query), then call a Supabase Edge Function to verify the payment and update the booking status. In Supabase Dashboard → Edge Functions → New Function: write a Deno function that calls Stripe's retrieve session API and updates the booking record server-side.
Expected result: Users are redirected to Stripe Checkout, and successful payment triggers a server-side booking confirmation.
Display the Date Picker for Filtered Browsing
Display the Date Picker for Filtered Browsing
Add a Date Picker element (Add panel → search 'Date Picker') above your Calendar for users to jump to a specific date or filter by date range. In the Date Picker's Settings, set Mode to 'range' for date-range filtering. Create two page variables: 'filterStart' (Text) and 'filterEnd' (Text). Bind the Date Picker's On date select workflow to set both variables from the emitted date range object. Update your 'bookings' collection filter to add: start_time → is after → filterStart → Ignore if empty, and start_time → is before → filterEnd → Ignore if empty. Add a Fetch collection action in the On date select workflow to reload filtered data. The Calendar will automatically re-render with the filtered events.
Expected result: Selecting a date range in the Date Picker filters the calendar to show only available slots within that period.
Complete working example
1// Injection point: Supabase Dashboard → Edge Functions → New Function2// Function name: confirm-booking3// Called from WeWeb booking-success page's On page load workflow4// via Supabase workflow action: Invoke Edge Function56import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';7import Stripe from 'https://esm.sh/stripe@14';89const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {10 apiVersion: '2023-10-16',11 httpClient: Stripe.createFetchHttpClient()12});1314const supabase = createClient(15 Deno.env.get('SUPABASE_URL')!,16 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!17);1819Deno.serve(async (req) => {20 const { session_id, booking_id } = await req.json();2122 if (!session_id || !booking_id) {23 return new Response(JSON.stringify({ error: 'Missing parameters' }), {24 status: 400, headers: { 'Content-Type': 'application/json' }25 });26 }2728 // Verify payment with Stripe29 const session = await stripe.checkout.sessions.retrieve(session_id);3031 if (session.payment_status !== 'paid') {32 return new Response(JSON.stringify({ error: 'Payment not completed' }), {33 status: 402, headers: { 'Content-Type': 'application/json' }34 });35 }3637 // Update booking status in Supabase38 const { error } = await supabase39 .from('bookings')40 .update({41 status: 'booked',42 stripe_payment_id: session.payment_intent as string43 })44 .eq('id', booking_id)45 .eq('status', 'available'); // Idempotency check4647 if (error) {48 return new Response(JSON.stringify({ error: error.message }), {49 status: 500, headers: { 'Content-Type': 'application/json' }50 });51 }5253 return new Response(JSON.stringify({ success: true }), {54 status: 200, headers: { 'Content-Type': 'application/json' }55 });56});Common mistakes
Why it's a problem: Updating booking status to 'booked' directly from the WeWeb frontend without checking for race conditions
How to avoid: Use Supabase's UPDATE with a WHERE status = 'available' clause. If two users try to book the same slot simultaneously, only the first UPDATE will match the condition and succeed. Check the number of updated rows in the response — if 0, show an error 'This slot was just taken' and refetch the collection.
Why it's a problem: Binding Calendar events to raw Supabase data without mapping to the expected event schema
How to avoid: The Calendar component requires objects with 'start', 'end', and 'title' keys. Supabase data uses 'start_time' and 'end_time'. Always map your collection data using a formula before binding to the Calendar's Events property.
Why it's a problem: Not handling timezone differences between the server and the user's browser
How to avoid: Store all times in Supabase as TIMESTAMPTZ (with timezone, UTC). The Date Picker component supports timezone configuration — set its 'timezone' property to the user's local timezone using JavaScript's Intl.DateTimeFormat().resolvedOptions().timeZone. Display times using WeWeb's date formatting formulas which respect local timezone.
Best practices
- Store all booking times as TIMESTAMPTZ in UTC in Supabase and convert to local timezone only in the display layer
- Use optimistic locking (WHERE status = 'available') on Supabase UPDATEs to handle concurrent booking attempts gracefully
- Always verify Stripe payments server-side (Edge Function) before marking a booking as confirmed — never trust the frontend
- Send booking confirmation emails via a Supabase Database Function trigger rather than from WeWeb, so emails are sent even if the user closes the browser after payment
- Pre-generate available time slots server-side (e.g., via a scheduled Supabase function) rather than computing them dynamically in WeWeb formulas
- Use CSS classes (not inline styles) to color-code event types so styles can be updated globally from App Settings → Custom Code
- Add a 'Refresh calendar' button with a Fetch collection action as a fallback for users who lose their Realtime connection
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a booking system with WeWeb frontend and Supabase backend. My bookings table has: id, start_time (timestamptz), end_time (timestamptz), status (available/booked/cancelled), customer_id, provider_id. Write a PostgreSQL function that generates available 30-minute slots for a given provider between two dates, excluding already-booked slots.
In my WeWeb booking app, I have a Calendar element and a 'bookings' Supabase collection. Help me write the formula for the Calendar's Events property that maps the collection data to the correct Calendar event format, with green color for 'available' slots and blue for 'booked' slots owned by the current user.
Frequently asked questions
Does the WeWeb Calendar component support recurring events?
The Calendar component (Vue Cal) does not natively generate recurring events from a recurrence rule (RRULE). To display recurring events, you need to pre-expand them into individual records in your Supabase database. For example, a weekly recurring booking would be stored as 52 individual rows. You can automate this with a Supabase scheduled Edge Function that generates recurring slots in advance.
Can users drag and drop events to reschedule them on the Calendar?
Yes. The Calendar component supports drag-and-drop rescheduling. Enable it in the Calendar's Settings panel under the Drag & Drop section. When a user drags an event to a new time, the Calendar emits an 'On event drag' event — add a workflow to this trigger that calls a Supabase Database Update action to persist the new start_time and end_time.
How do I block out dates (e.g., holidays, provider unavailability) on the Calendar?
Insert 'blocked' records into your bookings table for the unavailable time ranges (status: 'blocked'). In your Calendar events formula, include these blocked records with a distinct CSS class (e.g., 'slot-blocked') that applies a grey/strikethrough style. To prevent users from clicking blocked slots, add an If/Else action in the On event click workflow that checks if the clicked event's status is 'blocked' and skips the booking popup if true.
What is the difference between the WeWeb Calendar and the Calendly plugin?
The WeWeb Calendar component is a fully custom, data-driven calendar you control — all event data lives in your own Supabase database. The Calendly plugin embeds a Calendly scheduling widget using your Calendly account. Use the native Calendar when you need a custom booking database, custom UI, and Stripe payments. Use the Calendly plugin when you want zero backend setup and are happy with Calendly's standard functionality and email system.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation