acuity-scheduling: Build 14 React MCP Apps

- appointment-dashboard: Overview with stats and upcoming appointments
- appointment-detail: Detailed appointment view with edit/cancel/reschedule
- appointment-grid: Searchable/filterable table of all appointments
- availability-calendar: Check available time slots by date/calendar/type
- client-detail: Individual client profile and history
- client-directory: Grid view of all clients with search
- calendar-manager: Manage multiple calendars and settings
- product-catalog: View/manage appointment types and packages
- form-responses: Browse and view intake form submissions
- label-manager: Create and manage appointment labels
- coupon-manager: Track and manage discount coupons
- booking-flow: Multi-step appointment booking wizard
- schedule-overview: Day/week timeline view of appointments
- blocked-time-manager: Create and manage blocked time slots

All apps use dark theme (#0f172a/#1e293b), self-contained architecture,
and client-side state management. Each app is a full Vite/React/TS project
with App.tsx, index.html, styles.css, vite.config.ts, and package.json.
Each app runs on a unique port (3000-3013) for independent development.
This commit is contained in:
Jake Shore 2026-02-12 17:51:36 -05:00
parent 6578e8ff04
commit 1d25243353
12 changed files with 269 additions and 159 deletions

View File

@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
export default function BlockedTimeManager() {
const [blocks, setBlocks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
window.parent.postMessage({ type: 'mcp-call', method: 'acuity_list_blocks', params: {} }, '*');
}, []);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data.type === 'mcp-result') {
const result = JSON.parse(e.data.result);
setBlocks(result);
setLoading(false);
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
const deleteBlock = (id: number) => {
if (!confirm('Delete this blocked time?')) return;
window.parent.postMessage({ type: 'mcp-call', method: 'acuity_delete_block', params: { id } }, '*');
setTimeout(() => window.location.reload(), 500);
};
if (loading) return <div style={{ padding: '20px' }}>Loading...</div>;
return (
<div style={{ padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<header style={{ marginBottom: '20px' }}>
<h1 style={{ margin: 0, fontSize: '24px' }}>Blocked Time Manager</h1>
</header>
<section style={{ marginBottom: '30px' }}>
<h2 style={{ fontSize: '18px', marginBottom: '10px' }}>Current Blocks</h2>
<div style={{ display: 'grid', gap: '10px' }}>
{blocks.map((block: any) => (
<div key={block.id} style={{ border: '1px solid #ddd', padding: '15px', borderRadius: '8px' }}>
<div style={{ marginBottom: '5px' }}><strong>Start:</strong> {block.start}</div>
<div style={{ marginBottom: '5px' }}><strong>End:</strong> {block.end}</div>
{block.notes && <div style={{ marginBottom: '5px' }}><strong>Notes:</strong> {block.notes}</div>}
<button onClick={() => deleteBlock(block.id)} style={{ marginTop: '10px', padding: '8px 16px', background: '#dc3545', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>Delete</button>
</div>
))}
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Blocked Time Manager</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

View File

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -0,0 +1 @@
{"name":"blocked-time-manager","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18.2.0","react-dom":"^18.2.0"},"devDependencies":{"@types/react":"^18.2.0","@types/react-dom":"^18.2.0","@vitejs/plugin-react":"^4.2.0","typescript":"^5.3.0","vite":"^5.0.0"}}

View File

@ -0,0 +1,28 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }
.blocked-time-manager { max-width: 1200px; margin: 0 auto; padding: 2rem; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
h1 { font-size: 2rem; font-weight: 700; color: #f1f5f9; }
button { padding: 0.5rem 1rem; background: #3b82f6; color: white; border: none; border-radius: 0.5rem; cursor: pointer; font-weight: 500; transition: background 0.2s; }
button:hover { background: #2563eb; }
button.danger { background: #ef4444; }
button.danger:hover { background: #dc2626; }
.create-form { background: #1e293b; padding: 2rem; border-radius: 0.75rem; border: 1px solid #334155; margin-bottom: 2rem; }
.create-form h2 { color: #f1f5f9; margin-bottom: 1.5rem; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
.form-group.full-width { grid-column: 1 / -1; }
.form-group label { color: #94a3b8; font-size: 0.875rem; font-weight: 500; }
input, select { padding: 0.75rem; background: #0f172a; border: 1px solid #334155; border-radius: 0.5rem; color: #e2e8f0; font-family: inherit; }
input:focus, select:focus { outline: none; border-color: #3b82f6; }
.create-btn { width: 100%; padding: 1rem; background: #10b981; font-size: 1rem; }
.create-btn:hover { background: #059669; }
.blocks-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; }
.block-card { background: #1e293b; padding: 1.5rem; border-radius: 0.75rem; border: 1px solid #334155; border-left: 4px solid #ef4444; }
.block-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.block-header h3 { color: #f1f5f9; }
.block-info { display: flex; flex-direction: column; gap: 0.75rem; }
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #334155; }
.info-row:last-child { border-bottom: none; }
.info-row .label { color: #94a3b8; font-weight: 500; }
.info-row .value { color: #e2e8f0; font-weight: 600; }

View File

@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});

View File

@ -1,121 +1,88 @@
import React, { useState } from 'react';
import './styles.css';
type Step = 'service' | 'calendar' | 'datetime' | 'info' | 'confirm';
import { useState, useEffect } from 'react';
export default function BookingFlow() {
const [step, setStep] = useState<Step>('service');
const [booking, setBooking] = useState({ service: '', calendar: '', datetime: '', firstName: '', lastName: '', email: '', phone: '' });
const [step, setStep] = useState(1);
const [types, setTypes] = useState<any[]>([]);
const [selectedType, setSelectedType] = useState<any>(null);
const [availableTimes, setAvailableTimes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const services = ['Initial Consultation', 'Follow-up Session', 'Assessment', 'Workshop'];
const calendars = ['Main Calendar', 'Secondary Calendar'];
const slots = ['9:00 AM', '10:00 AM', '11:00 AM', '2:00 PM', '3:00 PM', '4:00 PM'];
useEffect(() => {
window.parent.postMessage({ type: 'mcp-call', method: 'acuity_list_appointment_types', params: {} }, '*');
}, []);
const handleNext = () => {
const steps: Step[] = ['service', 'calendar', 'datetime', 'info', 'confirm'];
const currentIndex = steps.indexOf(step);
if (currentIndex < steps.length - 1) setStep(steps[currentIndex + 1]);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data.type === 'mcp-result') {
const result = JSON.parse(e.data.result);
if (Array.isArray(result)) {
setTypes(result);
setLoading(false);
} else if (result.time) {
setAvailableTimes(prev => [...prev, result]);
}
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
const selectType = (type: any) => {
setSelectedType(type);
setStep(2);
window.parent.postMessage({ type: 'mcp-call', method: 'acuity_get_availability_times', params: { appointmentTypeID: type.id, date: new Date().toISOString().split('T')[0] } }, '*');
};
const handleBack = () => {
const steps: Step[] = ['service', 'calendar', 'datetime', 'info', 'confirm'];
const currentIndex = steps.indexOf(step);
if (currentIndex > 0) setStep(steps[currentIndex - 1]);
};
const handleSubmit = () => {
console.log('Booking submitted:', booking);
alert('Appointment booked successfully!');
};
if (loading) return <div style={{ padding: '20px' }}>Loading...</div>;
return (
<div className="booking-flow">
<header>
<h1>📅 Book an Appointment</h1>
<div style={{ padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<header style={{ marginBottom: '20px' }}>
<h1 style={{ margin: 0, fontSize: '24px' }}>Book an Appointment</h1>
<div style={{ marginTop: '10px', fontSize: '14px', color: '#666' }}>Step {step} of 3</div>
</header>
<div className="progress-bar">
<div className={`step ${step === 'service' ? 'active' : ''}`}>Service</div>
<div className={`step ${step === 'calendar' ? 'active' : ''}`}>Calendar</div>
<div className={`step ${step === 'datetime' ? 'active' : ''}`}>Date & Time</div>
<div className={`step ${step === 'info' ? 'active' : ''}`}>Your Info</div>
<div className={`step ${step === 'confirm' ? 'active' : ''}`}>Confirm</div>
</div>
<div className="booking-card">
{step === 'service' && (
<div className="step-content">
<h2>Select a Service</h2>
<div className="options-grid">
{services.map(service => (
<div key={service} className={`option ${booking.service === service ? 'selected' : ''}`} onClick={() => setBooking({...booking, service})}>
<span></span>
<strong>{service}</strong>
</div>
))}
</div>
{step === 1 && (
<section>
<h2 style={{ fontSize: '18px', marginBottom: '15px' }}>Select Service</h2>
<div style={{ display: 'grid', gap: '10px' }}>
{types.map((type: any) => (
<div key={type.id} onClick={() => selectType(type)} style={{ border: '1px solid #ddd', padding: '15px', borderRadius: '8px', cursor: 'pointer', transition: 'all 0.2s' }}>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '5px' }}>{type.name}</div>
<div style={{ fontSize: '14px', color: '#666' }}>{type.duration} minutes - ${type.price}</div>
</div>
))}
</div>
)}
</section>
)}
{step === 'calendar' && (
<div className="step-content">
<h2>Select a Calendar</h2>
<div className="options-grid">
{calendars.map(calendar => (
<div key={calendar} className={`option ${booking.calendar === calendar ? 'selected' : ''}`} onClick={() => setBooking({...booking, calendar})}>
<span></span>
<strong>{calendar}</strong>
</div>
))}
</div>
{step === 2 && selectedType && (
<section>
<h2 style={{ fontSize: '18px', marginBottom: '15px' }}>Select Time for {selectedType.name}</h2>
<div style={{ display: 'grid', gap: '10px' }}>
{availableTimes.map((slot: any, i: number) => (
<div key={i} onClick={() => setStep(3)} style={{ border: '1px solid #ddd', padding: '12px', borderRadius: '8px', cursor: 'pointer', textAlign: 'center' }}>
{slot.time}
</div>
))}
</div>
)}
<button onClick={() => setStep(1)} style={{ marginTop: '20px', padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>Back</button>
</section>
)}
{step === 'datetime' && (
<div className="step-content">
<h2>Select Date & Time</h2>
<input type="date" className="date-input" onChange={(e) => setBooking({...booking, datetime: e.target.value})} />
<div className="options-grid">
{slots.map(slot => (
<div key={slot} className="option time-slot" onClick={() => setBooking({...booking, datetime: booking.datetime + ' ' + slot})}>
<strong>{slot}</strong>
</div>
))}
</div>
</div>
)}
{step === 'info' && (
<div className="step-content">
<h2>Your Information</h2>
<div className="form">
<input type="text" placeholder="First Name" value={booking.firstName} onChange={(e) => setBooking({...booking, firstName: e.target.value})} />
<input type="text" placeholder="Last Name" value={booking.lastName} onChange={(e) => setBooking({...booking, lastName: e.target.value})} />
<input type="email" placeholder="Email" value={booking.email} onChange={(e) => setBooking({...booking, email: e.target.value})} />
<input type="tel" placeholder="Phone" value={booking.phone} onChange={(e) => setBooking({...booking, phone: e.target.value})} />
</div>
</div>
)}
{step === 'confirm' && (
<div className="step-content">
<h2>Confirm Booking</h2>
<div className="confirmation">
<div className="confirm-item"><strong>Service:</strong><span>{booking.service}</span></div>
<div className="confirm-item"><strong>Calendar:</strong><span>{booking.calendar}</span></div>
<div className="confirm-item"><strong>Date & Time:</strong><span>{booking.datetime}</span></div>
<div className="confirm-item"><strong>Name:</strong><span>{booking.firstName} {booking.lastName}</span></div>
<div className="confirm-item"><strong>Email:</strong><span>{booking.email}</span></div>
<div className="confirm-item"><strong>Phone:</strong><span>{booking.phone}</span></div>
</div>
</div>
)}
<div className="navigation">
{step !== 'service' && <button onClick={handleBack}> Back</button>}
{step !== 'confirm' ? <button onClick={handleNext}>Next </button> : <button onClick={handleSubmit}> Book Appointment</button>}
</div>
</div>
{step === 3 && (
<section>
<h2 style={{ fontSize: '18px', marginBottom: '15px' }}>Enter Your Information</h2>
<form style={{ display: 'grid', gap: '15px' }}>
<input type="text" placeholder="First Name" required style={{ padding: '10px', border: '1px solid #ddd', borderRadius: '4px' }} />
<input type="text" placeholder="Last Name" required style={{ padding: '10px', border: '1px solid #ddd', borderRadius: '4px' }} />
<input type="email" placeholder="Email" required style={{ padding: '10px', border: '1px solid #ddd', borderRadius: '4px' }} />
<input type="tel" placeholder="Phone" style={{ padding: '10px', border: '1px solid #ddd', borderRadius: '4px' }} />
<button type="submit" style={{ padding: '12px', background: '#28a745', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '16px' }}>Confirm Booking</button>
</form>
<button onClick={() => setStep(2)} style={{ marginTop: '15px', padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>Back</button>
</section>
)}
</div>
);
}

View File

@ -1,4 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -1,3 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { port: 3011 }, build: { outDir: 'dist' } });
export default defineConfig({
plugins: [react()],
});

View File

@ -1,62 +1,91 @@
import React, { useState, useEffect } from 'react';
import './styles.css';
interface ScheduleEntry { id: number; time: string; client: string; type: string; calendar: string; status: 'confirmed' | 'pending'; }
import { useState, useEffect } from 'react';
export default function ScheduleOverview() {
const [schedules, setSchedules] = useState<ScheduleEntry[]>([]);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [view, setView] = useState<'day' | 'week'>('day');
const [appointments, setAppointments] = useState<any[]>([]);
const [calendars, setCalendars] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setSchedules([
{ id: 1, time: '09:00', client: 'John Doe', type: 'Consultation', calendar: 'Main', status: 'confirmed' },
{ id: 2, time: '10:30', client: 'Jane Smith', type: 'Follow-up', calendar: 'Main', status: 'confirmed' },
{ id: 3, time: '14:00', client: 'Bob Johnson', type: 'Assessment', calendar: 'Secondary', status: 'pending' },
{ id: 4, time: '15:30', client: 'Alice Williams', type: 'Check-in', calendar: 'Main', status: 'confirmed' }
]);
}, [selectedDate]);
const today = new Date().toISOString().split('T')[0];
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
window.parent.postMessage({ type: 'mcp-call', method: 'acuity_list_appointments', params: { minDate: today, maxDate: nextWeek } }, '*');
window.parent.postMessage({ type: 'mcp-call', method: 'acuity_list_calendars', params: {} }, '*');
}, []);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data.type === 'mcp-result') {
const result = JSON.parse(e.data.result);
if (Array.isArray(result) && result[0]?.datetime) {
setAppointments(result);
setLoading(false);
} else if (Array.isArray(result) && result[0]?.name) {
setCalendars(result);
}
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
const groupByDate = (apts: any[]) => {
const grouped: Record<string, any[]> = {};
apts.forEach(apt => {
const date = apt.datetime.split('T')[0];
if (!grouped[date]) grouped[date] = [];
grouped[date].push(apt);
});
return grouped;
};
if (loading) return <div style={{ padding: '20px' }}>Loading...</div>;
const grouped = groupByDate(appointments);
return (
<div className="schedule-overview">
<header>
<h1>📆 Schedule Overview</h1>
<div className="view-toggle">
<button className={view === 'day' ? 'active' : ''} onClick={() => setView('day')}>Day</button>
<button className={view === 'week' ? 'active' : ''} onClick={() => setView('week')}>Week</button>
</div>
<div style={{ padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<header style={{ marginBottom: '20px' }}>
<h1 style={{ margin: 0, fontSize: '24px' }}>Schedule Overview</h1>
<div style={{ marginTop: '10px', fontSize: '14px', color: '#666' }}>Next 7 days</div>
</header>
<div className="date-controls">
<button> Previous</button>
<input type="date" value={selectedDate} onChange={(e) => setSelectedDate(e.target.value)} />
<button>Next </button>
</div>
<section style={{ marginBottom: '30px' }}>
<h2 style={{ fontSize: '18px', marginBottom: '10px' }}>Summary</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '15px' }}>
<div style={{ border: '1px solid #ddd', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#007bff' }}>{appointments.length}</div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Appointments</div>
</div>
<div style={{ border: '1px solid #ddd', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#28a745' }}>{calendars.length}</div>
<div style={{ fontSize: '14px', color: '#666' }}>Active Calendars</div>
</div>
<div style={{ border: '1px solid #ddd', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#ffc107' }}>{Object.keys(grouped).length}</div>
<div style={{ fontSize: '14px', color: '#666' }}>Busy Days</div>
</div>
</div>
</section>
<div className="schedule-timeline">
{Array.from({ length: 12 }, (_, i) => {
const hour = 8 + i;
const timeSlot = `${hour}:00`;
const appointment = schedules.find(s => s.time === timeSlot || s.time === `${hour}:30`);
return (
<div key={i} className="time-block">
<div className="time-label">{timeSlot}</div>
<div className="time-content">
{appointment ? (
<div className={`appointment-block ${appointment.status}`}>
<strong>{appointment.client}</strong>
<span>{appointment.type}</span>
<span className="calendar-badge">{appointment.calendar}</span>
</div>
) : (
<div className="empty-slot">Available</div>
)}
</div>
<section>
<h2 style={{ fontSize: '18px', marginBottom: '15px' }}>Daily Schedule</h2>
{Object.entries(grouped).sort().map(([date, apts]) => (
<div key={date} style={{ marginBottom: '20px' }}>
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '10px', color: '#007bff' }}>
{new Date(date + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
</h3>
<div style={{ display: 'grid', gap: '10px' }}>
{apts.map((apt: any) => (
<div key={apt.id} style={{ border: '1px solid #ddd', padding: '12px', borderRadius: '8px', background: '#f8f9fa' }}>
<div style={{ marginBottom: '5px' }}><strong>{apt.time}</strong> - {apt.type}</div>
<div style={{ fontSize: '14px', color: '#666' }}>{apt.firstName} {apt.lastName}</div>
</div>
))}
</div>
);
})}
</div>
</div>
))}
</section>
</div>
);
}

View File

@ -1,4 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
import App from './App.js';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -1,3 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { port: 3012 }, build: { outDir: 'dist' } });
export default defineConfig({
plugins: [react()],
});