This guide provides a step-by-step walkthrough for building a web application using RedwoodJS that allows users to book appointments and receive automated SMS reminders via MessageBird. We'll cover everything from project setup and core logic implementation to deployment and verification.
Modern applications often require timely communication to reduce friction and improve user experience. For appointment-based services, automated reminders are crucial for minimizing no-shows and ensuring smooth operations. This guide demonstrates how to leverage the power of RedwoodJS's full-stack capabilities combined with MessageBird's robust SMS API to create a production-ready appointment reminder system.
Project Overview and Goals
What We'll Build:
We will create a RedwoodJS application with the following features:
- A frontend interface (web side) allowing users to book an appointment by providing their name, desired service, phone number, and preferred date/time.
- A backend API (api side) that validates user input, including phone number verification using MessageBird's Lookup API.
- Integration with MessageBird's SMS API to schedule a reminder message to be sent a predefined time (e.g., 3 hours) before the appointment.
- A database layer (using Prisma) to persist appointment details.
- A confirmation screen for the user after successful booking.
- Note: Full implementation for updating/canceling appointments, including handling the corresponding scheduled MessageBird messages, is not covered in this guide but would be necessary for a complete production system.
Problem Solved:
This application directly addresses the common business problem of customer no-shows for scheduled appointments, which leads to lost revenue and inefficient resource allocation. By sending timely SMS reminders, we aim to significantly reduce the no-show rate.
Technologies Used:
- RedwoodJS: A full-stack, serverless-first web application framework based on React, GraphQL, and Prisma. It provides structure and conventions that accelerate development.
- Node.js: v20.x or later. The underlying runtime environment for RedwoodJS's API side.
- MessageBird: A communication platform providing APIs for SMS, voice, and more. We'll use their Node.js SDK for phone number lookup and scheduled SMS sending.
- Prisma: A next-generation Node.js and TypeScript ORM used by RedwoodJS for database access.
- PostgreSQL (or SQLite): The relational database for storing appointment data. (Examples will assume PostgreSQL, but SQLite works for development).
- React: Used for building the frontend user interface (RedwoodJS web side).
- GraphQL: RedwoodJS uses GraphQL for communication between the frontend and backend.
- Moment.js: A JavaScript library for parsing, validating, manipulating, and formatting dates (as used in the original MessageBird tutorial example).
- Note: While functional, Moment.js is in maintenance mode and has known bundle size/mutability issues. For new projects, consider alternatives like
date-fns
, Luxon, or nativeIntl
/Date
methods where possible. This guide retains Moment.js for consistency with the example, but replacements are recommended for production codebases.
- Note: While functional, Moment.js is in maintenance mode and has known bundle size/mutability issues. For new projects, consider alternatives like
System Architecture:
graph LR
A[User's Browser] -- HTTP/GraphQL Request --> B(RedwoodJS Web Server);
B -- GraphQL Mutation --> C(RedwoodJS API Server);
C -- Validate Input --> C;
C -- Lookup Request --> D(MessageBird Lookup API);
D -- Validation Result --> C;
C -- Schedule SMS Request --> E(MessageBird Messages API);
E -- Confirmation --> C;
C -- Save Appointment --> F(Database via Prisma);
F -- Saved Data --> C;
C -- GraphQL Response --> B;
B -- HTTP Response --> A;
E -- Sends SMS at Scheduled Time --> G(User's Phone);
classDef default fill:#f9f,stroke:#333,stroke-width:2px;
classDef redwood fill:#bfd,stroke:#333,stroke-width:2px;
classDef messagebird fill:#dff,stroke:#333,stroke-width:2px;
classDef db fill:#fdb,stroke:#333,stroke-width:2px;
class A,G default;
class B,C redwood;
class D,E messagebird;
class F db;
Prerequisites:
- Node.js v20.x or later
- Yarn v1.22.21 or later
- A MessageBird account (Sign up at MessageBird.com)
- Access to a PostgreSQL database (or use SQLite for development)
- Basic understanding of React, GraphQL, and JavaScript/TypeScript.
Final Outcome:
By the end of this guide, you will have a fully functional RedwoodJS application capable of accepting appointment bookings and scheduling SMS reminders via MessageBird. You will also have the foundational knowledge to extend this application with more features like authentication, cancellation flows, or administrative dashboards.
1. Setting Up the RedwoodJS Project
Let's start by creating a new RedwoodJS project and installing the necessary dependencies. We'll use TypeScript for enhanced type safety.
-
Create RedwoodJS App: Open your terminal and run the RedwoodJS create command:
yarn create redwood-app redwood-messagebird-reminders --typescript
This command scaffolds a new RedwoodJS project with TypeScript configured in a directory named
redwood-messagebird-reminders
. -
Navigate to Project Directory:
cd redwood-messagebird-reminders
-
Install Initial Dependencies: RedwoodJS automatically runs
yarn install
after creation. If you need to run it manually:yarn install
-
Configure Environment Variables: RedwoodJS uses a
.env
file for environment variables. Create this file in the project root:touch .env
Open
.env
and add the following variables. You'll get the values in subsequent steps.# .env # Database Connection String (Replace with your actual connection string) # Example for PostgreSQL: DATABASE_URL=""postgresql://user:password@host:port/database?schema=public"" # Example for SQLite (for development): DATABASE_URL=""file:./dev.db"" DATABASE_URL=""file:./dev.db"" # Start with SQLite for simplicity # MessageBird API Key (Get from MessageBird Dashboard -> Developers -> API access) MESSAGEBIRD_API_KEY=""YOUR_MESSAGEBIRD_LIVE_API_KEY"" # Default Country Code for Phone Number Lookup (e.g., US, NL, GB) MESSAGEBIRD_COUNTRY_CODE=""US"" # MessageBird Originator (Sender ID - alphanumeric or phone number) # Check Country Restrictions: https://developers.messagebird.com/api/sms-messaging/#country-restrictions # Use a purchased MessageBird number if alphanumeric is restricted (e.g., in the US) MESSAGEBIRD_ORIGINATOR=""BeautyBird"" # Or your MessageBird Number e.g. +12025550181
- Why
.env
? Storing sensitive information like API keys and database URLs directly in code is insecure. Environment variables provide a standard, secure way to manage configuration across different environments (development, staging, production). Redwood automatically loads variables from.env
intoprocess.env
.
- Why
-
Install Additional API Dependencies: We need the MessageBird Node.js SDK and Moment.js for date manipulation within our API service. Navigate to the
api
workspace to install them:yarn workspace api add messagebird moment # Note: Moment.js is in maintenance mode. Consider alternatives like date-fns or Luxon for new projects.
- Why
yarn workspace api add
? Redwood uses Yarn Workspaces to manage dependencies for theweb
andapi
sides separately. This command ensures these packages are added only to theapi
side where they are needed.
- Why
-
Initial Git Commit (Recommended): Initialize a Git repository and make your first commit:
git init git add . git commit -m ""Initial project setup with RedwoodJS""
You now have a basic RedwoodJS project structure with the necessary configurations and dependencies ready for development.
2. Implementing Core Functionality (API & Database)
Now, let's define the database schema, create the API service logic for handling bookings and scheduling reminders, and set up the GraphQL endpoint.
-
Define Database Schema (Prisma): Open the Prisma schema file located at
api/db/schema.prisma
. Replace the default example model with ourAppointment
model:// api/db/schema.prisma datasource db { provider = ""sqlite"" // Or ""postgresql"" url = env(""DATABASE_URL"") } generator client { provider = ""prisma-client-js"" binaryTargets = ""native"" // Add other targets like ""rhel-openssl-1.0.x"" if needed for deployment } model Appointment { id Int @id @default(autoincrement()) name String treatment String phoneNumber String // Store the validated, normalized number appointmentDt DateTime // The actual date/time of the appointment (ideally stored as UTC) reminderDt DateTime // The date/time the reminder should be sent (ideally stored as UTC) messageBirdId String? // Optional: Store the MessageBird message ID createdAt DateTime @default(now()) }
- Why this schema? It captures essential appointment details, including separate fields for the appointment time (
appointmentDt
) and the scheduled reminder time (reminderDt
). Storing themessageBirdId
allows tracking the scheduled message status later if needed. Storing dates in UTC is best practice.
- Why this schema? It captures essential appointment details, including separate fields for the appointment time (
-
Apply Database Migrations: Use Prisma Migrate to create the
Appointment
table in your database based on the schema changes.yarn rw prisma migrate dev
- You'll be prompted to enter a name for the migration. Something like
create appointment model
is suitable. - Why
migrate dev
? This command compares yourschema.prisma
with the database state, generates SQL migration files, applies them to your development database, and regenerates the Prisma Client. It's essential for keeping your database schema in sync with your application model.
- You'll be prompted to enter a name for the migration. Something like
-
Generate GraphQL SDL and Service: Redwood's generators can scaffold the basic GraphQL schema definition language (SDL) files and service implementations for CRUD operations.
yarn rw generate sdl Appointment --crud
This command creates/updates:
api/src/graphql/appointments.sdl.ts
: Defines the GraphQL types (Appointment
,CreateAppointmentInput
,UpdateAppointmentInput
) and operations (queries/mutations).api/src/services/appointments/appointments.ts
: Contains the business logic (resolvers) for interacting with theAppointment
model.api/src/services/appointments/appointments.scenarios.ts
: For defining seed data for tests.api/src/services/appointments/appointments.test.ts
: Basic test file structure.
-
Customize GraphQL SDL: Open
api/src/graphql/appointments.sdl.ts
. We need to adjust theCreateAppointmentInput
to match the fields we'll collect from the user form (excluding fields generated by the backend likereminderDt
,messageBirdId
,createdAt
).// api/src/graphql/appointments.sdl.ts export const schema = gql` type Appointment { id: Int! name: String! treatment: String! phoneNumber: String! appointmentDt: DateTime! reminderDt: DateTime! messageBirdId: String createdAt: DateTime! } type Query { appointments: [Appointment!]! @requireAuth appointment(id: Int!): Appointment @requireAuth } input CreateAppointmentInput { name: String! treatment: String! number: String! // Use 'number' from form input initially date: String! // Receive date as string from form (YYYY-MM-DD) time: String! // Receive time as string from form (HH:mm) // Consider accepting a single ISO 8601 DateTime string for robustness } input UpdateAppointmentInput { name: String treatment: String number: String date: String time: String // We generally wouldn't update reminder details this way } type Mutation { createAppointment(input: CreateAppointmentInput!): Appointment! @skipAuth # Allow public booking updateAppointment(id: Int!, input: UpdateAppointmentInput!): Appointment! @requireAuth deleteAppointment(id: Int!): Appointment! @requireAuth } `
- Changes:
- Modified
CreateAppointmentInput
to acceptnumber
,date
, andtime
as strings, reflecting typical HTML form input. Added comment suggesting ISO 8601. - Added
@skipAuth
tocreateAppointment
mutation to allow unauthenticated users to book. Keep@requireAuth
for other operations.
- Modified
- Changes:
-
Implement Service Logic: This is where the core booking and scheduling logic resides. Open
api/src/services/appointments/appointments.ts
and modify thecreateAppointment
function significantly.// api/src/services/appointments/appointments.ts import type { MutationResolvers, QueryResolvers } from 'types/graphql' import { validate } from '@redwoodjs/api' // For basic input presence validation import { RedwoodUser, db } from 'src/lib/db' // Prisma client import { logger } from 'src/lib/logger' // Redwood Logger import MessageBird from 'messagebird' // MessageBird SDK import moment from 'moment' // Moment.js for date handling (Consider date-fns/Luxon) // Initialize MessageBird Client (only once) let messagebird: MessageBird.MessageBird | null = null; try { // Validate that the API key is present if (!process.env.MESSAGEBIRD_API_KEY) { throw new Error('MESSAGEBIRD_API_KEY environment variable not set.') } messagebird = MessageBird(process.env.MESSAGEBIRD_API_KEY) logger.info('MessageBird SDK initialized successfully.') } catch (error) { logger.error({ error }, 'Failed to initialize MessageBird SDK:') // Depending on requirements, you might want the app to fail startup. // Currently, the app continues running, but createAppointment will fail later if the SDK is needed. } // Helper function for date validation const validateAppointmentDateTime = (dateStr: string, timeStr: string) => { // WARNING: This parsing relies on the server's local time zone. // For robust handling, parse using UTC or ensure the input includes timezone info (e.g., ISO 8601). // Consider using moment.utc() or switching to a library that handles timezones explicitly. const appointmentDateTime = moment(`${dateStr} ${timeStr}`, 'YYYY-MM-DD HH:mm'); // Rule: Appointment must be at least 3 hours and 5 minutes in the future const earliestPossibleDateTime = moment().add({ hours: 3, minutes: 5 }); if (!appointmentDateTime.isValid()) { throw new Error('Invalid date or time format provided.'); } if (appointmentDateTime.isBefore(earliestPossibleDateTime)) { throw new Error('Appointment must be scheduled at least 3 hours and 5 minutes from now.'); } return appointmentDateTime; // Return the moment object (potentially in server's local time) } export const appointments: QueryResolvers['appointments'] = () => { return db.appointment.findMany() } export const appointment: QueryResolvers['appointment'] = ({ id }) => { return db.appointment.findUnique({ where: { id }, }) } export const createAppointment: MutationResolvers['createAppointment'] = async ({ input }) => { // 0. Check if MessageBird SDK is initialized if (!messagebird) { logger.error('MessageBird SDK not available. Cannot process appointment.') throw new Error('Appointment scheduling service is temporarily unavailable.') } // 1. Basic Input Validation (Presence) validate(input.name, 'Name', { presence: true }) validate(input.treatment, 'Treatment', { presence: true }) validate(input.number, 'Phone Number', { presence: true }) validate(input.date, 'Date', { presence: true }) validate(input.time, 'Time', { presence: true }) let validatedPhoneNumber: string; let normalizedPhoneNumber: string; try { // 2. Validate Appointment Date/Time // Note: appointmentDateTime may be in server's local time based on validateAppointmentDateTime implementation. const appointmentDateTime = validateAppointmentDateTime(input.date, input.time); // Calculate reminder time relative to the (potentially local) appointment time. const reminderDateTime = appointmentDateTime.clone().subtract({ hours: 3 }); // 3. Validate Phone Number via MessageBird Lookup await new Promise<void>((resolve, reject) => { const countryCode = process.env.MESSAGEBIRD_COUNTRY_CODE || undefined; // Use undefined if not set messagebird!.lookup.read(input.number, countryCode, (err, response) => { if (err) { // Specific error for invalid format if (err.errors && err.errors[0].code === 21) { logger.warn({ number: input.number, error: err }, 'Invalid phone number format provided.'); return reject(new Error('Please enter a valid phone number format.')); } // Other lookup errors logger.error({ number: input.number, error: err }, 'MessageBird Lookup API error.'); return reject(new Error('Could not validate phone number. Please try again.')); } // Check if the number type is mobile if (response && response.type !== 'mobile') { logger.warn({ number: input.number, type: response.type }, 'Non-mobile phone number provided.'); return reject(new Error('Please provide a mobile phone number to receive SMS reminders.')); } // Success - store the validated, normalized number validatedPhoneNumber = input.number; // Or keep original if preferred normalizedPhoneNumber = response.phoneNumber; // E.164 format logger.info({ number: input.number, normalized: normalizedPhoneNumber }, 'Phone number validated successfully.'); resolve(); }); }); // 4. Schedule Reminder SMS via MessageBird const messageParams: MessageBird.MessageParameters = { originator: process.env.MESSAGEBIRD_ORIGINATOR || 'MessageBird', // Fallback originator recipients: [normalizedPhoneNumber], // Use ISO 8601 format (UTC is strongly recommended). Moment's toISOString() provides this. scheduledDatetime: reminderDateTime.toISOString(), body: `${input.name}, here's a reminder for your ${input.treatment} appointment scheduled for ${appointmentDateTime.format('HH:mm')} today. See you soon!`, // Optional: Reference, Report URL etc. // reference: `appointment_${SOME_UNIQUE_ID}` }; const messageResponse = await new Promise<MessageBird.Message>((resolve, reject) => { messagebird!.messages.create(messageParams, (err, response) => { if (err) { logger.error({ error: err, params: messageParams }, 'Failed to schedule MessageBird SMS.'); return reject(new Error('Failed to schedule the SMS reminder. Please try booking again.')); } logger.info({ response }, 'MessageBird SMS scheduled successfully.'); resolve(response); }); }); // 5. Store Appointment in Database const createdAppointment = await db.appointment.create({ data: { name: input.name, treatment: input.treatment, phoneNumber: normalizedPhoneNumber, // Store normalized number // Convert moment object to standard Date object for Prisma. // Ideally, ensure these are stored as UTC in the DB. appointmentDt: appointmentDateTime.toDate(), reminderDt: reminderDateTime.toDate(), messageBirdId: messageResponse.id, // Store message ID }, }); logger.info({ appointmentId: createdAppointment.id }, 'Appointment created successfully in DB.'); return createdAppointment; } catch (error: any) { logger.error({ error: error, input }, 'Error creating appointment:'); // Re-throw the specific error message for the frontend throw new Error(error.message || 'An unexpected error occurred while booking the appointment.'); } } // --- Keep other generated resolvers (updateAppointment, deleteAppointment) --- export const updateAppointment: MutationResolvers['updateAppointment'] = ({ id, input, }) => { // Add validation and logic for updates if needed. // IMPORTANT: This stub does NOT handle updating/canceling the previously scheduled // MessageBird reminder. A full implementation would need to fetch the existing appointment, // potentially cancel the old message via MessageBird API (using messageBirdId), // and schedule a new one if the time changed. This is out of scope for this guide. logger.warn(`Update operation called for appointment ${id} - MessageBird cancellation/rescheduling not implemented.`); throw new Error('Updating appointments with reminder rescheduling is not supported in this example.'); // return db.appointment.update({ // data: input, // Careful with direct input mapping // where: { id }, // }) } export const deleteAppointment: MutationResolvers['deleteAppointment'] = async ({ id, }) => { // IMPORTANT: This stub does NOT handle canceling the scheduled MessageBird reminder. // A full implementation would need to fetch the appointment record BEFORE deleting it, // get the `messageBirdId`, and make a separate API call to MessageBird to attempt // cancellation of the scheduled message. This is out of scope for this guide. logger.warn(`Delete operation called for appointment ${id} - MessageBird SMS cancellation not implemented.`); // Fetch the appointment first if you needed to cancel the message // const appointmentToDelete = await db.appointment.findUnique({ where: { id } }); // if (appointmentToDelete?.messageBirdId) { // // Call MessageBird API to cancel message ID appointmentToDelete.messageBirdId // } // Then delete from DB return db.appointment.delete({ where: { id }, }) }
- Explanation:
- Initialization: Added comment clarifying the implication of not failing fast on SDK init error.
- Validation: Added comments to
validateAppointmentDateTime
warning about server-local time zone parsing and recommending UTC/ISO 8601. - Lookup: Calls
messagebird.lookup.read
asynchronously using Promises. Handles errors and checks if the number is mobile. Throws specific, user-friendly errors. Stores the E.164 formattednormalizedPhoneNumber
. - Scheduling: Calculates the reminder time. Creates parameters for
messagebird.messages.create
, ensuringscheduledDatetime
is formatted as ISO 8601 (UTC recommended). Uses Promises for async handling. - Database: Saves the appointment details, converting Moment objects to Dates. Added comment about ensuring UTC storage.
- Error Handling: Uses
try...catch
blocks. Logs detailed errors using Redwood's logger and throws user-friendly error messages. - Update/Delete: Enhanced comments to explicitly state that MessageBird message cancellation/rescheduling is not implemented and is out of scope.
- Explanation:
You've now implemented the core backend logic. The API can accept booking requests, validate data and phone numbers, schedule SMS reminders via MessageBird, and save appointments to the database.
3. Building the Frontend Interface
Let's create the React components and page for users to interact with the booking system.
-
Generate Page and Component: Use Redwood generators to create the page and a reusable form component.
yarn rw generate page Booking /booking yarn rw generate component BookingForm
- This creates:
web/src/pages/BookingPage/BookingPage.tsx
(and related files) accessible at the/booking
route.web/src/components/BookingForm/BookingForm.tsx
(and related files).
- This creates:
-
Implement the Booking Form Component: Open
web/src/components/BookingForm/BookingForm.tsx
. We'll use Redwood's form helpers for easier state management and validation handling.// web/src/components/BookingForm/BookingForm.tsx import { Form, FormError, Label, TextField, DatetimeLocalField, // Using DatetimeLocalField simplifies date/time input Submit, FieldError, useForm, } from '@redwoodjs/forms' import { navigate, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { logger } from 'src/lib/logger' // Use Redwood's web logger if needed // Consider adding a date library like date-fns for robust parsing/formatting // import { parseISO, format } from 'date-fns' // GraphQL Mutation Definition (must match the backend SDL) const CREATE_APPOINTMENT_MUTATION = gql` mutation CreateAppointmentMutation($input: CreateAppointmentInput!) { createAppointment(input: $input) { id # Request the ID upon successful creation } } ` // Constants for minimum time calculation clarity const MIN_HOURS_ADVANCE = 3; const MIN_MINUTES_BUFFER = 15; // Add buffer to ensure validation passes interface BookingFormProps { onSuccess?: () => void // Optional callback after successful submission } const BookingForm = ({ onSuccess }: BookingFormProps) => { const formMethods = useForm({ mode: 'onBlur' }) // Use onBlur validation mode const [create, { loading, error }] = useMutation( CREATE_APPOINTMENT_MUTATION, { onCompleted: (data) => { toast.success('Appointment booked successfully!') logger.info({ appointmentId: data.createAppointment.id }, 'Appointment created via form.') // Navigate to a confirmation page or call onSuccess callback navigate(routes.bookingSuccess({ id: data.createAppointment.id })) // Example: Navigate to a success page // onSuccess?.() // Alternative: Call callback if provided formMethods.reset() // Reset the form fields }, onError: (error) => { logger.error({ error }, 'Error booking appointment via form:') toast.error(`Booking failed: ${error.message}`) }, } ) const onSubmit = (data) => { // Redwood's DatetimeLocalField provides ISO string like ""YYYY-MM-DDTHH:mm"" // We need to split it for our backend mutation input which expects separate date and time strings. // WARNING: Native `new Date()` parsing can be unreliable across browsers/locales for formats // other than strict ISO 8601 with timezone (like the one from DatetimeLocalField). // Using a library like date-fns (e.g., parseISO) is more robust. try { const dateTime = new Date(data.appointmentDateTime); if (isNaN(dateTime.getTime())) { throw new Error('Invalid date/time selected.') } // NOTE: The following relies on specific string formats and might be fragile. // Using date library functions (e.g., date-fns format(dateTime, 'yyyy-MM-dd')) is preferred. const dateString = dateTime.toISOString().split('T')[0]; // Extracts YYYY-MM-DD from UTC ISO string const timeString = dateTime.toTimeString().split(' ')[0].substring(0, 5); // Extracts HH:mm from local time string const input = { name: data.name, treatment: data.treatment, number: data.number, date: dateString, time: timeString, } logger.debug({ input } , 'Submitting booking form data') create({ variables: { input } }) } catch (e) { logger.error({ error: e, formData: data}, 'Error processing form data before submission') toast.error(`Error processing input: ${e.message}`) } } // Helper to set min date/time for the input field const getMinDateTime = () => { const now = new Date(); // Add minimum advance time (3 hours from backend rule) plus a buffer (15 mins) // to ensure the selected time is valid when it reaches the server. now.setHours(now.getHours() + MIN_HOURS_ADVANCE); now.setMinutes(now.getMinutes() + MIN_MINUTES_BUFFER); // Format for datetime-local input requires 'YYYY-MM-DDTHH:mm' in local time. // Need to adjust for timezone offset if using Date methods that operate in UTC. const offset = now.getTimezoneOffset() // Offset in minutes from UTC const adjustedDate = new Date(now.getTime() - (offset*60*1000)) // Adjust to local time return adjustedDate.toISOString().slice(0,16) // Format to YYYY-MM-DDTHH:mm } return ( <div className=""rw-form-wrapper""> <Form onSubmit={onSubmit} error={error} formMethods={formMethods}> <FormError error={error} wrapperClassName=""rw-form-error-wrapper"" /> <Label name=""name"" className=""rw-label"" errorClassName=""rw-label rw-label-error""> Your Name </Label> <TextField name=""name"" className=""rw-input"" errorClassName=""rw-input rw-input-error"" validation={{ required: true }} /> <FieldError name=""name"" className=""rw-field-error"" /> <Label name=""treatment"" className=""rw-label"" errorClassName=""rw-label rw-label-error""> Desired Treatment </Label> <TextField name=""treatment"" className=""rw-input"" errorClassName=""rw-input rw-input-error"" validation={{ required: true }} /> <FieldError name=""treatment"" className=""rw-field-error"" /> <Label name=""number"" className=""rw-label"" errorClassName=""rw-label rw-label-error""> Mobile Phone Number (for SMS reminder) </Label> <TextField name=""number"" placeholder=""e.g._ +14155552671 or 4155552671"" className=""rw-input"" errorClassName=""rw-input rw-input-error"" validation={{ required: true_ // Basic pattern - backend validation via MessageBird is more reliable // pattern: { // value: /^\+?[1-9]\d{1_14}$/_ // Simple E.164-like pattern // message: 'Please enter a valid phone number format.'_ // }_ }} /> <FieldError name=""number"" className=""rw-field-error"" /> <Label name=""appointmentDateTime"" className=""rw-label"" errorClassName=""rw-label rw-label-error""> Appointment Date and Time </Label> {/* Use datetime-local for combined input */} <DatetimeLocalField name=""appointmentDateTime"" className=""rw-input"" errorClassName=""rw-input rw-input-error"" validation={{ required: true_ valueAsDate: true_ // Ensure the value can be parsed as a Date // Custom validation could also be added here if needed }} min={getMinDateTime()} // Set minimum selectable date/time based on current time + buffer /> <FieldError name=""appointmentDateTime"" className=""rw-field-error"" /> <div className=""rw-button-group""> <Submit disabled={loading} className=""rw-button rw-button-blue""> {loading ? 'Booking...' : 'Book Appointment'} </Submit> </div> </Form> </div> ) } export default BookingForm
- Explanation:
- Imports: Includes necessary components from
@redwoodjs/forms
,@redwoodjs/router
,@redwoodjs/web
, and@redwoodjs/web/toast
. Added logger import. Commented outdate-fns
import but noted its recommendation. - Mutation: Defines the
CREATE_APPOINTMENT_MUTATION
matching the backend SDL. useMutation
Hook: Sets up the mutation call, includingonCompleted
(for success toast, logging, navigation, form reset) andonError
(for error toast and logging) handlers.useForm
Hook: Initializes Redwood's form handling, setting validation mode toonBlur
.onSubmit
Handler:- Retrieves data from the form state.
- Parses the
datetime-local
input string (data.appointmentDateTime
) into aDate
object. Includes a warning about potential nativeDate
parsing issues and recommends a library. - Splits the
Date
object intoYYYY-MM-DD
andHH:mm
strings as required by the backend mutation input. Includes a note about the fragility of string manipulation and preference for date library functions. - Constructs the
input
object for the mutation. - Calls the
create
function fromuseMutation
. - Includes a
try...catch
block for errors during date/time processing before the mutation is sent.
getMinDateTime
Helper: Calculates the earliest selectable date/time for theDatetimeLocalField
based on the backend rule (3 hours) plus a buffer (15 minutes) to prevent validation failures due to timing differences. Formats the date correctly for themin
attribute ofdatetime-local
.- Form Structure: Uses Redwood's
<Form>
,<FormError>
,<Label>
,<TextField>
,<DatetimeLocalField>
,<FieldError>
, and<Submit>
components. - Input Fields: Sets up fields for name, treatment, phone number, and appointment date/time.
- Uses
DatetimeLocalField
for combined date and time input, simplifying the UI. - Includes basic
required
validation. Commented out a basic phone number pattern, emphasizing backend validation is primary. - Sets the
min
attribute onDatetimeLocalField
usinggetMinDateTime
.
- Uses
- Submission: The
<Submit>
button is disabled during loading.
- Imports: Includes necessary components from
- Explanation:
-
Implement the Booking Page: Open
web/src/pages/BookingPage/BookingPage.tsx
. This page will simply render theBookingForm
component.// web/src/pages/BookingPage/BookingPage.tsx import { MetaTags } from '@redwoodjs/web' import BookingForm from 'src/components/BookingForm/BookingForm' const BookingPage = () => { return ( <> <MetaTags title=""Book Appointment"" description=""Schedule your appointment"" /> <h1>Book an Appointment</h1> <p>Fill out the form below to book your appointment. You will receive an SMS reminder 3 hours before your scheduled time.</p> <BookingForm /> </> ) } export default BookingPage
- Explanation: Imports
MetaTags
for SEO and theBookingForm
. Renders a heading, some descriptive text, and the form component.
- Explanation: Imports
-
Add Routes: Ensure the routes are defined in
web/src/Routes.tsx
. Thegenerate page
command should have added the/booking
route. You might also want a success page route.// web/src/Routes.tsx import { Router, Route, Set } from '@redwoodjs/router' import MainLayout from 'src/layouts/MainLayout/MainLayout' // Example layout const Routes = () => { return ( <Router> {/* Add a simple success page component if desired */} {/* <Route path=""/booking-success/{id:Int}"" page={BookingSuccessPage} name=""bookingSuccess"" /> */} <Set wrap={MainLayout}> {/* Wrap pages in a layout if you have one */} <Route path=""/booking"" page={BookingPage} name=""booking"" /> {/* Add other routes here */} <Route notfound page={NotFoundPage} /> </Set> </Router> ) } export default Routes
- Note: Added a commented-out example route
bookingSuccess
which theBookingForm
attempts to navigate to. You would need to generate and implementBookingSuccessPage
for this to work fully.
- Note: Added a commented-out example route
-
Add Toaster: To display the success/error messages from
toast
, you need to add the<Toaster />
component, typically in your main layout file (e.g.,web/src/layouts/MainLayout/MainLayout.tsx
).// web/src/layouts/MainLayout/MainLayout.tsx (Example) import { Toaster } from '@redwoodjs/web/toast' type MainLayoutProps = { children?: React.ReactNode } const MainLayout = ({ children }: MainLayoutProps) => { return ( <div className=""main-layout""> <Toaster toastOptions={{ className: 'rw-toast'_ duration: 6000 }} /> <header>{/* Header content */}</header> <main>{children}</main> <footer>{/* Footer content */}</footer> </div> ) } export default MainLayout
The frontend is now set up. Users can navigate to /booking
, fill out the form, and submit it to trigger the backend logic. Feedback is provided via toasts.
4. Running and Testing
-
Start the Development Server: Run the Redwood development server which starts both the API and web sides with hot-reloading.
yarn rw dev
-
Access the Application: Open your browser and navigate to
http://localhost:8910/booking
(or the port specified in yourredwood.toml
). -
Test Booking:
- Fill out the form with valid details.
- Use a real mobile number you have access to if you want to receive the test SMS (ensure your MessageBird account has credit).
- Select a date and time at least 3 hours and 15 minutes in the future.
- Submit the form.
- Check for the success toast message.
- Verify the appointment record is created in your database (e.g., using Prisma Studio:
yarn rw prisma studio
). - Check your MessageBird dashboard (Logs -> Messages) to see the scheduled SMS.
- Wait for the scheduled time (minus 3 hours) to see if you receive the SMS reminder.
- Fill out the form with valid details.
-
Test Validation:
- Try submitting with empty fields.
- Try entering an invalid phone number format.
- Try entering a non-mobile number (like a landline, if MessageBird Lookup identifies it as such).
- Try selecting a time less than 3 hours in the future.
- Observe the error messages displayed via toasts and field errors.
Conclusion and Next Steps
You have successfully built a RedwoodJS application that integrates with MessageBird to accept appointment bookings and schedule automated SMS reminders. This setup leverages Redwood's conventions for rapid development, Prisma for database management, GraphQL for API communication, and MessageBird for crucial communication features.
Potential Enhancements:
- Confirmation Page: Create the
BookingSuccessPage
component and route. - Update/Cancel Flow: Implement logic to update appointments and, crucially, cancel or reschedule the corresponding MessageBird scheduled messages using the stored
messageBirdId
. - Authentication: Add user accounts (
yarn rw generate auth ...
) so users can manage their own appointments. - Admin Dashboard: Create pages for administrators to view and manage all appointments.
- Timezone Handling: Implement robust timezone handling (e.g., using
date-fns-tz
or Luxon) to ensure appointment and reminder times are correct regardless of user or server location. Store dates in UTC. - Error Handling: Add more granular error handling and user feedback.
- Deployment: Deploy the application to a hosting provider like Vercel, Netlify, or Render (refer to RedwoodJS deployment documentation). Ensure environment variables (
DATABASE_URL
,MESSAGEBIRD_API_KEY
, etc.) are configured correctly in the deployment environment. - MessageBird Webhooks: Implement webhooks to receive real-time status updates for sent SMS messages (e.g., delivered, failed).