Update staff roles, documentation, and add tenant API

Staff Roles:
- Remove Support Staff default role (now Manager and Staff only)
- Add position field for custom role ordering
- Update StaffRolesSettings with improved permission UI
- Add RolePermissions component for visual permission display

Documentation Updates:
- HelpStaff: Explain two-tier permission system (User Roles + Staff Roles)
- HelpSettingsStaffRoles: Update default roles, add settings access permissions
- HelpComprehensive: Update staff roles section with correct role structure
- HelpCustomers: Add customer creation and onboarding sections
- HelpContracts: Add lifecycle, snapshotting, and signing experience docs
- HelpSettingsAppearance: Update with 20 color palettes and navigation text

Tenant API:
- Add new isolated API at /tenant-api/v1/ for third-party integrations
- Token-based authentication with scope permissions
- Endpoints: business, services, resources, availability, bookings, customers, webhooks

Tests:
- Add test coverage for Celery tasks across modules
- Reorganize schedule view tests for better maintainability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-24 20:46:36 -05:00
parent d8d3a4e846
commit 464726ee3e
47 changed files with 7826 additions and 2723 deletions

View File

@@ -829,7 +829,6 @@ const AppContent: React.FC = () => {
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
<Route path="/dashboard/help/automations" element={<HelpAutomations />} />
<Route path="/dashboard/help/site-builder" element={<HelpSiteBuilder />} />
<Route path="/dashboard/help/api" element={<HelpApiOverview />} />
<Route path="/dashboard/help/api/appointments" element={<HelpApiAppointments />} />
<Route path="/dashboard/help/api/services" element={<HelpApiServices />} />
<Route path="/dashboard/help/api/resources" element={<HelpApiResources />} />

View File

@@ -566,7 +566,7 @@ const ApiTokensSection: React.FC = () => {
</div>
<div className="flex items-center gap-2">
<a
href="/help/api"
href="/dashboard/help/api"
className="px-3 py-2 text-sm font-medium text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-lg transition-colors flex items-center gap-2"
>
<ExternalLink size={16} />

View File

@@ -0,0 +1,266 @@
/**
* Shared Role Permission Components
*
* Reusable components for displaying and editing staff role permissions.
* Used in both StaffRolesSettings and the Invite Staff modal.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { PermissionDefinition } from '../../types';
export interface PermissionSectionProps {
title: string;
description: string;
permissions: Record<string, PermissionDefinition>;
values: Record<string, boolean>;
onChange: (key: string, value: boolean) => void;
onSelectAll?: () => void;
onClearAll?: () => void;
variant?: 'default' | 'settings' | 'dangerous';
readOnly?: boolean;
columns?: 1 | 2;
}
/**
* A section of permission checkboxes with header and select/clear all buttons
*/
export const PermissionSection: React.FC<PermissionSectionProps> = ({
title,
description,
permissions,
values,
onChange,
onSelectAll,
onClearAll,
variant = 'default',
readOnly = false,
columns = 2,
}) => {
const { t } = useTranslation();
const variantStyles = {
default: {
container: '',
checkbox: 'text-brand-600 focus:ring-brand-500',
hover: 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
},
settings: {
container: 'p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30',
checkbox: 'text-blue-600 focus:ring-blue-500',
hover: 'hover:bg-blue-100/50 dark:hover:bg-blue-900/20',
},
dangerous: {
container: 'p-3 bg-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30',
checkbox: 'text-red-600 focus:ring-red-500',
hover: 'hover:bg-red-100/50 dark:hover:bg-red-900/20',
},
};
const styles = variantStyles[variant];
const gridCols = columns === 1 ? 'grid-cols-1' : 'grid-cols-2';
return (
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
{title}
{variant === 'dangerous' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">
{t('common.caution', 'Caution')}
</span>
)}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">{description}</p>
</div>
{!readOnly && onSelectAll && onClearAll && (
<div className="flex gap-2">
<button
type="button"
onClick={onSelectAll}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={onClearAll}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
)}
</div>
<div className={`grid ${gridCols} gap-2 ${styles.container}`}>
{Object.entries(permissions).map(([key, def]) => (
<PermissionCheckbox
key={key}
permissionKey={key}
definition={def}
checked={values[key] || false}
onChange={(value) => onChange(key, value)}
checkboxClass={styles.checkbox}
hoverClass={styles.hover}
readOnly={readOnly}
/>
))}
</div>
</div>
);
};
interface PermissionCheckboxProps {
permissionKey: string;
definition: PermissionDefinition;
checked: boolean;
onChange: (value: boolean) => void;
checkboxClass?: string;
hoverClass?: string;
readOnly?: boolean;
}
/**
* Individual permission checkbox with label and description
*/
export const PermissionCheckbox: React.FC<PermissionCheckboxProps> = ({
permissionKey,
definition,
checked,
onChange,
checkboxClass = 'text-brand-600 focus:ring-brand-500',
hoverClass = 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
readOnly = false,
}) => {
return (
<label
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer ${readOnly ? 'opacity-60 cursor-default' : hoverClass}`}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={readOnly}
className={`w-4 h-4 border-gray-300 dark:border-gray-600 rounded ${checkboxClass} disabled:opacity-50`}
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{definition.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{definition.description}
</div>
</div>
</label>
);
};
interface RolePermissionsEditorProps {
permissions: Record<string, boolean>;
onChange: (permissions: Record<string, boolean>) => void;
availablePermissions: {
menu: Record<string, PermissionDefinition>;
settings: Record<string, PermissionDefinition>;
dangerous: Record<string, PermissionDefinition>;
};
readOnly?: boolean;
columns?: 1 | 2;
}
/**
* Full role permissions editor with all three sections
*/
export const RolePermissionsEditor: React.FC<RolePermissionsEditorProps> = ({
permissions,
onChange,
availablePermissions,
readOnly = false,
columns = 2,
}) => {
const { t } = useTranslation();
const togglePermission = (key: string, value: boolean) => {
const updates: Record<string, boolean> = { [key]: value };
// If enabling any settings sub-permission, also enable the main settings access
if (value && key.startsWith('can_access_settings_')) {
updates['can_access_settings'] = true;
}
// If disabling the main settings access, disable all sub-permissions
if (!value && key === 'can_access_settings') {
Object.keys(availablePermissions.settings).forEach((settingKey) => {
if (settingKey !== 'can_access_settings') {
updates[settingKey] = false;
}
});
}
onChange({ ...permissions, ...updates });
};
const toggleAllInCategory = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
const categoryPerms = availablePermissions[category];
const updates: Record<string, boolean> = {};
Object.keys(categoryPerms).forEach((key) => {
updates[key] = enable;
});
// If enabling settings permissions, ensure main settings access is also enabled
if (category === 'settings' && enable) {
updates['can_access_settings'] = true;
}
onChange({ ...permissions, ...updates });
};
return (
<div className="space-y-6">
{/* Menu Permissions */}
<PermissionSection
title={t('settings.staffRoles.menuPermissions', 'Menu Access')}
description={t('settings.staffRoles.menuPermissionsDescription', 'Control which pages staff can see in the sidebar.')}
permissions={availablePermissions.menu}
values={permissions}
onChange={togglePermission}
onSelectAll={() => toggleAllInCategory('menu', true)}
onClearAll={() => toggleAllInCategory('menu', false)}
variant="default"
readOnly={readOnly}
columns={columns}
/>
{/* Settings Permissions */}
<PermissionSection
title={t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
description={t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
permissions={availablePermissions.settings}
values={permissions}
onChange={togglePermission}
onSelectAll={() => toggleAllInCategory('settings', true)}
onClearAll={() => toggleAllInCategory('settings', false)}
variant="settings"
readOnly={readOnly}
columns={columns}
/>
{/* Dangerous Permissions */}
<PermissionSection
title={t('settings.staffRoles.dangerousPermissions', 'Dangerous Operations')}
description={t('settings.staffRoles.dangerousPermissionsDescription', 'Allow staff to perform destructive or sensitive actions.')}
permissions={availablePermissions.dangerous}
values={permissions}
onChange={togglePermission}
onSelectAll={() => toggleAllInCategory('dangerous', true)}
onClearAll={() => toggleAllInCategory('dangerous', false)}
variant="dangerous"
readOnly={readOnly}
columns={columns}
/>
</div>
);
};
export default RolePermissionsEditor;

View File

@@ -53,7 +53,6 @@ export interface CreateInvitationData {
role: 'TENANT_STAFF';
create_bookable_resource?: boolean;
resource_name?: string;
permissions?: StaffPermissions;
staff_role_id?: number | null;
}

View File

@@ -93,3 +93,21 @@ export const useDeleteStaffRole = () => {
},
});
};
/**
* Hook to reorder staff roles
*/
export const useReorderStaffRoles = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (roleIds: number[]) => {
const response = await apiClient.post('/staff-roles/reorder/', { role_ids: roleIds });
return response.data as StaffRole[];
},
onSuccess: (data) => {
// Update the cache with the new order
queryClient.setQueryData(['staffRoles'], data);
},
});
};

View File

@@ -248,6 +248,17 @@
"retrieveCustomer": "Retrieve a customer",
"updateCustomer": "Update a customer",
"listCustomers": "List all customers",
"filtering": "Filtering & Sorting",
"filteringDescription": "Most list endpoints support filtering and sorting to help you retrieve exactly the data you need. Use query parameters to narrow your results.",
"comparisonOperators": "Comparison Operators",
"comparisonOperatorsDescription": "Append these suffixes to field names to filter by comparison:",
"servicesFilters": "Services Filters",
"bookingsFilters": "Bookings Filters",
"sorting": "Sorting",
"sortingDescription": "Use the ordering parameter to sort results. Prefix with a minus sign (-) for descending order.",
"filterServicesExample": "Filter Services Example",
"filterBookingsExample": "Filter Bookings Example",
"filteredResponse": "Filtered Response",
"webhooks": "Webhooks",
"webhookEvents": "Webhook events",
"webhookEventsDescription": "Webhooks allow you to receive real-time notifications when events occur in your business.",
@@ -3777,7 +3788,15 @@
"emailTemplates": "Email Templates",
"embedWidget": "Embed Widget",
"staffRoles": "Staff Roles",
"smsCalling": "SMS &amp; Calling"
"smsCalling": "SMS &amp; Calling",
"settingsSections": {
"business": "Business",
"branding": "Branding",
"integrations": "Integrations",
"access": "Access",
"communication": "Communication",
"billing": "Billing"
}
},
"introduction": {
"title": "Introduction",

View File

@@ -42,7 +42,7 @@ const LANGUAGES: Record<CodeLanguage, LanguageConfig> = {
// Default test credentials (used when no tokens are available)
const DEFAULT_TEST_API_KEY = 'ss_test_<your_test_token_here>';
const DEFAULT_TEST_WEBHOOK_SECRET = 'whsec_test_abc123def456ghi789jkl012mno345pqr678';
const SANDBOX_URL = 'https://sandbox.smoothschedule.com/v1';
const SANDBOX_URL = 'https://sandbox.smoothschedule.com/tenant-api/v1';
// Multi-language code interface
interface MultiLangCode {
@@ -385,6 +385,7 @@ const navSections: NavSection[] = [
{ titleKey: 'help.api.listCustomers', id: 'list-customers', method: 'GET' },
],
},
{ titleKey: 'help.api.filtering', id: 'filtering' },
{
titleKey: 'help.api.webhooks',
id: 'webhooks',
@@ -1926,6 +1927,139 @@ my $response = $ua->get('${SANDBOX_URL}/services/',
);`,
};
// Filtering example code
const filterServicesCode: MultiLangCode = {
curl: `# Filter services by price range (in cents)
curl "${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000" \\
-H "Authorization: Bearer ${TEST_API_KEY}"
# Search by name and sort by price descending
curl "${SANDBOX_URL}/services/?search=haircut&ordering=-price" \\
-H "Authorization: Bearer ${TEST_API_KEY}"`,
javascript: `// Filter services by price range
const params = new URLSearchParams({
price__gte: '2000', // >= $20.00
price__lte: '10000', // <= $100.00
ordering: '-price' // Sort by price descending
});
const response = await fetch(
'${SANDBOX_URL}/services/?' + params,
{ headers: { 'Authorization': 'Bearer ${TEST_API_KEY}' } }
);`,
python: `import requests
# Filter by price range (in cents) and sort
response = requests.get(
'${SANDBOX_URL}/services/',
headers={'Authorization': 'Bearer ${TEST_API_KEY}'},
params={
'price__gte': 2000, # >= $20.00
'price__lte': 10000, # <= $100.00
'ordering': '-price' # Sort descending
}
)`,
go: `// Build URL with query parameters
req, _ := http.NewRequest("GET", "${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000&ordering=-price", nil)
req.Header.Set("Authorization", "Bearer ${TEST_API_KEY}")
resp, _ := http.DefaultClient.Do(req)`,
java: `// Filter with query parameters
String url = "${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000&ordering=-price";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer ${TEST_API_KEY}")
.build();`,
csharp: `// Filter services with query parameters
var url = "${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000&ordering=-price";
var response = await client.GetAsync(url);`,
php: `$params = http_build_query([
'price__gte' => 2000,
'price__lte' => 10000,
'ordering' => '-price'
]);
$ch = curl_init('${SANDBOX_URL}/services/?' . $params);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ${TEST_API_KEY}'
]);`,
ruby: `uri = URI('${SANDBOX_URL}/services/')
uri.query = URI.encode_www_form({
price__gte: 2000,
price__lte: 10000,
ordering: '-price'
})
request = Net::HTTP::Get.new(uri)
request['Authorization'] = 'Bearer ${TEST_API_KEY}'`,
perl: `my $url = '${SANDBOX_URL}/services/?price__gte=2000&price__lte=10000&ordering=-price';
my $response = $ua->get($url,
'Authorization' => 'Bearer ${TEST_API_KEY}'
);`,
};
const filterBookingsCode: MultiLangCode = {
curl: `# Get bookings in a date range
curl "${SANDBOX_URL}/bookings/?start_time__gte=2024-01-01T00:00:00Z&start_time__lte=2024-01-31T23:59:59Z" \\
-H "Authorization: Bearer ${TEST_API_KEY}"
# Filter by status and sort newest first
curl "${SANDBOX_URL}/bookings/?status=CONFIRMED&ordering=-start_time" \\
-H "Authorization: Bearer ${TEST_API_KEY}"`,
javascript: `// Get bookings in January 2024
const params = new URLSearchParams({
start_time__gte: '2024-01-01T00:00:00Z',
start_time__lte: '2024-01-31T23:59:59Z',
status: 'CONFIRMED',
ordering: '-start_time' // Newest first
});
const response = await fetch(
'${SANDBOX_URL}/bookings/?' + params,
{ headers: { 'Authorization': 'Bearer ${TEST_API_KEY}' } }
);`,
python: `import requests
# Get bookings in date range
response = requests.get(
'${SANDBOX_URL}/bookings/',
headers={'Authorization': 'Bearer ${TEST_API_KEY}'},
params={
'start_time__gte': '2024-01-01T00:00:00Z',
'start_time__lte': '2024-01-31T23:59:59Z',
'status': 'CONFIRMED',
'ordering': '-start_time'
}
)`,
go: `req, _ := http.NewRequest("GET", "${SANDBOX_URL}/bookings/?start_time__gte=2024-01-01T00:00:00Z&ordering=-start_time", nil)
req.Header.Set("Authorization", "Bearer ${TEST_API_KEY}")
resp, _ := http.DefaultClient.Do(req)`,
java: `String url = "${SANDBOX_URL}/bookings/?" +
"start_time__gte=2024-01-01T00:00:00Z&" +
"start_time__lte=2024-01-31T23:59:59Z&" +
"ordering=-start_time";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer ${TEST_API_KEY}")
.build();`,
csharp: `var url = "${SANDBOX_URL}/bookings/?" +
"start_time__gte=2024-01-01T00:00:00Z&" +
"start_time__lte=2024-01-31T23:59:59Z&" +
"ordering=-start_time";
var response = await client.GetAsync(url);`,
php: `$params = http_build_query([
'start_time__gte' => '2024-01-01T00:00:00Z',
'start_time__lte' => '2024-01-31T23:59:59Z',
'ordering' => '-start_time'
]);
$ch = curl_init('${SANDBOX_URL}/bookings/?' . $params);`,
ruby: `uri = URI('${SANDBOX_URL}/bookings/')
uri.query = URI.encode_www_form({
start_time__gte: '2024-01-01T00:00:00Z',
start_time__lte: '2024-01-31T23:59:59Z',
ordering: '-start_time'
})`,
perl: `my $url = '${SANDBOX_URL}/bookings/?start_time__gte=2024-01-01T00:00:00Z&ordering=-start_time';
my $response = $ua->get($url, 'Authorization' => 'Bearer ${TEST_API_KEY}');`,
};
return (
<LanguageContext.Provider value={{ activeLanguage, setActiveLanguage }}>
<div className="min-h-screen bg-white dark:bg-gray-900">
@@ -1964,15 +2098,6 @@ my $response = $ua->get('${SANDBOX_URL}/services/',
</div>
)}
<a
href={`${API_BASE_URL}/v1/docs/`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg transition-colors"
>
<ExternalLink size={16} />
{t('help.api.interactiveExplorer')}
</a>
</div>
</div>
</header>
@@ -2038,7 +2163,7 @@ my $response = $ua->get('${SANDBOX_URL}/services/',
<CodeBlock
title={t('help.api.baseUrl')}
language="http"
code={`https://api.smoothschedule.com/v1/
code={`https://api.smoothschedule.com/tenant-api/v1/
# Sandbox/Test environment
${SANDBOX_URL}`}
@@ -2243,7 +2368,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/business/"
code="GET /tenant-api/v1/business/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getServicesCode} />
</ApiExample>
@@ -2315,7 +2440,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/services/"
code="GET /tenant-api/v1/services/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getServicesCode} />
<CodeBlock
@@ -2366,7 +2491,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/services/{id}/"
code="GET /tenant-api/v1/services/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getServiceCode} />
<CodeBlock
@@ -2461,7 +2586,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/resources/"
code="GET /tenant-api/v1/resources/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getResourcesCode} />
<CodeBlock
@@ -2518,7 +2643,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/resources/{id}/"
code="GET /tenant-api/v1/resources/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getResourceCode} />
<CodeBlock
@@ -2574,7 +2699,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/availability/"
code="GET /tenant-api/v1/availability/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getAvailabilityCode} />
<CodeBlock
@@ -2703,7 +2828,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="POST /v1/appointments/"
code="POST /tenant-api/v1/appointments/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={createAppointmentCode} />
<CodeBlock
@@ -2756,7 +2881,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/appointments/{id}/"
code="GET /tenant-api/v1/appointments/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getAppointmentCode} />
<CodeBlock
@@ -2818,7 +2943,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="PATCH /v1/appointments/{id}/"
code="PATCH /tenant-api/v1/appointments/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={updateAppointmentCode} />
<CodeBlock
@@ -2872,7 +2997,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="DELETE /v1/appointments/{id}/"
code="DELETE /tenant-api/v1/appointments/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={cancelAppointmentCode} />
<CodeBlock
@@ -2919,7 +3044,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/appointments/"
code="GET /tenant-api/v1/appointments/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getAppointmentsCode} />
<CodeBlock
@@ -3045,7 +3170,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="POST /v1/customers/"
code="POST /tenant-api/v1/customers/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={createCustomerCode} />
<CodeBlock
@@ -3087,7 +3212,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/customers/{id}/"
code="GET /tenant-api/v1/customers/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getCustomerCode} />
<CodeBlock
@@ -3139,7 +3264,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="PATCH /v1/customers/{id}/"
code="PATCH /tenant-api/v1/customers/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={updateCustomerCode} />
<CodeBlock
@@ -3190,7 +3315,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/customers/"
code="GET /tenant-api/v1/customers/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getCustomersCode} />
<CodeBlock
@@ -3220,6 +3345,118 @@ X-RateLimit-Burst-Remaining: 95`}
</ApiExample>
</ApiSection>
{/* Filtering & Sorting */}
<ApiSection id="filtering">
<ApiContent>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-0">
{t('help.api.filtering')}
</h2>
<p className="text-gray-600 dark:text-gray-300">
{t('help.api.filteringDescription')}
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('help.api.comparisonOperators')}
</h3>
<p className="text-gray-600 dark:text-gray-300">
{t('help.api.comparisonOperatorsDescription')}
</p>
<AttributeTable
attributes={[
{ name: '__lt', type: 'suffix', description: 'Less than the specified value.' },
{ name: '__lte', type: 'suffix', description: 'Less than or equal to the specified value.' },
{ name: '__gt', type: 'suffix', description: 'Greater than the specified value.' },
{ name: '__gte', type: 'suffix', description: 'Greater than or equal to the specified value.' },
]}
/>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('help.api.servicesFilters')}
</h3>
<AttributeTable
attributes={[
{ name: 'is_active', type: 'boolean', description: 'Filter by active/inactive status.' },
{ name: 'price__gte', type: 'integer', description: 'Minimum price in cents.' },
{ name: 'price__lte', type: 'integer', description: 'Maximum price in cents.' },
{ name: 'duration__gte', type: 'integer', description: 'Minimum duration in minutes.' },
{ name: 'duration__lte', type: 'integer', description: 'Maximum duration in minutes.' },
]}
/>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('help.api.bookingsFilters')}
</h3>
<AttributeTable
attributes={[
{ name: 'status', type: 'string', description: 'Filter by status: scheduled, completed, cancelled, no_show.' },
{ name: 'start_time__gte', type: 'datetime', description: 'Start time on or after (ISO 8601).' },
{ name: 'start_time__lte', type: 'datetime', description: 'Start time on or before (ISO 8601).' },
{ name: 'service_id', type: 'integer', description: 'Filter by specific service.' },
{ name: 'resource_id', type: 'integer', description: 'Filter by specific resource.' },
{ name: 'customer_id', type: 'integer', description: 'Filter by specific customer.' },
]}
/>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('help.api.sorting')}
</h3>
<p className="text-gray-600 dark:text-gray-300">
{t('help.api.sortingDescription')}
</p>
<AttributeTable
attributes={[
{ name: 'ordering', type: 'string', description: 'Field to sort by. Prefix with - for descending (e.g., -start_time).' },
]}
/>
</ApiContent>
<ApiExample>
<TabbedCodeBlock title={t('help.api.filterServicesExample')} codes={filterServicesCode} />
<CodeBlock
title={t('help.api.filteredResponse')}
language="json"
code={`[
{
"id": "svc_h8i9j0",
"name": "Premium Package",
"description": "Full spa treatment",
"duration": 90,
"price": 8500,
"is_active": true
},
{
"id": "svc_k1l2m3",
"name": "Deluxe Treatment",
"description": "Extended massage session",
"duration": 60,
"price": 6000,
"is_active": true
}
]`}
/>
<TabbedCodeBlock title={t('help.api.filterBookingsExample')} codes={filterBookingsCode} />
<CodeBlock
title={t('help.api.filteredResponse')}
language="json"
code={`[
{
"id": "apt_x1y2z3",
"service": { "id": "svc_123", "name": "Haircut" },
"customer": { "id": "cust_456", "name": "Jane Doe" },
"resource": { "id": "res_789", "name": "Sarah" },
"start_time": "2024-01-15T10:00:00Z",
"end_time": "2024-01-15T11:00:00Z",
"status": "scheduled"
},
{
"id": "apt_a4b5c6",
"service": { "id": "svc_123", "name": "Haircut" },
"customer": { "id": "cust_789", "name": "John Smith" },
"resource": { "id": "res_789", "name": "Sarah" },
"start_time": "2024-01-20T14:30:00Z",
"end_time": "2024-01-20T15:30:00Z",
"status": "completed"
}
]`}
/>
</ApiExample>
</ApiSection>
{/* Webhook Events */}
<ApiSection id="webhook-events">
<ApiContent>
@@ -3304,7 +3541,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="POST /v1/webhooks/"
code="POST /tenant-api/v1/webhooks/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={createWebhookCode} />
<CodeBlock
@@ -3353,7 +3590,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/webhooks/"
code="GET /tenant-api/v1/webhooks/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getWebhooksCode} />
<CodeBlock
@@ -3408,7 +3645,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="GET /v1/webhooks/{id}/"
code="GET /tenant-api/v1/webhooks/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={getWebhookCode} />
<CodeBlock
@@ -3460,7 +3697,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="PATCH /v1/webhooks/{id}/"
code="PATCH /tenant-api/v1/webhooks/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={updateWebhookCode} />
<CodeBlock
@@ -3504,7 +3741,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="DELETE /v1/webhooks/{id}/"
code="DELETE /tenant-api/v1/webhooks/{id}/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={deleteWebhookCode} />
<CodeBlock
@@ -3540,7 +3777,7 @@ X-RateLimit-Burst-Remaining: 95`}
<CodeBlock
title={t('help.api.endpoint')}
language="http"
code="POST /v1/webhooks/{id}/test/"
code="POST /tenant-api/v1/webhooks/{id}/test/"
/>
<TabbedCodeBlock title={t('help.api.request')} codes={testWebhookCode} />
<CodeBlock

View File

@@ -11,7 +11,8 @@ import {
StaffInvitation,
CreateInvitationData,
} from '../hooks/useInvitations';
import { useStaffRoles } from '../hooks/useStaffRoles';
import { useStaffRoles, useAvailablePermissions } from '../hooks/useStaffRoles';
import { RolePermissionsEditor } from '../components/staff/RolePermissions';
import {
Plus,
User as UserIcon,
@@ -37,7 +38,6 @@ import {
ArrowUpDown,
} from 'lucide-react';
import Portal from '../components/Portal';
import StaffPermissions from '../components/StaffPermissions';
interface StaffProps {
onMasquerade: (user: User) => void;
@@ -50,6 +50,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const { data: resources = [] } = useResources();
const { data: invitations = [], isLoading: invitationsLoading } = useInvitations();
const { data: staffRoles = [] } = useStaffRoles();
const { data: availablePermissions } = useAvailablePermissions();
const createResourceMutation = useCreateResource();
const createInvitationMutation = useCreateInvitation();
const cancelInvitationMutation = useCancelInvitation();
@@ -66,9 +67,9 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
const [createBookableResource, setCreateBookableResource] = useState(false);
const [resourceName, setResourceName] = useState('');
const [invitePermissions, setInvitePermissions] = useState<Record<string, boolean>>({});
const [inviteError, setInviteError] = useState('');
const [inviteSuccess, setInviteSuccess] = useState('');
const [showInvitePermissions, setShowInvitePermissions] = useState(false);
const [showInactiveStaff, setShowInactiveStaff] = useState(false);
// Edit modal state
@@ -97,6 +98,21 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
}));
};
// Get the selected role's permissions for the invite modal
const selectedInviteRole = useMemo(() => {
return staffRoles.find(r => r.id === inviteStaffRoleId);
}, [staffRoles, inviteStaffRoleId]);
// Format available permissions for the permission editor
const formattedAvailablePermissions = useMemo(() => {
if (!availablePermissions) return { menu: {}, settings: {}, dangerous: {} };
return {
menu: availablePermissions.menu_permissions || {},
settings: availablePermissions.settings_permissions || {},
dangerous: availablePermissions.dangerous_permissions || {},
};
}, [availablePermissions]);
// Separate active and inactive staff, then sort
const activeStaff = useMemo(() => {
const active = staffMembers.filter((s) => s.is_active);
@@ -156,8 +172,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
role: inviteRole,
create_bookable_resource: createBookableResource,
resource_name: resourceName.trim(),
permissions: invitePermissions,
staff_role_id: inviteRole === 'TENANT_STAFF' ? inviteStaffRoleId : null,
staff_role_id: inviteStaffRoleId,
};
await createInvitationMutation.mutateAsync(invitationData);
@@ -165,7 +180,6 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setInviteEmail('');
setCreateBookableResource(false);
setResourceName('');
setInvitePermissions({});
setInviteStaffRoleId(null);
// Close modal after short delay
setTimeout(() => {
@@ -201,9 +215,9 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setInviteStaffRoleId(null);
setCreateBookableResource(false);
setResourceName('');
setInvitePermissions({});
setInviteError('');
setInviteSuccess('');
setShowInvitePermissions(false);
setIsInviteModalOpen(true);
};
@@ -657,16 +671,39 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('staff.staffRoleSelectHint')}
</p>
{/* Show/Hide Permissions Button */}
{selectedInviteRole && (
<button
type="button"
onClick={() => setShowInvitePermissions(!showInvitePermissions)}
className="mt-2 text-xs text-brand-600 dark:text-brand-400 hover:underline flex items-center gap-1"
>
{showInvitePermissions ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{t('staff.viewPermissions', 'View role permissions')}
</button>
)}
</div>
)}
{/* Permissions - Using shared component */}
<StaffPermissions
role="staff"
permissions={invitePermissions}
onChange={setInvitePermissions}
variant="invite"
/>
{/* Role Permissions (Read-only) */}
{selectedInviteRole && showInvitePermissions && availablePermissions && (
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Shield size={16} className="text-brand-600" />
{t('staff.rolePermissionsTitle', '{{role}} Permissions', { role: selectedInviteRole.name })}
</h4>
</div>
<RolePermissionsEditor
permissions={selectedInviteRole.permissions || {}}
onChange={() => {}}
availablePermissions={formattedAvailablePermissions}
readOnly
columns={1}
/>
</div>
)}
{/* Make Bookable Option */}
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">

View File

@@ -82,7 +82,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 text-xs font-semibold rounded">GET</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Retrieve a list of appointments with optional filtering.</p>
@@ -90,13 +90,18 @@ const HelpApiAppointments: React.FC = () => {
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">start_date</code> - Filter by start date (YYYY-MM-DD)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">end_date</code> - Filter by end date (YYYY-MM-DD)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">status</code> - Filter by status (scheduled, confirmed, completed, etc.)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">customer_id</code> - Filter by customer UUID</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">start_datetime</code> - Filter by start datetime (ISO 8601, e.g., 2025-12-01T09:00:00Z)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">end_datetime</code> - Filter by end datetime (ISO 8601)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">status</code> - Filter by status (SCHEDULED, CONFIRMED, COMPLETED, CANCELLED)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">customer_id</code> - Filter by customer ID</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">service_id</code> - Filter by service ID</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">resource_id</code> - Filter by resource ID</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">ordering</code> - Sort order: <code>start_time</code>, <code>-start_time</code> (default, newest first), <code>created_at</code>, <code>-created_at</code></li>
</ul>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X GET "https://api.smoothschedule.com/api/v1/appointments/?start_date=2025-12-01" \\
{`curl -X GET "https://api.smoothschedule.com/tenant-api/v1/bookings/?start_date=2025-12-01" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}
</pre>
</div>
@@ -106,13 +111,13 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 text-xs font-semibold rounded">GET</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/&#123;id&#125;/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Retrieve a single appointment by ID.</p>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X GET "https://api.smoothschedule.com/api/v1/appointments/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
{`curl -X GET "https://api.smoothschedule.com/tenant-api/v1/bookings/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}
</pre>
</div>
@@ -122,7 +127,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 text-xs font-semibold rounded">POST</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Create a new appointment.</p>
@@ -144,7 +149,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X POST "https://api.smoothschedule.com/api/v1/appointments/" \\
{`curl -X POST "https://api.smoothschedule.com/tenant-api/v1/bookings/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
@@ -162,7 +167,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-semibold rounded">PATCH</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/&#123;id&#125;/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Update an existing appointment.</p>
@@ -179,7 +184,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X PATCH "https://api.smoothschedule.com/api/v1/appointments/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
{`curl -X PATCH "https://api.smoothschedule.com/tenant-api/v1/bookings/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
@@ -194,7 +199,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 text-xs font-semibold rounded">DELETE</span>
<code className="text-sm text-gray-900 dark:text-white font-mono">/api/v1/appointments/&#123;id&#125;/</code>
<code className="text-sm text-gray-900 dark:text-white font-mono">/tenant-api/v1/bookings/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">Cancel an appointment. Optionally provide a cancellation reason.</p>
@@ -209,7 +214,7 @@ const HelpApiAppointments: React.FC = () => {
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X DELETE "https://api.smoothschedule.com/api/v1/appointments/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
{`curl -X DELETE "https://api.smoothschedule.com/tenant-api/v1/bookings/a1b2c3d4-5678-90ab-cdef-1234567890ab/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"reason": "Customer requested cancellation"}'`}

View File

@@ -82,7 +82,7 @@ const HelpApiCustomers: React.FC = () => {
List Customers
</h3>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4">
<code className="text-green-600 dark:text-green-400 font-mono">GET /api/v1/customers/</code>
<code className="text-green-600 dark:text-green-400 font-mono">GET /tenant-api/v1/customers/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Retrieve a paginated list of customers. Results are limited to 100 customers per request.
@@ -92,6 +92,7 @@ const HelpApiCustomers: React.FC = () => {
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li><code className="bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">email</code> - Filter by exact email address</li>
<li><code className="bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">search</code> - Search by name or email (partial match)</li>
<li><code className="bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">ordering</code> - Sort order: last_name, -last_name, first_name, -first_name, email, -email, date_joined, -date_joined</li>
</ul>
</div>
</div>
@@ -102,7 +103,7 @@ const HelpApiCustomers: React.FC = () => {
Get Customer
</h3>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4">
<code className="text-green-600 dark:text-green-400 font-mono">GET /api/v1/customers/&#123;id&#125;/</code>
<code className="text-green-600 dark:text-green-400 font-mono">GET /tenant-api/v1/customers/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300">
Retrieve a specific customer by their UUID.
@@ -115,7 +116,7 @@ const HelpApiCustomers: React.FC = () => {
Create Customer
</h3>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4">
<code className="text-orange-600 dark:text-orange-400 font-mono">POST /api/v1/customers/</code>
<code className="text-orange-600 dark:text-orange-400 font-mono">POST /tenant-api/v1/customers/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Create a new customer record.
@@ -141,7 +142,7 @@ const HelpApiCustomers: React.FC = () => {
Update Customer
</h3>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4">
<code className="text-blue-600 dark:text-blue-400 font-mono">PATCH /api/v1/customers/&#123;id&#125;/</code>
<code className="text-blue-600 dark:text-blue-400 font-mono">PATCH /tenant-api/v1/customers/&#123;id&#125;/</code>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Update an existing customer's information.
@@ -304,7 +305,7 @@ const HelpApiCustomers: React.FC = () => {
</h3>
<pre className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-gray-800 dark:text-gray-200">
{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/customers/" \\
{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/customers/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json"`}
</code>
@@ -318,7 +319,7 @@ const HelpApiCustomers: React.FC = () => {
</h3>
<pre className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-gray-800 dark:text-gray-200">
{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/customers/?search=jane" \\
{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/customers/?search=jane" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json"`}
</code>
@@ -332,7 +333,7 @@ const HelpApiCustomers: React.FC = () => {
</h3>
<pre className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-gray-800 dark:text-gray-200">
{`curl -X POST "https://yourbusiness.smoothschedule.com/api/v1/customers/" \\
{`curl -X POST "https://yourbusiness.smoothschedule.com/tenant-api/v1/customers/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
@@ -351,7 +352,7 @@ const HelpApiCustomers: React.FC = () => {
</h3>
<pre className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 overflow-x-auto">
<code className="text-sm text-gray-800 dark:text-gray-200">
{`curl -X PATCH "https://yourbusiness.smoothschedule.com/api/v1/customers/123e4567-e89b-12d3-a456-426614174000/" \\
{`curl -X PATCH "https://yourbusiness.smoothschedule.com/tenant-api/v1/customers/123e4567-e89b-12d3-a456-426614174000/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{

View File

@@ -71,7 +71,7 @@ const HelpApiOverview: React.FC = () => {
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Base URL</h4>
<code className="text-sm text-brand-600 dark:text-brand-400">
https://your-subdomain.smoothschedule.com/api/v1/
https://your-subdomain.smoothschedule.com/tenant-api/v1/
</code>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
@@ -79,15 +79,8 @@ const HelpApiOverview: React.FC = () => {
<Book size={18} className="text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<p className="text-sm text-blue-800 dark:text-blue-300">
<strong>Interactive Documentation:</strong> Explore and test API endpoints at{' '}
<a
href="/api/v1/docs/"
target="_blank"
rel="noopener noreferrer"
className="bg-blue-100 dark:bg-blue-900/40 px-1 rounded font-mono hover:underline"
>
/api/v1/docs/
</a>
<strong>Tenant Remote API:</strong> This API is designed for third-party integrations.
All requests require Bearer token authentication with appropriate scopes.
</p>
</div>
</div>
@@ -130,7 +123,7 @@ const HelpApiOverview: React.FC = () => {
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Example Request</h4>
<div className="bg-gray-900 dark:bg-black rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`curl -X GET "https://demo.smoothschedule.com/api/v1/services/" \\
{`curl -X GET "https://demo.smoothschedule.com/tenant-api/v1/services/" \\
-H "Authorization: Bearer ss_live_xxxxxxxxx" \\
-H "Content-Type: application/json"`}
</pre>
@@ -230,6 +223,216 @@ const HelpApiOverview: React.FC = () => {
</div>
</section>
{/* Filtering & Sorting Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Code size={20} className="text-brand-500" />
Filtering & Sorting
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
All list endpoints support filtering and sorting via query parameters.
</p>
{/* Comparison Operators */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Comparison Operators</h4>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
For numeric fields, append these suffixes to filter by comparison:
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Suffix</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Meaning</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Example</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">(none)</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Equals</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price=5000</code></td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">__lt</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Less than</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__lt=5000</code></td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">__lte</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Less than or equal</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__lte=5000</code></td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">__gt</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Greater than</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__gt=5000</code></td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">__gte</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Greater than or equal</td>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__gte=5000</code></td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Sorting */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Sorting</h4>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Use the <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">ordering</code> parameter to sort results.
Prefix with <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">-</code> for descending order.
</p>
<div className="bg-gray-900 dark:bg-black rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`# Ascending (oldest first)
GET /tenant-api/v1/bookings/?ordering=start_time
# Descending (newest first)
GET /tenant-api/v1/bookings/?ordering=-start_time`}
</pre>
</div>
</div>
{/* Services Filters */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Services Endpoint Filters</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Parameter</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Type</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Description</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">search</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Search by name (partial match)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">name</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Exact name match</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Price in cents (supports __lt, __lte, __gt, __gte)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Duration in minutes (supports __lt, __lte, __gt, __gte)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">variable_pricing</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">boolean</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by variable pricing (true/false)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">requires_manual_scheduling</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">boolean</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by manual scheduling requirement</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">ordering</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">name, price, duration, display_order (prefix - for desc)</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Bookings Filters */}
<div className="mb-6">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Bookings Endpoint Filters</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Parameter</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Type</th>
<th className="text-left py-2 px-3 font-medium text-gray-900 dark:text-white">Description</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">start_date</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">date</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter from date (YYYY-MM-DD)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">end_date</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">date</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter to date (YYYY-MM-DD)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">start_datetime</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">datetime</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter from datetime (ISO 8601)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">end_datetime</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">datetime</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter to datetime (ISO 8601)</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">status</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">SCHEDULED, CONFIRMED, COMPLETED, CANCELLED</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">service_id</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by service</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">customer_id</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by customer</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">resource_id</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">integer</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">Filter by resource</td>
</tr>
<tr>
<td className="py-2 px-3"><code className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">ordering</code></td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">string</td>
<td className="py-2 px-3 text-gray-600 dark:text-gray-300">start_time, end_time, created_at, updated_at</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Example */}
<div>
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Examples</h4>
<div className="bg-gray-900 dark:bg-black rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100 font-mono">
{`# Services under $50 with duration over 30 minutes
GET /tenant-api/v1/services/?price__lt=5000&duration__gt=30&ordering=price
# Bookings between two dates, newest first
GET /tenant-api/v1/bookings/?start_date=2025-01-01&end_date=2025-01-31&ordering=-start_time
# Customers search with sorting by last name
GET /tenant-api/v1/customers/?search=john&ordering=last_name`}
</pre>
</div>
</div>
</div>
</section>
{/* Rate Limiting Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">

View File

@@ -92,7 +92,7 @@ const HelpApiResources: React.FC = () => {
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mb-4 font-mono text-sm">
<span className="text-green-600 dark:text-green-400 font-semibold">GET</span>{' '}
<span className="text-gray-900 dark:text-gray-100">/api/v1/resources/</span>
<span className="text-gray-900 dark:text-gray-100">/tenant-api/v1/resources/</span>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-3">
Returns a list of all active resources in your account.
@@ -114,6 +114,16 @@ const HelpApiResources: React.FC = () => {
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">string</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Filter by resource type (STAFF, ROOM, EQUIPMENT)</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100">search</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">string</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Search by resource name</td>
</tr>
<tr>
<td className="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-100">ordering</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">string</td>
<td className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Sort order: name, -name, type, -type (prefix with - for descending)</td>
</tr>
</tbody>
</table>
</div>
@@ -128,7 +138,7 @@ const HelpApiResources: React.FC = () => {
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mb-4 font-mono text-sm">
<span className="text-green-600 dark:text-green-400 font-semibold">GET</span>{' '}
<span className="text-gray-900 dark:text-gray-100">/api/v1/resources/</span>
<span className="text-gray-900 dark:text-gray-100">/tenant-api/v1/resources/</span>
<span className="text-blue-600 dark:text-blue-400">{'{id}'}</span>
<span className="text-gray-900 dark:text-gray-100">/</span>
</div>
@@ -261,7 +271,7 @@ const HelpApiResources: React.FC = () => {
List All Resources
</h3>
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm">
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/resources/" \\
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/resources/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}</code>
</pre>
</div>
@@ -272,7 +282,7 @@ const HelpApiResources: React.FC = () => {
Filter by Type
</h3>
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm">
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/resources/?type=STAFF" \\
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/resources/?type=STAFF" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}</code>
</pre>
</div>
@@ -283,7 +293,7 @@ const HelpApiResources: React.FC = () => {
Get Specific Resource
</h3>
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm">
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/resources/550e8400-e29b-41d4-a716-446655440000/" \\
<code className="text-gray-900 dark:text-gray-100">{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/resources/550e8400-e29b-41d4-a716-446655440000/" \\
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"`}</code>
</pre>
</div>

View File

@@ -88,12 +88,51 @@ const HelpApiServices: React.FC = () => {
GET
</span>
<code className="text-sm text-gray-900 dark:text-gray-100 font-mono">
/api/v1/services/
/tenant-api/v1/services/
</code>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
Returns all active services ordered by <code className="text-xs px-1 bg-gray-100 dark:bg-gray-700 rounded">display_order</code>.
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">
Returns all active services with optional filtering and sorting.
</p>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-2">Text Filters:</h4>
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">search</code> - Search by service name (partial match)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">name</code> - Filter by exact name (case-insensitive)</li>
</ul>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-2">Numeric Filters (with comparison operators):</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Use <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">__lt</code> (less than),
<code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">__lte</code> (less than or equal),
<code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">__gt</code> (greater than),
<code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">__gte</code> (greater than or equal) suffixes.
</p>
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price</code> - Exact price in cents (e.g., <code>price=1000</code> for $10.00)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__lt</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__lte</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__gt</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">price__gte</code> - Price comparisons</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration</code> - Exact duration in minutes</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration__lt</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration__lte</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration__gt</code>, <code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">duration__gte</code> - Duration comparisons</li>
</ul>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-2">Boolean Filters:</h4>
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">variable_pricing</code> - Filter by variable pricing (<code>true</code>/<code>false</code>)</li>
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">requires_manual_scheduling</code> - Filter by manual scheduling requirement</li>
</ul>
<h4 className="font-medium text-gray-900 dark:text-white text-sm mb-2">Sorting:</h4>
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 mb-4">
<li><code className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">ordering</code> - Sort field (prefix with <code>-</code> for descending): <code>name</code>, <code>price</code>, <code>duration</code>, <code>display_order</code></li>
</ul>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<p className="text-xs text-gray-400 mb-2"># Get services under $50 with duration over 30 minutes, sorted by price</p>
<pre className="text-sm text-gray-100 font-mono">
{`curl "https://demo.smoothschedule.com/tenant-api/v1/services/?price__lt=5000&duration__gt=30&ordering=price" \\
-H "Authorization: Bearer ss_live_xxxxxxxxx"`}
</pre>
</div>
</div>
{/* Get Service */}
@@ -103,7 +142,7 @@ const HelpApiServices: React.FC = () => {
GET
</span>
<code className="text-sm text-gray-900 dark:text-gray-100 font-mono">
/api/v1/services/{'{id}'}/
/tenant-api/v1/services/{'{id}'}/
</code>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
@@ -191,10 +230,10 @@ const HelpApiServices: React.FC = () => {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
decimal | null
integer
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Price in dollars (null for variable pricing)
Price in cents (e.g., 4500 = $45.00)
</td>
</tr>
<tr>
@@ -222,6 +261,39 @@ const HelpApiServices: React.FC = () => {
Whether the service is currently active
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-sm font-mono text-purple-600 dark:text-purple-400">variable_pricing</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
boolean
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
If true, final price is determined after service completion
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-sm font-mono text-purple-600 dark:text-purple-400">requires_manual_scheduling</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
boolean
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
If true, bookings go to Pending Requests instead of auto-scheduling
</td>
</tr>
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-sm font-mono text-purple-600 dark:text-purple-400">display_order</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
integer
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
Order in which services appear in menus (lower = first)
</td>
</tr>
</tbody>
</table>
</div>
@@ -237,17 +309,23 @@ const HelpApiServices: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<pre className="bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono">
{`{
"id": "550e8400-e29b-41d4-a716-446655440000",
"id": 1,
"name": "Haircut",
"description": "Professional haircut service",
"duration": 30,
"price": "45.00",
"price": 4500,
"photos": [
"https://smoothschedule.nyc3.digitaloceanspaces.com/..."
],
"is_active": true
"is_active": true,
"variable_pricing": false,
"requires_manual_scheduling": false,
"display_order": 0
}`}
</pre>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Note: <code>price</code> is in cents (4500 = $45.00)
</p>
</div>
</section>
@@ -264,7 +342,7 @@ const HelpApiServices: React.FC = () => {
List All Services
</h3>
<pre className="bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono">
{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/services/" \\
{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/services/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json"`}
</pre>
@@ -276,7 +354,7 @@ const HelpApiServices: React.FC = () => {
Get Specific Service
</h3>
<pre className="bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono">
{`curl -X GET "https://yourbusiness.smoothschedule.com/api/v1/services/550e8400-e29b-41d4-a716-446655440000/" \\
{`curl -X GET "https://yourbusiness.smoothschedule.com/tenant-api/v1/services/550e8400-e29b-41d4-a716-446655440000/" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json"`}
</pre>

View File

@@ -98,7 +98,7 @@ const HelpApiWebhooks: React.FC = () => {
<List size={18} className="text-blue-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">List Subscriptions</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /api/v1/webhooks/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /tenant-api/v1/webhooks/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Returns a list of all webhook subscriptions for your account.
</p>
@@ -112,7 +112,7 @@ const HelpApiWebhooks: React.FC = () => {
<Plus size={18} className="text-green-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Create Subscription</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">POST /api/v1/webhooks/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">POST /tenant-api/v1/webhooks/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2 mb-3">
Create a new webhook subscription. Returns the subscription including a <strong>secret</strong> for signature verification.
</p>
@@ -141,7 +141,7 @@ const HelpApiWebhooks: React.FC = () => {
<LinkIcon size={18} className="text-purple-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Get Subscription</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /api/v1/webhooks/{`{id}`}/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /tenant-api/v1/webhooks/{`{id}`}/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Retrieve details of a specific webhook subscription.
</p>
@@ -155,7 +155,7 @@ const HelpApiWebhooks: React.FC = () => {
<Edit size={18} className="text-orange-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Update Subscription</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">PATCH /api/v1/webhooks/{`{id}`}/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">PATCH /tenant-api/v1/webhooks/{`{id}`}/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Update an existing webhook subscription (URL, events, or description).
</p>
@@ -169,7 +169,7 @@ const HelpApiWebhooks: React.FC = () => {
<Trash2 size={18} className="text-red-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Delete Subscription</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">DELETE /api/v1/webhooks/{`{id}`}/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">DELETE /tenant-api/v1/webhooks/{`{id}`}/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Delete a webhook subscription permanently.
</p>
@@ -183,7 +183,7 @@ const HelpApiWebhooks: React.FC = () => {
<Bell size={18} className="text-blue-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">List Event Types</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /api/v1/webhooks/events/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /tenant-api/v1/webhooks/events/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Get a list of all available webhook event types.
</p>
@@ -197,7 +197,7 @@ const HelpApiWebhooks: React.FC = () => {
<Send size={18} className="text-green-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">Send Test Webhook</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">POST /api/v1/webhooks/{`{id}`}/test/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">POST /tenant-api/v1/webhooks/{`{id}`}/test/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Send a test webhook to verify your endpoint is working correctly.
</p>
@@ -211,7 +211,7 @@ const HelpApiWebhooks: React.FC = () => {
<Clock size={18} className="text-purple-500 mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">View Delivery History</h3>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /api/v1/webhooks/{`{id}`}/deliveries/</code>
<code className="text-sm text-gray-600 dark:text-gray-400">GET /tenant-api/v1/webhooks/{`{id}`}/deliveries/</code>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
View delivery history and status for a webhook subscription.
</p>
@@ -425,7 +425,7 @@ function verifyWebhook(request, secret) {
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<pre className="text-xs overflow-x-auto text-gray-700 dark:text-gray-300">
{`# Using curl
curl -X POST https://api.smoothschedule.com/api/v1/webhooks/ \\
curl -X POST https://api.smoothschedule.com/tenant-api/v1/webhooks/ \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import {
ArrowLeft, FileSignature, FileText, Send, CheckCircle, ChevronRight,
HelpCircle, Shield, Clock, Users, AlertCircle, Copy, Eye, Download,
Plus, Pencil, Trash2, ChevronDown, Ban, ExternalLink,
Plus, Pencil, Trash2, ChevronDown, Ban, ExternalLink, Mail,
LayoutGrid,
} from 'lucide-react';
@@ -50,6 +50,112 @@ const HelpContracts: React.FC = () => {
</div>
</section>
{/* Contract Lifecycle Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Clock size={20} className="text-brand-500" /> Contract Lifecycle
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Contracts flow through a three-stage lifecycle from creation to completion:
</p>
{/* Visual Flow */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex-1 text-center p-4 bg-blue-100 dark:bg-blue-900/30 rounded-lg border-2 border-blue-300 dark:border-blue-700">
<FileText size={28} className="mx-auto mb-2 text-blue-600 dark:text-blue-400" />
<div className="font-semibold text-gray-900 dark:text-white">Template</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Reusable document</div>
</div>
<ChevronRight size={24} className="hidden md:block text-gray-400" />
<div className="w-full md:w-auto text-center text-gray-400 md:hidden"></div>
<div className="flex-1 text-center p-4 bg-yellow-100 dark:bg-yellow-900/30 rounded-lg border-2 border-yellow-300 dark:border-yellow-700">
<Send size={28} className="mx-auto mb-2 text-yellow-600 dark:text-yellow-400" />
<div className="font-semibold text-gray-900 dark:text-white">Contract</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Sent to customer</div>
</div>
<ChevronRight size={24} className="hidden md:block text-gray-400" />
<div className="w-full md:w-auto text-center text-gray-400 md:hidden"></div>
<div className="flex-1 text-center p-4 bg-green-100 dark:bg-green-900/30 rounded-lg border-2 border-green-300 dark:border-green-700">
<CheckCircle size={28} className="mx-auto mb-2 text-green-600 dark:text-green-400" />
<div className="font-semibold text-gray-900 dark:text-white">Signature</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Legally binding</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold shrink-0">1</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Create Template</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Design reusable contract templates with placeholders like {"{{CUSTOMER_NAME}}"} that get filled automatically. Templates can be versioned and updated without affecting already-sent contracts.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div className="w-8 h-8 rounded-full bg-yellow-500 text-white flex items-center justify-center text-sm font-bold shrink-0">2</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Send Contract</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
When you send a contract, the system creates a <strong>snapshot</strong> of the template content with all variables filled in. This frozen copy is what the customer signseven if you update the template later, their contract remains unchanged.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="w-8 h-8 rounded-full bg-green-500 text-white flex items-center justify-center text-sm font-bold shrink-0">3</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Customer Signs</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Customer receives an email with a unique signing link. They review the contract, check consent boxes, and click "Sign". The system captures a complete audit trail including timestamp, IP address, device info, and optional geolocation.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Content Snapshotting Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> Content Snapshotting & Integrity
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
For legal validity, the contract content is frozen at the moment of sending:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<FileText size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Immutable Content</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When a contract is sent, the rendered HTML (with all variables replaced) is saved as a permanent snapshot. This is what the customer sees and signsit cannot be changed after sending.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Shield size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">SHA-256 Hash</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
A cryptographic hash of the content is computed when the contract is created and again when signed. If these hashes match, it proves the document wasn't altered between sending and signing.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Copy size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Template Version Tracking</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Each contract records which version of the template was used. You can update templates freely—existing contracts keep their original content, and new contracts use the latest version.
</p>
</div>
</div>
</div>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<LayoutGrid size={20} className="text-brand-500" /> {t('help.contracts.pageLayout.title', 'Page Layout')}
@@ -339,6 +445,162 @@ const HelpContracts: React.FC = () => {
</div>
</section>
{/* Customer Signing Experience Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<ExternalLink size={20} className="text-brand-500" /> Customer Signing Experience
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
When you send a contract, the customer receives a professional signing experience:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Mail size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Email Notification</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Customer receives an email with your business name, the contract title, and a prominent "Sign Contract" button. The email includes a unique, secure signing link.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<Eye size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Public Signing Page</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
The signing link opens a clean, branded page showing the full contract content. The customer can scroll through and read the entire document before signing.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<CheckCircle size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Consent & Signature</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
At the bottom, the customer must check two consent boxes: one agreeing to the contract terms, and one consenting to electronic signatures (ESIGN Act requirement). Then they enter their name and click "Sign Contract".
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<Download size={20} className="text-amber-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Confirmation & PDF</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
After signing, the customer sees a success message and can download a PDF copy of the signed contract. They also receive a confirmation email with the PDF attached.
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300">
<strong>Security:</strong> Each signing link is a unique, 64-character token that expires after use or when the contract expires. Links cannot be guessed or reused.
</p>
</div>
</div>
</section>
{/* Service Contract Requirements Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<FileText size={20} className="text-brand-500" /> Requiring Contracts for Services
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
You can configure services to automatically require customers to sign specific contracts before or after booking:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<FileSignature size={20} className="text-indigo-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Per-Service Requirements</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
In <strong>Settings → Services</strong>, edit any service and scroll to the "Contracts" section. Add templates that customers must sign when booking this service.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Users size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Customer-Level vs Per-Appointment</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
<strong>Customer-Level:</strong> Sign once and it applies to all future bookings (e.g., terms of service)<br />
<strong>Per-Appointment:</strong> Sign for each booking (e.g., liability waivers specific to each session)
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<AlertCircle size={20} className="text-amber-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Required vs Optional</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Mark contracts as <strong>Required</strong> to block booking until signed, or <strong>Optional</strong> to send but allow booking regardless of signature status.
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-blue-500 mt-0.5 shrink-0" />
<p className="text-sm text-blue-700 dark:text-blue-300">
<strong>Tip:</strong> Use customer-level contracts for general agreements (privacy policy, general terms) and per-appointment contracts for service-specific waivers or acknowledgments.
</p>
</div>
</div>
</div>
</section>
{/* What Happens After Signing Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<CheckCircle size={20} className="text-brand-500" /> What Happens After Signing
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Once a customer signs, several things happen automatically:
</p>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>Status Updated:</strong> Contract status changes from "Pending" to "Signed"</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>Audit Trail Created:</strong> Full signature record with timestamp, IP, device, and geolocation</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>PDF Generated:</strong> Legally compliant PDF with contract content, signature, and audit trail</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>Customer Email:</strong> Confirmation email sent with PDF attachment</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-xs font-bold">✓</div>
<span className="text-gray-700 dark:text-gray-300"><strong>Business Notification:</strong> You receive an email confirming the signature</span>
</div>
</div>
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Download Options</h4>
<p className="text-sm text-gray-600 dark:text-gray-300">
For signed contracts, you can download a complete evidence package containing the signed PDF, an official audit certificate, and a machine-readable JSON signature record for archival purposes.
</p>
</div>
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> {t('help.contracts.legalCompliance.title', 'Legal Compliance')}

View File

@@ -26,6 +26,11 @@ import {
Eye,
AlertCircle,
MoreHorizontal,
Globe,
Send,
Key,
FileSignature,
Link as LinkIcon,
} from 'lucide-react';
const HelpCustomers: React.FC = () => {
@@ -178,6 +183,52 @@ const HelpCustomers: React.FC = () => {
</div>
</section>
{/* How Customers Are Created Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Users size={20} className="text-brand-500" /> How Customers Are Created
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Customer records can be created in three ways, each serving different business scenarios:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<Globe size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Online Booking</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When customers book through your public booking page, they enter their contact information and a customer record is automatically created. If the customer already exists (matched by email), the booking is linked to their existing record.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<UserPlus size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Manual Creation</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use the "Add Customer" button to manually create customers. This is ideal for walk-in clients, phone bookings, or importing existing customers from another system.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<Calendar size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">During Appointment Scheduling</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When scheduling an appointment in the dashboard, you can create a new customer inline by entering their details. This streamlines the booking process without leaving the scheduler.
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300">
<strong>Customer Record vs. Account:</strong> A customer record stores contact information and booking history. A customer account allows the customer to log in and manage their own appointments. Customers can exist as records without having login access.
</p>
</div>
</div>
</section>
{/* Adding a Customer Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
@@ -280,6 +331,143 @@ const HelpCustomers: React.FC = () => {
</div>
</section>
{/* Customer Onboarding Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Send size={20} className="text-brand-500" /> Customer Onboarding
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Customer onboarding is the process of bringing new customers into your system and enabling them to self-service their appointments. There are several pathways depending on how the customer relationship begins.
</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Self-Registration (Online Booking)</h3>
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
When customers book through your public booking page, they go through a streamlined registration process:
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-sm font-semibold text-brand-600 dark:text-brand-400">1</div>
<span className="text-sm text-gray-600 dark:text-gray-300">Customer selects a service and time slot</span>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-sm font-semibold text-brand-600 dark:text-brand-400">2</div>
<span className="text-sm text-gray-600 dark:text-gray-300">Customer enters contact information (name, email, phone)</span>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-sm font-semibold text-brand-600 dark:text-brand-400">3</div>
<span className="text-sm text-gray-600 dark:text-gray-300">Customer creates a password (optional, for account access)</span>
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center text-sm font-semibold text-brand-600 dark:text-brand-400">4</div>
<span className="text-sm text-gray-600 dark:text-gray-300">Booking is confirmed and customer record is created</span>
</div>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Manual Customer Creation</h3>
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
For customers you add manually (phone bookings, walk-ins, imports), you control their onboarding:
</p>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600">
<Users size={18} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Record Only</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Customer exists in your database but cannot log in. You manage all bookings for them.</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600">
<Send size={18} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Invite to Create Account</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Send an invitation email so the customer can set a password and manage their own appointments.</p>
</div>
</div>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">What Customers Can Do With an Account</h3>
<ul className="space-y-2 mb-4">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>View Appointments:</strong> See past and upcoming bookings in one place</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>Book Online:</strong> Schedule new appointments without entering contact info each time</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>Cancel/Reschedule:</strong> Modify appointments within your cancellation policy</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>Sign Contracts:</strong> Review and sign required contracts digitally</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 shrink-0" />
<span className="text-gray-600 dark:text-gray-300"><strong>Update Profile:</strong> Keep contact information current</span>
</li>
</ul>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-blue-600 dark:text-blue-400 mt-0.5 shrink-0" />
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Tip:</strong> Customers who create accounts reduce your administrative workload by managing their own bookings. Encourage account creation by sending invitation emails to manually-added customers.
</p>
</div>
</div>
</div>
</section>
{/* Contract Requirements Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<FileSignature size={20} className="text-brand-500" /> Customer Contracts
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
For services that require contracts (waivers, agreements, terms of service), the customer onboarding process includes contract signing.
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<FileSignature size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Automatic Contract Requests</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When a customer books a service with required contracts, they receive an email with a link to review and sign the documents before their appointment.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Key size={20} className="text-orange-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Scope Options</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Contracts can be configured to apply once per customer (sign once, valid for all future appointments) or per appointment (must sign before each visit).
</p>
</div>
</div>
</div>
<div className="mt-4">
<Link
to="/dashboard/help/contracts"
className="inline-flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400 text-sm font-medium"
>
<LinkIcon size={14} />
Learn more about contracts
<ChevronRight size={14} />
</Link>
</div>
</div>
</section>
{/* Tags Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">

View File

@@ -188,23 +188,56 @@ const HelpSettingsAppearance: React.FC = () => {
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Choose from 10 professionally designed color palettes. Each palette includes a primary
and secondary color that work well together. Click any palette to instantly preview how
Choose from 20 professionally designed color palettes organized into two categories. Each palette includes
a primary and secondary color that work well together. Click any palette to instantly preview how
it looks throughout the interface.
</p>
{/* Bold/Dark Themes */}
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Bold Themes</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Deep, vibrant colors with white text - ideal for a professional, bold look.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
{[
{ name: 'Ocean Blue', colors: ['#2563eb', '#0ea5e9'] },
{ name: 'Sky Blue', colors: ['#0ea5e9', '#38bdf8'] },
{ name: 'Mint Green', colors: ['#10b981', '#34d399'] },
{ name: 'Coral Reef', colors: ['#f97316', '#fb923c'] },
{ name: 'Lavender', colors: ['#a78bfa', '#c4b5fd'] },
{ name: 'Rose Pink', colors: ['#ec4899', '#f472b6'] },
{ name: 'Forest Green', colors: ['#059669', '#10b981'] },
{ name: 'Royal Purple', colors: ['#7c3aed', '#a78bfa'] },
{ name: 'Slate Gray', colors: ['#475569', '#64748b'] },
{ name: 'Crimson Red', colors: ['#dc2626', '#ef4444'] },
{ name: 'Emerald', colors: ['#10b981', '#34d399'] },
{ name: 'Coral', colors: ['#f97316', '#fb923c'] },
{ name: 'Rose', colors: ['#ec4899', '#f472b6'] },
{ name: 'Forest', colors: ['#059669', '#10b981'] },
{ name: 'Violet', colors: ['#7c3aed', '#a78bfa'] },
{ name: 'Slate', colors: ['#475569', '#64748b'] },
{ name: 'Crimson', colors: ['#dc2626', '#ef4444'] },
{ name: 'Indigo', colors: ['#4f46e5', '#6366f1'] },
].map((palette) => (
<div key={palette.name} className="text-center">
<div
className="h-6 rounded-md mb-1"
style={{ background: `linear-gradient(to right, ${palette.colors[0]}, ${palette.colors[1]})` }}
/>
<span className="text-xs text-gray-600 dark:text-gray-400">{palette.name}</span>
</div>
))}
</div>
{/* Light/Pastel Themes */}
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Light & Pastel Themes</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Soft, airy colors with dark text - perfect for a friendly, approachable feel.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
{[
{ name: 'Soft Mint', colors: ['#6ee7b7', '#a7f3d0'] },
{ name: 'Lavender', colors: ['#c4b5fd', '#ddd6fe'] },
{ name: 'Peach', colors: ['#fdba74', '#fed7aa'] },
{ name: 'Baby Blue', colors: ['#7dd3fc', '#bae6fd'] },
{ name: 'Blush', colors: ['#fda4af', '#fecdd3'] },
{ name: 'Lemon', colors: ['#fde047', '#fef08a'] },
{ name: 'Aqua', colors: ['#5eead4', '#99f6e4'] },
{ name: 'Lilac', colors: ['#d8b4fe', '#e9d5ff'] },
{ name: 'Apricot', colors: ['#fcd34d', '#fde68a'] },
{ name: 'Cloud', colors: ['#94a3b8', '#cbd5e1'] },
].map((palette) => (
<div key={palette.name} className="text-center">
<div
@@ -243,7 +276,7 @@ const HelpSettingsAppearance: React.FC = () => {
</p>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Primary Color</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
@@ -256,6 +289,26 @@ const HelpSettingsAppearance: React.FC = () => {
Used for accents, hover states, and gradients. Should complement the primary color.
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Navigation Text</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Text color for the sidebar navigation. Auto-calculated for contrast, but customizable.
</p>
</div>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<AlertCircle size={18} className="text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Automatic Text Color</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
When you select a palette, the navigation text color is automatically calculated for optimal
readability. Dark backgrounds get white text; light backgrounds get dark text. Click "Auto"
next to the Navigation Text picker to reset to the calculated value.
</p>
</div>
</div>
</div>
<div className="p-4 bg-brand-50 dark:bg-brand-900/20 rounded-lg border border-brand-200 dark:border-brand-800">
@@ -263,7 +316,7 @@ const HelpSettingsAppearance: React.FC = () => {
<ol className="text-sm text-gray-600 dark:text-gray-300 space-y-1 list-decimal list-inside">
<li>Click the color swatch to open the color picker</li>
<li>Select a color visually or enter a hex code (e.g., #3b82f6)</li>
<li>The preview bar shows the gradient of your primary and secondary colors</li>
<li>The Navigation Preview shows exactly how your sidebar will look</li>
<li>Click <strong>Save Changes</strong> to apply your custom colors</li>
</ol>
</div>
@@ -271,6 +324,59 @@ const HelpSettingsAppearance: React.FC = () => {
</div>
</section>
{/* Navigation Preview */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Eye size={20} className="text-brand-500" /> Navigation Preview
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
As you adjust colors, a live preview shows exactly how your sidebar navigation will appear.
The preview includes:
</p>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={18} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Brand Gradient Background</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
Shows the gradient from your primary to secondary color
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={18} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Business Name & Logo Area</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
Displays your business initials and subdomain with the navigation text color
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={18} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white text-sm">Menu Item States</h4>
<p className="text-xs text-gray-600 dark:text-gray-400">
Shows active, hover, and inactive menu item styles
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div className="flex items-start gap-3">
<Sparkles size={18} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-600 dark:text-gray-400">
<strong>Real-time Updates:</strong> The preview updates instantly as you change colors.
Look at your actual sidebar too—it updates in real-time so you can see exactly how
it looks in context before saving.
</p>
</div>
</div>
</div>
</section>
{/* Saving Changes */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">

View File

@@ -78,37 +78,52 @@ const HelpSettingsStaffRoles: React.FC = () => {
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
SmoothSchedule comes with three built-in roles to get you started:
SmoothSchedule comes with two built-in roles to get you started:
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2">
<Shield size={20} className="text-green-500" />
<h4 className="font-medium text-gray-900 dark:text-white">Manager</h4>
<span className="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full">Full Access</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Full access to all features and settings. Best for supervisors and team leads.
<p className="text-sm text-gray-600 dark:text-gray-400">
Complete access to all features, settings, and operations. Managers can:
</p>
<ul className="text-xs text-gray-500 dark:text-gray-400 space-y-1 mt-2">
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-green-500" /> Access all menu items</li>
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-green-500" /> View and modify all settings</li>
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-green-500" /> Perform all operations including deletions</li>
</ul>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
Best for: Supervisors, team leads, office managers
</p>
</div>
<div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-2">
<Shield size={20} className="text-purple-500" />
<h4 className="font-medium text-gray-900 dark:text-white">Support Staff</h4>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Customer-facing operations including scheduling, customers, tickets, messages, and payments.
</p>
</div>
<div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2">
<Shield size={20} className="text-blue-500" />
<h4 className="font-medium text-gray-900 dark:text-white">Staff</h4>
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">Limited Access</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Basic access to own schedule and availability. Best for general staff members.
<p className="text-sm text-gray-600 dark:text-gray-400">
Basic access focused on personal schedule management. Staff can:
</p>
<ul className="text-xs text-gray-500 dark:text-gray-400 space-y-1 mt-2">
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-blue-500" /> View and manage their own schedule</li>
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-blue-500" /> Set their availability</li>
<li className="flex items-center gap-1"><CheckCircle size={12} className="text-blue-500" /> Handle assigned appointments</li>
</ul>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
Best for: General employees, technicians, service providers
</p>
</div>
</div>
<div className="mt-4 p-4 bg-brand-50 dark:bg-brand-900/20 rounded-lg border border-brand-200 dark:border-brand-800">
<p className="text-sm text-brand-700 dark:text-brand-300">
<strong>Need something in between?</strong> Create a custom role with exactly the permissions you need.
For example, a "Front Desk" role with customer and scheduling access but no settings access.
</p>
</div>
</div>
</section>
@@ -280,6 +295,85 @@ const HelpSettingsStaffRoles: React.FC = () => {
</div>
</section>
{/* Settings Access Permissions */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Settings size={20} className="text-brand-500" />
Settings Access Permissions
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Settings permissions control which Settings pages staff can access. This allows you to grant
access to specific configuration areas without giving full settings access.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">General Settings</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Business name, timezone, contact</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Business Hours</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Operating hours and schedules</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Booking Settings</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Booking URL and redirect settings</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Branding</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Logo, colors, appearance</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Email Templates</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Automated email customization</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Custom Domains</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Domain configuration</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Embed Widget</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Website integration code</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">API & Webhooks</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">API tokens and webhook config</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<CheckCircle size={16} className="text-purple-500 mt-0.5" />
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">Staff Roles</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">Role management (this page)</p>
</div>
</div>
</div>
</div>
</section>
{/* Dangerous Permissions */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
@@ -294,8 +388,8 @@ const HelpSettingsStaffRoles: React.FC = () => {
Exercise Caution with These Permissions
</h3>
<p className="text-red-700 dark:text-red-300 text-sm mb-4">
Dangerous permissions allow staff to perform irreversible delete operations.
Only grant these permissions to trusted staff members.
Dangerous permissions allow staff to perform high-impact or irreversible operations.
Only grant these permissions to trusted staff members who need them for their role.
</p>
</div>
</div>
@@ -305,7 +399,16 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Delete Customers</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Allows staff to permanently delete customer records and all associated data
Permanently delete customer records and all associated data
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<AlertTriangle size={18} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Cancel Appointments</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Cancel scheduled appointments, triggering cancellation notifications
</p>
</div>
</div>
@@ -314,7 +417,7 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Delete Appointments</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Allows staff to permanently delete scheduled appointments
Permanently delete appointments from the system
</p>
</div>
</div>
@@ -323,7 +426,7 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Delete Services</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Allows staff to permanently delete services from your offerings
Permanently delete services from your offerings
</p>
</div>
</div>
@@ -332,7 +435,34 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Delete Resources</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Allows staff to permanently delete resources (staff members, rooms, equipment)
Permanently delete resources (staff, rooms, equipment)
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<AlertTriangle size={18} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Process Refunds</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Issue refunds to customers through Stripe
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<AlertTriangle size={18} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Invite Staff</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Send invitations to new staff members
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<AlertTriangle size={18} className="text-red-500 mt-0.5" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Self-Approve Time Off</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Approve their own time-off requests without manager review
</p>
</div>
</div>
@@ -406,7 +536,7 @@ const HelpSettingsStaffRoles: React.FC = () => {
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Start with Default Roles</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use the built-in roles (Manager, Support Staff, Staff) as templates when creating custom roles
Use the built-in roles (Manager, Staff) as templates when creating custom roles
</p>
</div>
</li>

View File

@@ -75,16 +75,22 @@ const HelpStaff: React.FC = () => {
</div>
</section>
{/* Staff Roles Section */}
{/* User Roles vs Staff Roles Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> Staff Roles
<Shield size={20} className="text-brand-500" /> Understanding Roles
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Each staff member has a role that determines their base level of access:
SmoothSchedule uses a two-tier permission system to give you flexible control over what each team member can access:
</p>
<div className="space-y-4">
{/* User Role: Owner vs Staff */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 mt-6">User Role: Owner vs Staff</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
Every team member has a <strong>user role</strong> that determines their base access level:
</p>
<div className="space-y-4 mb-6">
<div className="flex items-start gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<Key size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
@@ -95,21 +101,43 @@ const HelpStaff: React.FC = () => {
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Full access to all features including billing, business settings, staff management, and all operational features. Owners cannot be deactivated or have their permissions restricted. Every business has at least one owner.
<strong>Unrestricted access</strong> to all features including billing, business settings, staff management, and all operational features. Owners cannot be deactivated or have their permissions restricted. Every business has at least one owner.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Briefcase size={20} className="text-blue-500 mt-0.5 shrink-0" />
<Users size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">Manager</h4>
<h4 className="font-medium text-gray-900 dark:text-white">Staff</h4>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
<Briefcase size={10} /> manager
<Users size={10} /> staff
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Can manage scheduling, customers, services, and resources. Has access to view reports. Limited access to settings. Permissions can be customized to expand or restrict access to specific features.
<strong>Configurable access</strong> controlled by their assigned <em>Staff Role</em>. Staff members can have anything from full manager-level access to minimal view-only permissions, depending on which Staff Role they're assigned.
</p>
</div>
</div>
</div>
{/* Staff Roles */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">Staff Roles: Permission Templates</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">
<strong>Staff Roles</strong> are permission templates you assign to staff members. They define exactly what menu items, settings, and operations that person can access. Two default roles are provided:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<Briefcase size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">Manager</h4>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
default role
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Full access to all features and settings. Ideal for supervisors, team leads, and trusted employees who need to manage day-to-day operations including scheduling, customers, services, resources, and settings.
</p>
</div>
</div>
@@ -118,12 +146,66 @@ const HelpStaff: React.FC = () => {
<div>
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">Staff</h4>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
staff
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
default role
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Basic access to view schedules and manage their own appointments. Read-only access to most areas. Ideal for employees who only need to see their calendar and handle their assigned bookings.
Basic access to own schedule and availability only. Perfect for employees who just need to see their calendar, manage their availability, and handle their assigned appointments.
</p>
</div>
</div>
</div>
{/* Custom Roles */}
<div className="mt-6 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<div className="flex items-start gap-2">
<Settings size={16} className="text-indigo-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-indigo-900 dark:text-indigo-100">Create Custom Roles</h4>
<p className="text-sm text-indigo-700 dark:text-indigo-300 mt-1">
Need something between Manager and Staff? Create custom Staff Roles with exactly the permissions you need in <strong>Settings → Staff Roles</strong>. For example: "Front Desk" with customer and scheduling access, or "Technician" with limited operational access.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Permission Categories Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-brand-500" /> Permission Categories
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Staff Role permissions are organized into three categories:
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Calendar size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Menu Access</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Controls which sidebar items are visible: Scheduler, Customers, Services, Resources, Staff, Messages, Payments, Contracts, Time Blocks, Site Builder, and more. Hidden items are completely inaccessible.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<Settings size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Settings Access</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Controls which Settings pages staff can access: General, Business Hours, Branding, Booking, Email Templates, Staff Roles, API & Integrations, and more. Grant settings access selectively.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<AlertCircle size={20} className="text-red-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Dangerous Operations</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Controls sensitive actions: Delete Customers, Cancel/Delete Appointments, Delete Services, Delete Resources, Process Refunds, Invite Staff, and Self-Approve Time Off. Only grant these to trusted team members.
</p>
</div>
</div>

View File

@@ -5,18 +5,20 @@
* Roles control what menu items and features are accessible to staff members.
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check } from 'lucide-react';
import { Business, User, StaffRole, PermissionDefinition } from '../../types';
import { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check, GripVertical } from 'lucide-react';
import { Business, User, StaffRole } from '../../types';
import {
useStaffRoles,
useAvailablePermissions,
useCreateStaffRole,
useUpdateStaffRole,
useDeleteStaffRole,
useReorderStaffRoles,
} from '../../hooks/useStaffRoles';
import { RolePermissionsEditor } from '../../components/staff/RolePermissions';
const StaffRolesSettings: React.FC = () => {
const { t } = useTranslation();
@@ -30,8 +32,12 @@ const StaffRolesSettings: React.FC = () => {
const createStaffRole = useCreateStaffRole();
const updateStaffRole = useUpdateStaffRole();
const deleteStaffRole = useDeleteStaffRole();
const reorderStaffRoles = useReorderStaffRoles();
const [isModalOpen, setIsModalOpen] = useState(false);
const [draggedRoleId, setDraggedRoleId] = useState<number | null>(null);
const [dragOverRoleId, setDragOverRoleId] = useState<number | null>(null);
const draggedRef = useRef<number | null>(null);
const [editingRole, setEditingRole] = useState<StaffRole | null>(null);
const [formData, setFormData] = useState({
name: '',
@@ -82,57 +88,10 @@ const StaffRolesSettings: React.FC = () => {
setError(null);
};
const togglePermission = (key: string) => {
setFormData((prev) => {
const newValue = !prev.permissions[key];
const updates: Record<string, boolean> = { [key]: newValue };
// If enabling any settings sub-permission, also enable the main settings access
if (newValue && key.startsWith('can_access_settings_')) {
updates['can_access_settings'] = true;
}
// If disabling the main settings access, disable all sub-permissions
if (!newValue && key === 'can_access_settings') {
Object.keys(allPermissions.settings).forEach((settingKey) => {
if (settingKey !== 'can_access_settings') {
updates[settingKey] = false;
}
});
}
return {
...prev,
permissions: {
...prev.permissions,
...updates,
},
};
});
};
const toggleAllPermissions = (category: 'menu' | 'settings' | 'dangerous', enable: boolean) => {
const permissions = category === 'menu'
? allPermissions.menu
: category === 'settings'
? allPermissions.settings
: allPermissions.dangerous;
const updates: Record<string, boolean> = {};
Object.keys(permissions).forEach((key) => {
updates[key] = enable;
});
// If enabling any settings permissions, ensure main settings access is also enabled
if (category === 'settings' && enable) {
updates['can_access_settings'] = true;
}
const handlePermissionsChange = (newPermissions: Record<string, boolean>) => {
setFormData((prev) => ({
...prev,
permissions: {
...prev.permissions,
...updates,
},
permissions: newPermissions,
}));
};
@@ -185,6 +144,67 @@ const StaffRolesSettings: React.FC = () => {
return Object.values(permissions).filter(Boolean).length;
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, roleId: number) => {
e.dataTransfer.setData('text/plain', String(roleId));
e.dataTransfer.effectAllowed = 'move';
draggedRef.current = roleId;
setDraggedRoleId(roleId);
};
const handleDragEnd = () => {
setDraggedRoleId(null);
setDragOverRoleId(null);
draggedRef.current = null;
};
const handleDragOver = (e: React.DragEvent, roleId: number) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (draggedRef.current && draggedRef.current !== roleId) {
setDragOverRoleId(roleId);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOverRoleId(null);
};
const handleDrop = async (e: React.DragEvent, targetRoleId: number) => {
e.preventDefault();
e.stopPropagation();
const draggedId = draggedRef.current;
setDragOverRoleId(null);
if (!draggedId || draggedId === targetRoleId) {
return;
}
// Reorder the roles
const currentOrder = staffRoles.map(r => r.id);
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetRoleId);
if (draggedIndex === -1 || targetIndex === -1) {
return;
}
// Remove dragged item and insert at target position
const newOrder = [...currentOrder];
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedId);
try {
await reorderStaffRoles.mutateAsync(newOrder);
} catch (err) {
// Silently fail - the UI will remain unchanged
}
};
if (!canManageRoles) {
return (
<div className="text-center py-12">
@@ -240,14 +260,36 @@ const StaffRolesSettings: React.FC = () => {
<p className="text-sm mt-1">{t('settings.staffRoles.createFirst', 'Create your first role to manage staff permissions.')}</p>
</div>
) : (
<div className="space-y-3">
<div
className="space-y-3"
onDragOver={(e) => e.preventDefault()}
>
{staffRoles.map((role) => (
<div
key={role.id}
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
draggable="true"
onDragStart={(e) => handleDragStart(e, role.id)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, role.id)}
onDragLeave={(e) => handleDragLeave(e)}
onDrop={(e) => handleDrop(e, role.id)}
className={`p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border transition-all select-none ${
dragOverRoleId === role.id
? 'border-brand-500 border-2 bg-brand-50 dark:bg-brand-900/20'
: draggedRoleId === role.id
? 'border-gray-300 dark:border-gray-600 opacity-50'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Drag Handle */}
<div
className="w-6 h-10 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
title={t('settings.staffRoles.dragToReorder', 'Drag to reorder')}
>
<GripVertical size={18} />
</div>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
role.is_default
? 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400'
@@ -283,9 +325,10 @@ const StaffRolesSettings: React.FC = () => {
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<div className="flex items-center gap-2 shrink-0 ml-2" onDragStart={(e) => e.preventDefault()}>
<button
onClick={() => openEditModal(role)}
draggable="false"
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
@@ -293,6 +336,7 @@ const StaffRolesSettings: React.FC = () => {
</button>
<button
onClick={() => handleDelete(role)}
draggable="false"
disabled={deleteStaffRole.isPending || !role.can_delete}
className={`p-2 transition-colors disabled:opacity-50 ${
role.can_delete
@@ -373,170 +417,12 @@ const StaffRolesSettings: React.FC = () => {
</div>
</div>
{/* Menu Permissions */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('settings.staffRoles.menuPermissions', 'Menu Access')}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.menuPermissionsDescription', 'Control which pages staff can see in the sidebar.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('menu', true)}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => toggleAllPermissions('menu', false)}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(allPermissions.menu).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-brand-600 border-gray-300 dark:border-gray-600 rounded focus:ring-brand-500"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{def.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Business Settings Permissions */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('settings.staffRoles.settingsPermissions', 'Business Settings Access')}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.settingsPermissionsDescription', 'Control which settings pages staff can access.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('settings', true)}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => toggleAllPermissions('settings', false)}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 p-3 bg-blue-50/50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-900/30">
{Object.entries(allPermissions.settings).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100/50 dark:hover:bg-blue-900/20 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{def.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Dangerous Permissions */}
<div>
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
{t('settings.staffRoles.dangerousPermissions', 'Dangerous Operations')}
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">
{t('common.caution', 'Caution')}
</span>
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.staffRoles.dangerousPermissionsDescription', 'Allow staff to perform destructive or sensitive actions.')}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => toggleAllPermissions('dangerous', true)}
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
>
{t('common.selectAll', 'Select All')}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={() => toggleAllPermissions('dangerous', false)}
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
{t('common.clearAll', 'Clear All')}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 p-3 bg-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30">
{Object.entries(allPermissions.dangerous).map(([key, def]: [string, PermissionDefinition]) => (
<label
key={key}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-red-100/50 dark:hover:bg-red-900/20 cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions[key] || false}
onChange={() => togglePermission(key)}
className="w-4 h-4 text-red-600 border-gray-300 dark:border-gray-600 rounded focus:ring-red-500"
/>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{def.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{def.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Permissions Editor */}
<RolePermissionsEditor
permissions={formData.permissions}
onChange={handlePermissionsChange}
availablePermissions={allPermissions}
/>
</div>
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">

View File

@@ -150,6 +150,7 @@ export interface StaffRole {
description: string;
permissions: Record<string, boolean>;
is_default: boolean;
position: number;
staff_count: number;
can_delete: boolean;
created_at: string;