Service Manager
Complete service management including categories, services, bundles, and archive functionality.
This demo is self-contained - it creates two merchants during setup:
- Empty State Merchant - Shows the empty state UI (no categories/services)
- Steady State Merchant - Pre-seeded with categories, services, and a bundle
Switch between merchants using the "View as" toggle to explore both states.
Quick Links
| Resource | Description |
|---|---|
| Service Categories API | Create, list, update, delete categories |
| Services API | Full service CRUD with variants |
| Service Packages API | Bundle/package management |
Interactive Demo
- Preview
- View Source
Loading...
ServiceManagerDemo.jsx
/**
* Service Manager Demo - Main export file
*
* This file re-exports the modular Service Manager components.
* The actual implementation has been split into separate files under
* ./service-manager/ for better maintainability.
*
* Components:
* - SetupPanel - Connect → Login → Setup flow
* - ServiceManagerUI - Main UI with user switcher
* - CategorySidebar / CategoryModal - Category management
* - ServiceList / ServiceCard - Service display
* - ServiceModal - Add/Edit service (4 tabs)
* - BundleModal - Add/Edit bundle (4 tabs)
* - CookbookSection - Preview/Source tabs for test cases
*/
// Re-export everything from the modular structure
export {
SetupPanel,
ServiceManagerUI,
UserSwitcher,
CategorySidebar,
CategoryItem,
CategoryModal,
ServiceList,
ServiceCard,
ServiceModal,
BundleModal,
CookbookSection,
SectionDivider,
colors,
baseStyles,
calendarColors,
STORAGE_KEYS,
durationOptions,
priceTypeOptions,
triggerTimeOptions,
triggerReferenceOptions,
SERVICE_TYPES,
defaultServiceForm,
defaultCategoryForm,
defaultBundleForm,
} from './service-manager';
// Default export for backward compatibility
export { default } from './service-manager';
Workflows
1. Empty State → First Category
When a merchant has no categories, the UI shows "No Categories Available".
import { ServiceCategoryService } from '@scp/sdk';
// Create first category
const category = await ServiceCategoryService.serviceCategoryCreate({
requestBody: {
name: 'Hair & Styling',
description: 'Haircuts, coloring, styling, and extensions',
tenant_id: tenantId,
merchant_id: merchantId
}
});
console.log('Created category:', category.id);
2. Create Service Under Category
import { ServiceService } from '@scp/sdk';
// Step 1: Create the service
const service = await ServiceService.serviceCreate({
requestBody: {
name: 'Hair Color',
description: 'Full hair coloring service',
default_duration_minutes: 90,
default_price: { amount: 12500, currency: 'USD' }, // $125.00
ui_parameters: { calendar_color: 'pink' },
is_active: true,
tenant_id: tenantId,
merchant_id: merchantId
}
});
// Step 2: Assign to category
await ServiceService.serviceAddCategory({
serviceId: service.id,
requestBody: { category_id: categoryId }
});
3. Create Service Bundle
Bundles combine multiple services into a package.
import { ServicePackageService } from '@scp/sdk';
// Create a bundle with discount pricing
const bundle = await ServicePackageService.servicePackageCreate({
requestBody: {
name: 'Platinum Hair Package',
description: 'Includes cut, color, and styling',
service_type: 'bundle',
default_price: { amount: 20000, currency: 'USD' }, // $200 (vs $250 separate)
is_active: true,
tenant_id: tenantId,
merchant_id: merchantId
}
});
console.log('Bundle created:', bundle.id);
4. Archive/Restore Services
Archive hides services from booking without deleting them.
// Archive a service (set is_active to false)
await ServiceService.serviceUpdate({
id: serviceId,
requestBody: {
is_active: false,
tenant_id: tenantId,
merchant_id: merchantId
}
});
// Restore a service
await ServiceService.serviceUpdate({
id: serviceId,
requestBody: {
is_active: true,
tenant_id: tenantId,
merchant_id: merchantId
}
});
5. Create Service Variant
Variants are alternative versions of a service (e.g., different durations).
// Create a variant of an existing service
const variant = await ServiceService.serviceCreateVariant({
serviceId: parentServiceId,
requestBody: {
name: 'Hair Color - Express',
description: 'Quick touch-up coloring',
default_duration_minutes: 45,
default_price: { amount: 7500, currency: 'USD' }, // $75.00
tenant_id: tenantId,
merchant_id: merchantId
}
});
// Variants are automatically linked to parent via parent_service_id
console.log('Variant parent:', variant.parent_service_id);
API Reference
Service Categories
- List
- Create
- Update
- Delete
import { ServiceCategoryService } from '@scp/sdk';
const result = await ServiceCategoryService.serviceCategoryList({
page: 1,
pageSize: 50,
orderBy: 'name'
});
// Response
{
data: [
{
id: 'uuid',
name: 'Hair & Styling',
description: 'Haircuts, coloring, styling',
order_index: 0,
is_active: true,
merchant_id: 'uuid',
tenant_id: 'uuid',
inserted_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z'
}
],
meta: {
total_count: 5,
page: 1,
page_size: 50
}
}
const category = await ServiceCategoryService.serviceCategoryCreate({
requestBody: {
name: 'Nail Care', // Required
description: 'Manicures...', // Optional
order_index: 1, // Optional (display order)
tenant_id: tenantId, // Required
merchant_id: merchantId // Required
}
});
await ServiceCategoryService.serviceCategoryUpdate({
id: categoryId,
requestBody: {
name: 'Nail Services',
description: 'Updated description',
order_index: 2,
tenant_id: tenantId,
merchant_id: merchantId
}
});
// Note: Fails if services are still assigned to this category
await ServiceCategoryService.serviceCategoryDelete({
id: categoryId
});
Services
- List
- Create
- Get
- Update
- Delete
import { ServiceService } from '@scp/sdk';
// List only base services (excludes variants and bundles)
const result = await ServiceService.serviceList({
page: 1,
pageSize: 50
});
// Each service includes:
// - id, name, description
// - default_duration_minutes
// - default_price: { amount, currency }
// - ui_parameters: { calendar_color }
// - service_type: 'standalone' | 'variant' | 'bundle'
// - is_active
// - categories: [{ id, name }]
const service = await ServiceService.serviceCreate({
requestBody: {
name: 'Deep Tissue Massage', // Required
description: 'Therapeutic massage', // Optional
default_duration_minutes: 60, // Optional, default varies
default_price: { // Optional
amount: 9500, // Cents
currency: 'USD'
},
ui_parameters: { // Optional
calendar_color: 'blue' // For scheduling UI
},
is_active: true, // Optional, default true
tenant_id: tenantId, // Required
merchant_id: merchantId // Required
}
});
const service = await ServiceService.serviceShow({
id: serviceId
});
// Response includes full service details plus:
// - categories: assigned categories
// - variants: child variants (if any)
// - bundles: packages containing this service
await ServiceService.serviceUpdate({
id: serviceId,
requestBody: {
name: 'Updated Service Name',
default_duration_minutes: 75,
default_price: { amount: 10000, currency: 'USD' },
ui_parameters: { calendar_color: 'green' },
is_active: true,
tenant_id: tenantId,
merchant_id: merchantId
}
});
await ServiceService.serviceDelete({
id: serviceId
});
Service-Category Mappings
- List Categories
- Add Category
- Remove Category
// Get categories assigned to a service
const result = await ServiceService.serviceListCategories({
serviceId: serviceId
});
// Returns array of categories
console.log(result.data);
// Assign a category to a service
await ServiceService.serviceAddCategory({
serviceId: serviceId,
requestBody: {
category_id: categoryId
}
});
// Note: Returns 422 if already assigned (duplicate)
// Remove category from service
await ServiceService.serviceRemoveCategory({
serviceId: serviceId,
categoryId: categoryId
});
// Note: Returns 200 even if not assigned (idempotent)
Service Variants
- List Variants
- Create Variant
// Get all variants of a service
const result = await ServiceService.serviceListVariants({
serviceId: parentServiceId
});
// Variants have parent_service_id set
result.data.forEach(v => {
console.log(`${v.name} (parent: ${v.parent_service_id})`);
});
// Create a variant (automatically sets parent_service_id)
const variant = await ServiceService.serviceCreateVariant({
serviceId: parentServiceId,
requestBody: {
name: 'Massage - 90 min',
default_duration_minutes: 90,
default_price: { amount: 13500, currency: 'USD' },
tenant_id: tenantId,
merchant_id: merchantId
}
});
// Variant properties:
// - service_type: 'variant' (auto-set)
// - parent_service_id: parentServiceId (auto-set)
Service Packages (Bundles)
- List Packages
- Create Package
- Get Package
- Delete Package
import { ServicePackageService } from '@scp/sdk';
// List only bundles (service_type: 'bundle')
const result = await ServicePackageService.servicePackageList({
page: 1,
pageSize: 50
});
// Each bundle includes bundled services
result.data.forEach(bundle => {
console.log(`${bundle.name}: ${bundle.services?.length} services`);
});
const bundle = await ServicePackageService.servicePackageCreate({
requestBody: {
name: 'Bridal Package',
description: 'Hair, makeup, and nails for the big day',
service_type: 'bundle', // Required for bundles
default_price: { amount: 35000, currency: 'USD' },
is_active: true,
tenant_id: tenantId,
merchant_id: merchantId
}
});
const bundle = await ServicePackageService.servicePackageShow({
id: bundleId
});
// Includes all bundled services
console.log('Bundled services:', bundle.services);
// Deletes bundle and all service mappings
await ServicePackageService.servicePackageDelete({
id: bundleId
});
UI Data Models
Service Object
interface Service {
id: string;
name: string;
description?: string;
default_duration_minutes?: number;
default_price?: {
amount: number; // In cents
currency: string; // 'USD'
};
ui_parameters?: {
calendar_color?: string;
};
service_type: 'standalone' | 'variant' | 'bundle';
is_active: boolean;
parent_service_id?: string; // For variants
categories?: Category[];
merchant_id: string;
tenant_id: string;
inserted_at: string;
updated_at: string;
}
Category Object
interface Category {
id: string;
name: string;
description?: string;
order_index?: number;
is_active: boolean;
merchant_id: string;
tenant_id: string;
inserted_at: string;
updated_at: string;
}
Best Practices
Category Organization
| Category Type | Examples | Notes |
|---|---|---|
| Service Type | Hair, Nails, Skincare | Group by what you offer |
| Duration | Quick Services, Full Treatments | Group by time commitment |
| Price Tier | Essential, Premium, Luxury | Group by pricing |
Naming Services
- Use clear, client-friendly names
- Include key differentiators (duration, style)
- Avoid internal codes or abbreviations
// Good names
'Haircut - Women\'s'
'Deep Tissue Massage - 60 min'
'Gel Manicure with Art'
// Avoid
'HCW001'
'DTM1H'
'GMWA'
Bundle Pricing
// Calculate savings for bundles
const individualTotal = services.reduce((sum, s) =>
sum + (s.default_price?.amount || 0), 0
);
const bundlePrice = Math.round(individualTotal * 0.85); // 15% discount
const bundle = await ServicePackageService.servicePackageCreate({
requestBody: {
name: 'Spa Day Package (Save 15%)',
default_price: { amount: bundlePrice, currency: 'USD' },
// ...
}
});
Archive vs Delete
| Action | Use When | Recoverable |
|---|---|---|
| Archive | Seasonal services, temporarily unavailable | Yes |
| Delete | Never offered, created by mistake | No |
Error Handling
try {
await ServiceService.serviceCreate({ requestBody: data });
} catch (error) {
if (error.status === 422) {
// Validation error - check error.body for details
console.error('Validation failed:', error.body.errors);
} else if (error.status === 404) {
// Resource not found (e.g., invalid merchant_id)
console.error('Not found:', error.body.error);
} else if (error.status === 401) {
// Authentication required
console.error('Please login first');
}
}
Next Steps
- API Reference - Use services in appointment booking
- API Reference - Configure payment rules per service
- API Reference - Assign staff to services