feat: Add comprehensive sandbox mode, public API system, and platform support
This commit adds major features for sandbox isolation, public API access, and platform support ticketing. ## Sandbox Mode - Add sandbox mode toggle for businesses to test features without affecting live data - Implement schema-based isolation for tenant data (appointments, resources, services) - Add is_sandbox field filtering for shared models (customers, staff, tickets) - Create sandbox middleware to detect and set sandbox mode from cookies - Add sandbox context and hooks for React frontend - Display sandbox banner when in test mode - Auto-reload page when switching between live/test modes - Prevent platform support tickets from being created in sandbox mode ## Public API System - Full REST API for external integrations with businesses - API token management with sandbox/live token separation - Test tokens (ss_test_*) show full plaintext for easy testing - Live tokens (ss_live_*) are hashed and secure - Security validation prevents live token plaintext storage - Comprehensive test suite for token security - Rate limiting and throttling per token - Webhook support for real-time event notifications - Scoped permissions system (read/write per resource type) - API documentation page with interactive examples - Token revocation with confirmation modal ## Platform Support - Dedicated support page for businesses to contact SmoothSchedule - View all platform support tickets in one place - Create new support tickets with simplified interface - Reply to existing tickets with conversation history - Platform tickets have no admin controls (no priority/category/assignee/status) - Internal notes hidden for platform tickets (business can't see them) - Quick help section with links to guides and API docs - Sandbox warning prevents ticket creation in test mode - Business ticketing retains full admin controls (priority, assignment, internal notes) ## UI/UX Improvements - Add notification dropdown with real-time updates - Staff permissions UI for ticket access control - Help dropdown in sidebar with Platform Guide, Ticketing Help, API Docs, and Support - Update sidebar "Contact Support" to "Support" with message icon - Fix navigation links to use React Router instead of anchor tags - Remove unused language translations (Japanese, Portuguese, Chinese) ## Technical Details - Sandbox middleware sets request.sandbox_mode from cookies - ViewSets filter data by is_sandbox field - API authentication via custom token auth class - WebSocket support for real-time ticket updates - Migration for sandbox fields on User, Tenant, and Ticket models - Comprehensive documentation in SANDBOX_MODE_IMPLEMENTATION.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ import Resources from './pages/Resources';
|
||||
import Services from './pages/Services';
|
||||
import Staff from './pages/Staff';
|
||||
import CustomerDashboard from './pages/customer/CustomerDashboard';
|
||||
import CustomerSupport from './pages/customer/CustomerSupport';
|
||||
import ResourceDashboard from './pages/resource/ResourceDashboard';
|
||||
import BookingPage from './pages/customer/BookingPage';
|
||||
import TrialExpired from './pages/TrialExpired';
|
||||
@@ -47,7 +48,7 @@ import Upgrade from './pages/Upgrade';
|
||||
// Import platform pages
|
||||
import PlatformDashboard from './pages/platform/PlatformDashboard';
|
||||
import PlatformBusinesses from './pages/platform/PlatformBusinesses';
|
||||
import PlatformSupport from './pages/platform/PlatformSupport';
|
||||
import PlatformSupportPage from './pages/platform/PlatformSupport';
|
||||
import PlatformUsers from './pages/platform/PlatformUsers';
|
||||
import PlatformSettings from './pages/platform/PlatformSettings';
|
||||
import ProfileSettings from './pages/ProfileSettings';
|
||||
@@ -56,6 +57,10 @@ import EmailVerificationRequired from './pages/EmailVerificationRequired';
|
||||
import AcceptInvitePage from './pages/AcceptInvitePage';
|
||||
import TenantOnboardPage from './pages/TenantOnboardPage';
|
||||
import Tickets from './pages/Tickets'; // Import Tickets page
|
||||
import HelpGuide from './pages/HelpGuide'; // Import Platform Guide page
|
||||
import HelpTicketing from './pages/HelpTicketing'; // Import Help page for ticketing
|
||||
import HelpApiDocs from './pages/HelpApiDocs'; // Import API documentation page
|
||||
import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -321,7 +326,10 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/platform/support" element={<PlatformSupport />} />
|
||||
<Route path="/platform/support" element={<PlatformSupportPage />} />
|
||||
<Route path="/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
{user.role === 'superuser' && (
|
||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||
)}
|
||||
@@ -346,19 +354,47 @@ const AppContent: React.FC = () => {
|
||||
|
||||
// Customer users
|
||||
if (user.role === 'customer') {
|
||||
// Wait for business data to load
|
||||
if (businessLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Handle business not found for customers
|
||||
if (!business) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">Business Not Found</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Unable to load business data. Please try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<CustomerLayout
|
||||
business={business || ({} as any)}
|
||||
business={business}
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
toggleTheme={toggleTheme}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<CustomerDashboard />} />
|
||||
<Route path="/book" element={<BookingPage />} />
|
||||
<Route path="/payments" element={<Payments />} />
|
||||
<Route path="/support" element={<CustomerSupport />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
@@ -470,6 +506,10 @@ const AppContent: React.FC = () => {
|
||||
/>
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/tickets" element={<Tickets />} />
|
||||
<Route path="/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
<Route path="/support" element={<PlatformSupport />} />
|
||||
<Route
|
||||
path="/customers"
|
||||
element={
|
||||
|
||||
@@ -16,7 +16,19 @@ const apiClient = axios.create({
|
||||
withCredentials: true, // For CORS with credentials
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token and business subdomain
|
||||
/**
|
||||
* Get sandbox mode from localStorage
|
||||
* This is set by the SandboxContext when mode changes
|
||||
*/
|
||||
const getSandboxMode = (): boolean => {
|
||||
try {
|
||||
return localStorage.getItem('sandbox_mode') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Request interceptor - add auth token, business subdomain, and sandbox mode
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Add business subdomain header if on business site
|
||||
@@ -32,6 +44,12 @@ apiClient.interceptors.request.use(
|
||||
config.headers['Authorization'] = `Token ${token}`;
|
||||
}
|
||||
|
||||
// Add sandbox mode header if in test mode
|
||||
const isSandbox = getSandboxMode();
|
||||
if (isSandbox) {
|
||||
config.headers['X-Sandbox-Mode'] = 'true';
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
|
||||
64
frontend/src/api/notifications.ts
Normal file
64
frontend/src/api/notifications.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
verb: string;
|
||||
read: boolean;
|
||||
timestamp: string;
|
||||
data: Record<string, any>;
|
||||
actor_type: string | null;
|
||||
actor_display: string | null;
|
||||
target_type: string | null;
|
||||
target_display: string | null;
|
||||
target_url: string | null;
|
||||
}
|
||||
|
||||
export interface UnreadCountResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notifications for the current user
|
||||
*/
|
||||
export const getNotifications = async (params?: { read?: boolean; limit?: number }): Promise<Notification[]> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.read !== undefined) {
|
||||
queryParams.append('read', String(params.read));
|
||||
}
|
||||
if (params?.limit !== undefined) {
|
||||
queryParams.append('limit', String(params.limit));
|
||||
}
|
||||
const query = queryParams.toString();
|
||||
const url = query ? `/api/notifications/?${query}` : '/api/notifications/';
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get count of unread notifications
|
||||
*/
|
||||
export const getUnreadCount = async (): Promise<number> => {
|
||||
const response = await apiClient.get<UnreadCountResponse>('/api/notifications/unread_count/');
|
||||
return response.data.count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
export const markNotificationRead = async (id: number): Promise<void> => {
|
||||
await apiClient.post(`/api/notifications/${id}/mark_read/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
export const markAllNotificationsRead = async (): Promise<void> => {
|
||||
await apiClient.post('/api/notifications/mark_all_read/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all read notifications
|
||||
*/
|
||||
export const clearAllNotifications = async (): Promise<void> => {
|
||||
await apiClient.delete('/api/notifications/clear_all/');
|
||||
};
|
||||
48
frontend/src/api/sandbox.ts
Normal file
48
frontend/src/api/sandbox.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Sandbox Mode API
|
||||
* Manage live/test mode switching for isolated test data
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface SandboxStatus {
|
||||
sandbox_mode: boolean;
|
||||
sandbox_enabled: boolean;
|
||||
sandbox_schema: string | null;
|
||||
}
|
||||
|
||||
export interface SandboxToggleResponse {
|
||||
sandbox_mode: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SandboxResetResponse {
|
||||
message: string;
|
||||
sandbox_schema: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sandbox mode status
|
||||
*/
|
||||
export const getSandboxStatus = async (): Promise<SandboxStatus> => {
|
||||
const response = await apiClient.get<SandboxStatus>('/api/sandbox/status/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle between live and sandbox mode
|
||||
*/
|
||||
export const toggleSandboxMode = async (enableSandbox: boolean): Promise<SandboxToggleResponse> => {
|
||||
const response = await apiClient.post<SandboxToggleResponse>('/api/sandbox/toggle/', {
|
||||
sandbox: enableSandbox,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset sandbox data to initial state
|
||||
*/
|
||||
export const resetSandboxData = async (): Promise<SandboxResetResponse> => {
|
||||
const response = await apiClient.post<SandboxResetResponse>('/api/sandbox/reset/');
|
||||
return response.data;
|
||||
};
|
||||
671
frontend/src/components/ApiTokensSection.tsx
Normal file
671
frontend/src/components/ApiTokensSection.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Copy,
|
||||
Check,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Clock,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useApiTokens,
|
||||
useCreateApiToken,
|
||||
useRevokeApiToken,
|
||||
useUpdateApiToken,
|
||||
API_SCOPES,
|
||||
SCOPE_PRESETS,
|
||||
APIToken,
|
||||
APITokenCreateResponse,
|
||||
} from '../hooks/useApiTokens';
|
||||
|
||||
interface NewTokenModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onTokenCreated: (token: APITokenCreateResponse) => void;
|
||||
}
|
||||
|
||||
const NewTokenModal: React.FC<NewTokenModalProps> = ({ isOpen, onClose, onTokenCreated }) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
|
||||
const [expiresIn, setExpiresIn] = useState<string>('never');
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const createMutation = useCreateApiToken();
|
||||
|
||||
const handlePresetSelect = (presetKey: keyof typeof SCOPE_PRESETS) => {
|
||||
setSelectedScopes(SCOPE_PRESETS[presetKey].scopes);
|
||||
};
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
setSelectedScopes(prev =>
|
||||
prev.includes(scope)
|
||||
? prev.filter(s => s !== scope)
|
||||
: [...prev, scope]
|
||||
);
|
||||
};
|
||||
|
||||
const calculateExpiryDate = (): string | null => {
|
||||
if (expiresIn === 'never') return null;
|
||||
const now = new Date();
|
||||
switch (expiresIn) {
|
||||
case '7d':
|
||||
now.setDate(now.getDate() + 7);
|
||||
break;
|
||||
case '30d':
|
||||
now.setDate(now.getDate() + 30);
|
||||
break;
|
||||
case '90d':
|
||||
now.setDate(now.getDate() + 90);
|
||||
break;
|
||||
case '1y':
|
||||
now.setFullYear(now.getFullYear() + 1);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return now.toISOString();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || selectedScopes.length === 0) return;
|
||||
|
||||
try {
|
||||
const result = await createMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
scopes: selectedScopes,
|
||||
expires_at: calculateExpiryDate(),
|
||||
});
|
||||
onTokenCreated(result);
|
||||
setName('');
|
||||
setSelectedScopes([]);
|
||||
setExpiresIn('never');
|
||||
} catch (error) {
|
||||
console.error('Failed to create token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Create API Token
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Token Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Token Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Website Integration, Mobile App"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Choose a descriptive name to identify this token's purpose
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scope Presets */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Permission Presets
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{Object.entries(SCOPE_PRESETS).map(([key, preset]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => handlePresetSelect(key as keyof typeof SCOPE_PRESETS)}
|
||||
className={`p-3 text-left border rounded-lg transition-colors ${
|
||||
JSON.stringify(selectedScopes.sort()) === JSON.stringify(preset.scopes.sort())
|
||||
? 'border-purple-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-purple-300 dark:hover:border-purple-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{preset.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{preset.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced: Individual Scopes */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300"
|
||||
>
|
||||
{showAdvanced ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
{showAdvanced ? 'Hide' : 'Show'} individual permissions
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
||||
{API_SCOPES.map((scope) => (
|
||||
<label
|
||||
key={scope.value}
|
||||
className="flex items-start gap-3 p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScopes.includes(scope.value)}
|
||||
onChange={() => toggleScope(scope.value)}
|
||||
className="mt-1 h-4 w-4 text-brand-600 border-gray-300 rounded focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{scope.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{scope.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={expiresIn}
|
||||
onChange={(e) => setExpiresIn(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
>
|
||||
<option value="never">Never expires</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Selected Scopes Summary */}
|
||||
{selectedScopes.length > 0 && (
|
||||
<div className="p-3 bg-brand-50 dark:bg-brand-900/20 rounded-lg">
|
||||
<div className="text-sm font-medium text-brand-700 dark:text-brand-300 mb-2">
|
||||
Selected permissions ({selectedScopes.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedScopes.map((scope) => (
|
||||
<span
|
||||
key={scope}
|
||||
className="px-2 py-0.5 text-xs bg-brand-100 dark:bg-brand-800 text-brand-700 dark:text-brand-300 rounded"
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || selectedScopes.length === 0 || createMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<span className="animate-spin">⏳</span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key size={16} />
|
||||
Create Token
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TokenCreatedModalProps {
|
||||
token: APITokenCreateResponse | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TokenCreatedModal: React.FC<TokenCreatedModalProps> = ({ token, onClose }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (token) {
|
||||
navigator.clipboard.writeText(token.key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full mx-4">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||
<Check className="text-green-600 dark:text-green-400" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Token Created
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{token.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" size={18} />
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Important:</strong> Copy your token now. You won't be able to see it again!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Your API Token
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token.key}
|
||||
readOnly
|
||||
className="w-full px-4 py-3 pr-20 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showToken ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`px-4 py-3 rounded-lg font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||
copied
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-brand-600 hover:bg-brand-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{copied ? <Check size={18} /> : <Copy size={18} />}
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TokenRowProps {
|
||||
token: APIToken;
|
||||
onRevoke: (id: string, name: string) => void;
|
||||
isRevoking: boolean;
|
||||
}
|
||||
|
||||
const TokenRow: React.FC<TokenRowProps> = ({ token, onRevoke, isRevoking }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const isExpired = token.expires_at && new Date(token.expires_at) < new Date();
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg ${
|
||||
!token.is_active || isExpired
|
||||
? 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 opacity-60'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||
}`}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
token.is_active && !isExpired
|
||||
? 'bg-brand-100 dark:bg-brand-900/30'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
<Key size={18} className={
|
||||
token.is_active && !isExpired
|
||||
? 'text-brand-600 dark:text-brand-400'
|
||||
: 'text-gray-400'
|
||||
} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{token.name}
|
||||
</span>
|
||||
{(!token.is_active || isExpired) && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded">
|
||||
{isExpired ? 'Expired' : 'Revoked'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 font-mono">
|
||||
{token.key_prefix}••••••••
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
{expanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</button>
|
||||
{token.is_active && !isExpired && (
|
||||
<button
|
||||
onClick={() => onRevoke(token.id, token.name)}
|
||||
disabled={isRevoking}
|
||||
className="p-2 text-red-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg disabled:opacity-50"
|
||||
title="Revoke token"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Created</div>
|
||||
<div className="text-gray-900 dark:text-white">{formatDate(token.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Last Used</div>
|
||||
<div className="text-gray-900 dark:text-white">{formatDate(token.last_used_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Expires</div>
|
||||
<div className={`${isExpired ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'}`}>
|
||||
{formatDate(token.expires_at)}
|
||||
</div>
|
||||
</div>
|
||||
{token.created_by && (
|
||||
<div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Created By</div>
|
||||
<div className="text-gray-900 dark:text-white">{token.created_by.full_name || token.created_by.username}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2">Permissions</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{token.scopes.map((scope) => (
|
||||
<span
|
||||
key={scope}
|
||||
className="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded"
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ApiTokensSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: tokens, isLoading, error } = useApiTokens();
|
||||
const revokeMutation = useRevokeApiToken();
|
||||
const [showNewTokenModal, setShowNewTokenModal] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<APITokenCreateResponse | null>(null);
|
||||
const [tokenToRevoke, setTokenToRevoke] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const handleTokenCreated = (token: APITokenCreateResponse) => {
|
||||
setShowNewTokenModal(false);
|
||||
setCreatedToken(token);
|
||||
};
|
||||
|
||||
const handleRevokeClick = (id: string, name: string) => {
|
||||
setTokenToRevoke({ id, name });
|
||||
};
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!tokenToRevoke) return;
|
||||
setTokenToRevoke(null);
|
||||
await revokeMutation.mutateAsync(tokenToRevoke.id);
|
||||
};
|
||||
|
||||
const activeTokens = tokens?.filter(t => t.is_active) || [];
|
||||
const revokedTokens = tokens?.filter(t => !t.is_active) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Revoke Confirmation Modal */}
|
||||
{tokenToRevoke && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<AlertTriangle size={24} className="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Revoke API Token?
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-1">
|
||||
Are you sure you want to revoke <strong>{tokenToRevoke.name}</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
This action cannot be undone. Applications using this token will immediately lose access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setTokenToRevoke(null)}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmRevoke}
|
||||
disabled={revokeMutation.isPending}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{revokeMutation.isPending ? 'Revoking...' : 'Revoke Token'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Key size={20} className="text-brand-500" />
|
||||
API Tokens
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Create and manage API tokens for third-party integrations
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/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} />
|
||||
API Docs
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowNewTokenModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
Failed to load API tokens. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
) : tokens && tokens.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full mb-4">
|
||||
<Key size={32} className="text-gray-400" />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No API tokens yet
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 max-w-sm mx-auto">
|
||||
Create your first API token to start integrating with external services and applications.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewTokenModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create API Token
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{activeTokens.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Shield size={16} className="text-green-500" />
|
||||
Active Tokens ({activeTokens.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{activeTokens.map((token) => (
|
||||
<TokenRow
|
||||
key={token.id}
|
||||
token={token}
|
||||
onRevoke={handleRevokeClick}
|
||||
isRevoking={revokeMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revokedTokens.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<Clock size={16} />
|
||||
Revoked Tokens ({revokedTokens.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{revokedTokens.map((token) => (
|
||||
<TokenRow
|
||||
key={token.id}
|
||||
token={token}
|
||||
onRevoke={handleRevokeClick}
|
||||
isRevoking={revokeMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Modals */}
|
||||
<NewTokenModal
|
||||
isOpen={showNewTokenModal}
|
||||
onClose={() => setShowNewTokenModal(false)}
|
||||
onTokenCreated={handleTokenCreated}
|
||||
/>
|
||||
<TokenCreatedModal
|
||||
token={createdToken}
|
||||
onClose={() => setCreatedToken(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiTokensSection;
|
||||
@@ -79,7 +79,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-[60] py-1 animate-in fade-in slide-in-from-top-2">
|
||||
<ul role="listbox" aria-label="Select language">
|
||||
{supportedLanguages.map((lang) => (
|
||||
<li key={lang.code}>
|
||||
|
||||
229
frontend/src/components/NotificationDropdown.tsx
Normal file
229
frontend/src/components/NotificationDropdown.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare } from 'lucide-react';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadNotificationCount,
|
||||
useMarkNotificationRead,
|
||||
useMarkAllNotificationsRead,
|
||||
useClearAllNotifications,
|
||||
} from '../hooks/useNotifications';
|
||||
import { Notification } from '../api/notifications';
|
||||
|
||||
interface NotificationDropdownProps {
|
||||
variant?: 'light' | 'dark';
|
||||
onTicketClick?: (ticketId: string) => void;
|
||||
}
|
||||
|
||||
const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = 'dark', onTicketClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: notifications = [], isLoading } = useNotifications({ limit: 20 });
|
||||
const { data: unreadCount = 0 } = useUnreadNotificationCount();
|
||||
const markReadMutation = useMarkNotificationRead();
|
||||
const markAllReadMutation = useMarkAllNotificationsRead();
|
||||
const clearAllMutation = useClearAllNotifications();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleNotificationClick = (notification: Notification) => {
|
||||
// Mark as read
|
||||
if (!notification.read) {
|
||||
markReadMutation.mutate(notification.id);
|
||||
}
|
||||
|
||||
// Handle ticket notifications specially - open modal instead of navigating
|
||||
if (notification.target_type === 'ticket' && onTicketClick) {
|
||||
const ticketId = notification.data?.ticket_id;
|
||||
if (ticketId) {
|
||||
onTicketClick(String(ticketId));
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to target if available
|
||||
if (notification.target_url) {
|
||||
navigate(notification.target_url);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate();
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
clearAllMutation.mutate();
|
||||
};
|
||||
|
||||
const getNotificationIcon = (targetType: string | null) => {
|
||||
switch (targetType) {
|
||||
case 'ticket':
|
||||
return <Ticket size={16} className="text-blue-500" />;
|
||||
case 'event':
|
||||
case 'appointment':
|
||||
return <Calendar size={16} className="text-green-500" />;
|
||||
default:
|
||||
return <MessageSquare size={16} className="text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t('notifications.justNow', 'Just now');
|
||||
if (diffMins < 60) return t('notifications.minutesAgo', '{{count}}m ago', { count: diffMins });
|
||||
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}}h ago', { count: diffHours });
|
||||
if (diffDays < 7) return t('notifications.daysAgo', '{{count}}d ago', { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const buttonClasses = variant === 'light'
|
||||
? 'p-2 rounded-md text-white/80 hover:text-white hover:bg-white/10 transition-colors'
|
||||
: 'relative p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700';
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Bell Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={buttonClasses}
|
||||
aria-label={t('notifications.openNotifications', 'Open notifications')}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-80 sm:w-96 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 z-50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('notifications.title', 'Notifications')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={t('notifications.markAllRead', 'Mark all as read')}
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification List */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Bell size={32} className="mx-auto text-gray-300 dark:text-gray-600 mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('notifications.noNotifications', 'No notifications yet')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{notifications.map((notification) => (
|
||||
<button
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${
|
||||
!notification.read ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{getNotificationIcon(notification.target_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.read ? 'font-medium' : ''} text-gray-900 dark:text-white`}>
|
||||
<span className="font-medium">{notification.actor_display || 'System'}</span>
|
||||
{' '}
|
||||
{notification.verb}
|
||||
</p>
|
||||
{notification.target_display && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{notification.target_display}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.read && (
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-2"></span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
disabled={clearAllMutation.isPending}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{t('notifications.clearRead', 'Clear read')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/notifications');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
||||
>
|
||||
{t('notifications.viewAll', 'View all')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationDropdown;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield } from 'lucide-react';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
|
||||
@@ -77,6 +77,18 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="mt-8 pt-4 border-t border-gray-800">
|
||||
<Link to="/help/ticketing" className={getNavClass('/help/ticketing')} title={t('nav.help', 'Help')}>
|
||||
<HelpCircle size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.help', 'Help')}</span>}
|
||||
</Link>
|
||||
<Link to="/help/api" className={getNavClass('/help/api')} title={t('nav.apiDocs', 'API Documentation')}>
|
||||
<Code size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.apiDocs', 'API Docs')}</span>}
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
79
frontend/src/components/SandboxBanner.tsx
Normal file
79
frontend/src/components/SandboxBanner.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Sandbox Banner Component
|
||||
* Displays a prominent warning banner when in test/sandbox mode
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlaskConical, X } from 'lucide-react';
|
||||
|
||||
interface SandboxBannerProps {
|
||||
/** Whether sandbox mode is currently active */
|
||||
isSandbox: boolean;
|
||||
/** Callback to switch to live mode */
|
||||
onSwitchToLive: () => void;
|
||||
/** Optional: Allow dismissing the banner (it will reappear on page reload) */
|
||||
onDismiss?: () => void;
|
||||
/** Whether switching is in progress */
|
||||
isSwitching?: boolean;
|
||||
}
|
||||
|
||||
const SandboxBanner: React.FC<SandboxBannerProps> = ({
|
||||
isSandbox,
|
||||
onSwitchToLive,
|
||||
onDismiss,
|
||||
isSwitching = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Don't render if not in sandbox mode
|
||||
if (!isSandbox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="w-5 h-5 animate-pulse" />
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="font-semibold text-sm">
|
||||
{t('sandbox.bannerTitle', 'TEST MODE')}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm opacity-90">
|
||||
{t('sandbox.bannerDescription', 'You are viewing test data. Changes here won\'t affect your live business.')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onSwitchToLive}
|
||||
disabled={isSwitching}
|
||||
className={`
|
||||
px-3 py-1 text-xs font-medium rounded-md
|
||||
bg-white text-orange-600 hover:bg-orange-50
|
||||
transition-colors duration-150
|
||||
${isSwitching ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
{isSwitching
|
||||
? t('sandbox.switching', 'Switching...')
|
||||
: t('sandbox.switchToLive', 'Switch to Live')
|
||||
}
|
||||
</button>
|
||||
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="p-1 hover:bg-orange-600 rounded transition-colors duration-150"
|
||||
title={t('sandbox.dismiss', 'Dismiss')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SandboxBanner;
|
||||
80
frontend/src/components/SandboxToggle.tsx
Normal file
80
frontend/src/components/SandboxToggle.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Sandbox Toggle Component
|
||||
* A toggle switch to switch between Live and Test modes
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlaskConical, Zap } from 'lucide-react';
|
||||
|
||||
interface SandboxToggleProps {
|
||||
/** Whether sandbox mode is currently active */
|
||||
isSandbox: boolean;
|
||||
/** Whether sandbox mode is available for this business */
|
||||
sandboxEnabled: boolean;
|
||||
/** Callback when mode is toggled */
|
||||
onToggle: (enableSandbox: boolean) => void;
|
||||
/** Whether a toggle operation is in progress */
|
||||
isToggling?: boolean;
|
||||
/** Optional additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SandboxToggle: React.FC<SandboxToggleProps> = ({
|
||||
isSandbox,
|
||||
sandboxEnabled,
|
||||
onToggle,
|
||||
isToggling = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Don't render if sandbox is not enabled for this business
|
||||
if (!sandboxEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center ${className}`}>
|
||||
{/* Live Mode Button */}
|
||||
<button
|
||||
onClick={() => onToggle(false)}
|
||||
disabled={isToggling || !isSandbox}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-l-md
|
||||
transition-colors duration-150 border
|
||||
${!isSandbox
|
||||
? 'bg-green-600 text-white border-green-600'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}
|
||||
${isToggling ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
title={t('sandbox.liveMode', 'Live Mode - Production data')}
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
<span>{t('sandbox.live', 'Live')}</span>
|
||||
</button>
|
||||
|
||||
{/* Test Mode Button */}
|
||||
<button
|
||||
onClick={() => onToggle(true)}
|
||||
disabled={isToggling || isSandbox}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-r-md
|
||||
transition-colors duration-150 border -ml-px
|
||||
${isSandbox
|
||||
? 'bg-orange-500 text-white border-orange-500'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}
|
||||
${isToggling ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
title={t('sandbox.testMode', 'Test Mode - Sandbox data')}
|
||||
>
|
||||
<FlaskConical className="w-3.5 h-3.5" />
|
||||
<span>{t('sandbox.test', 'Test')}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SandboxToggle;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
LogOut,
|
||||
ClipboardList,
|
||||
Briefcase,
|
||||
Ticket
|
||||
Ticket,
|
||||
HelpCircle,
|
||||
Code,
|
||||
ChevronDown,
|
||||
BookOpen,
|
||||
FileQuestion,
|
||||
LifeBuoy
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
@@ -29,6 +35,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
const location = useLocation();
|
||||
const { role } = user;
|
||||
const logoutMutation = useLogout();
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(location.pathname.startsWith('/help') || location.pathname === '/support');
|
||||
|
||||
const getNavClass = (path: string, exact: boolean = false, disabled: boolean = false) => {
|
||||
const isActive = exact
|
||||
@@ -174,6 +181,62 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
<Users size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
||||
</Link>
|
||||
{/* Help Dropdown */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsHelpOpen(!isHelpOpen)}
|
||||
className={`flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors w-full ${isCollapsed ? 'px-3 justify-center' : 'px-4'} ${location.pathname.startsWith('/help') || location.pathname === '/support' ? 'bg-white/10 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.help', 'Help')}
|
||||
>
|
||||
<HelpCircle size={20} className="shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">{t('nav.help', 'Help')}</span>
|
||||
<ChevronDown size={16} className={`shrink-0 transition-transform ${isHelpOpen ? 'rotate-180' : ''}`} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isHelpOpen && !isCollapsed && (
|
||||
<div className="ml-4 mt-1 space-y-1 border-l border-white/20 pl-4">
|
||||
<Link
|
||||
to="/help/guide"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/guide' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.platformGuide', 'Platform Guide')}
|
||||
>
|
||||
<BookOpen size={16} className="shrink-0" />
|
||||
<span>{t('nav.platformGuide', 'Platform Guide')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/help/ticketing"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/ticketing' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.ticketingHelp', 'Ticketing System')}
|
||||
>
|
||||
<FileQuestion size={16} className="shrink-0" />
|
||||
<span>{t('nav.ticketingHelp', 'Ticketing System')}</span>
|
||||
</Link>
|
||||
{role === 'owner' && (
|
||||
<Link
|
||||
to="/help/api"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/api' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.apiDocs', 'API Documentation')}
|
||||
>
|
||||
<Code size={16} className="shrink-0" />
|
||||
<span>{t('nav.apiDocs', 'API Docs')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="pt-2 mt-2 border-t border-white/10">
|
||||
<Link
|
||||
to="/support"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/support' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.support', 'Support')}
|
||||
>
|
||||
<MessageSquare size={16} className="shrink-0" />
|
||||
<span>{t('nav.support', 'Support')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -217,4 +280,4 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
export default Sidebar;
|
||||
|
||||
202
frontend/src/components/StaffPermissions.tsx
Normal file
202
frontend/src/components/StaffPermissions.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PermissionConfig {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
labelDefault: string;
|
||||
hintKey: string;
|
||||
hintDefault: string;
|
||||
defaultValue: boolean;
|
||||
roles: ('manager' | 'staff')[];
|
||||
}
|
||||
|
||||
// Define all available permissions in one place
|
||||
export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
// Manager-only permissions
|
||||
{
|
||||
key: 'can_invite_staff',
|
||||
labelKey: 'staff.canInviteStaff',
|
||||
labelDefault: 'Can invite new staff members',
|
||||
hintKey: 'staff.canInviteStaffHint',
|
||||
hintDefault: 'Allow this manager to send invitations to new staff members',
|
||||
defaultValue: false,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_manage_resources',
|
||||
labelKey: 'staff.canManageResources',
|
||||
labelDefault: 'Can manage resources',
|
||||
hintKey: 'staff.canManageResourcesHint',
|
||||
hintDefault: 'Create, edit, and delete bookable resources',
|
||||
defaultValue: true,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_manage_services',
|
||||
labelKey: 'staff.canManageServices',
|
||||
labelDefault: 'Can manage services',
|
||||
hintKey: 'staff.canManageServicesHint',
|
||||
hintDefault: 'Create, edit, and delete service offerings',
|
||||
defaultValue: true,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_view_reports',
|
||||
labelKey: 'staff.canViewReports',
|
||||
labelDefault: 'Can view reports',
|
||||
hintKey: 'staff.canViewReportsHint',
|
||||
hintDefault: 'Access business analytics and financial reports',
|
||||
defaultValue: true,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_access_settings',
|
||||
labelKey: 'staff.canAccessSettings',
|
||||
labelDefault: 'Can access business settings',
|
||||
hintKey: 'staff.canAccessSettingsHint',
|
||||
hintDefault: 'Modify business profile, branding, and configuration',
|
||||
defaultValue: false,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_refund_payments',
|
||||
labelKey: 'staff.canRefundPayments',
|
||||
labelDefault: 'Can refund payments',
|
||||
hintKey: 'staff.canRefundPaymentsHint',
|
||||
hintDefault: 'Process refunds for customer payments',
|
||||
defaultValue: false,
|
||||
roles: ['manager'],
|
||||
},
|
||||
// Staff-only permissions
|
||||
{
|
||||
key: 'can_view_all_schedules',
|
||||
labelKey: 'staff.canViewAllSchedules',
|
||||
labelDefault: 'Can view all schedules',
|
||||
hintKey: 'staff.canViewAllSchedulesHint',
|
||||
hintDefault: 'View schedules of other staff members (otherwise only their own)',
|
||||
defaultValue: false,
|
||||
roles: ['staff'],
|
||||
},
|
||||
{
|
||||
key: 'can_manage_own_appointments',
|
||||
labelKey: 'staff.canManageOwnAppointments',
|
||||
labelDefault: 'Can manage own appointments',
|
||||
hintKey: 'staff.canManageOwnAppointmentsHint',
|
||||
hintDefault: 'Create, reschedule, and cancel their own appointments',
|
||||
defaultValue: true,
|
||||
roles: ['staff'],
|
||||
},
|
||||
// Shared permissions (both manager and staff)
|
||||
{
|
||||
key: 'can_access_tickets',
|
||||
labelKey: 'staff.canAccessTickets',
|
||||
labelDefault: 'Can access support tickets',
|
||||
hintKey: 'staff.canAccessTicketsHint',
|
||||
hintDefault: 'View and manage customer support tickets',
|
||||
defaultValue: true, // Default for managers; staff will override to false
|
||||
roles: ['manager', 'staff'],
|
||||
},
|
||||
];
|
||||
|
||||
// Get default permissions for a role
|
||||
export const getDefaultPermissions = (role: 'manager' | 'staff'): Record<string, boolean> => {
|
||||
const defaults: Record<string, boolean> = {};
|
||||
PERMISSION_CONFIGS.forEach((config) => {
|
||||
if (config.roles.includes(role)) {
|
||||
// Staff members have ticket access disabled by default
|
||||
if (role === 'staff' && config.key === 'can_access_tickets') {
|
||||
defaults[config.key] = false;
|
||||
} else {
|
||||
defaults[config.key] = config.defaultValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
return defaults;
|
||||
};
|
||||
|
||||
interface StaffPermissionsProps {
|
||||
role: 'manager' | 'staff';
|
||||
permissions: Record<string, boolean>;
|
||||
onChange: (permissions: Record<string, boolean>) => void;
|
||||
variant?: 'invite' | 'edit';
|
||||
}
|
||||
|
||||
const StaffPermissions: React.FC<StaffPermissionsProps> = ({
|
||||
role,
|
||||
permissions,
|
||||
onChange,
|
||||
variant = 'edit',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Filter permissions for this role
|
||||
const rolePermissions = PERMISSION_CONFIGS.filter((config) =>
|
||||
config.roles.includes(role)
|
||||
);
|
||||
|
||||
const handleToggle = (key: string, checked: boolean) => {
|
||||
onChange({ ...permissions, [key]: checked });
|
||||
};
|
||||
|
||||
// Get the current value, falling back to default
|
||||
const getValue = (config: PermissionConfig): boolean => {
|
||||
if (permissions[config.key] !== undefined) {
|
||||
return permissions[config.key];
|
||||
}
|
||||
// Staff have ticket access disabled by default
|
||||
if (role === 'staff' && config.key === 'can_access_tickets') {
|
||||
return false;
|
||||
}
|
||||
return config.defaultValue;
|
||||
};
|
||||
|
||||
// Different styling for manager vs staff permissions
|
||||
const isManagerPermission = (config: PermissionConfig) =>
|
||||
config.roles.includes('manager') && !config.roles.includes('staff');
|
||||
|
||||
const getPermissionStyle = (config: PermissionConfig) => {
|
||||
if (isManagerPermission(config) || role === 'manager') {
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30';
|
||||
}
|
||||
return 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700';
|
||||
};
|
||||
|
||||
if (rolePermissions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{role === 'manager'
|
||||
? t('staff.managerPermissions', 'Manager Permissions')
|
||||
: t('staff.staffPermissions', 'Staff Permissions')}
|
||||
</h4>
|
||||
|
||||
{rolePermissions.map((config) => (
|
||||
<label
|
||||
key={config.key}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${getPermissionStyle(config)}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={getValue(config)}
|
||||
onChange={(e) => handleToggle(config.key, e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t(config.labelKey, config.labelDefault)}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t(config.hintKey, config.hintDefault)}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffPermissions;
|
||||
@@ -5,6 +5,7 @@ import { Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory, Ti
|
||||
import { useCreateTicket, useUpdateTicket, useTicketComments, useCreateTicketComment } from '../hooks/useTickets';
|
||||
import { useStaffForAssignment } from '../hooks/useUsers';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSandbox } from '../contexts/SandboxContext';
|
||||
|
||||
interface TicketModalProps {
|
||||
ticket?: Ticket | null; // If provided, it's an edit/detail view
|
||||
@@ -23,6 +24,7 @@ const CATEGORY_OPTIONS: Record<TicketType, TicketCategory[]> = {
|
||||
const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicketType = 'CUSTOMER' }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { isSandbox } = useSandbox();
|
||||
const [subject, setSubject] = useState(ticket?.subject || '');
|
||||
const [description, setDescription] = useState(ticket?.description || '');
|
||||
const [priority, setPriority] = useState<TicketPriority>(ticket?.priority || 'MEDIUM');
|
||||
@@ -30,8 +32,11 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
|
||||
const [ticketType, setTicketType] = useState<TicketType>(ticket?.ticketType || defaultTicketType);
|
||||
const [assigneeId, setAssigneeId] = useState<string | undefined>(ticket?.assignee);
|
||||
const [status, setStatus] = useState<TicketStatus>(ticket?.status || 'OPEN');
|
||||
const [newCommentText, setNewCommentText] = useState('');
|
||||
const [isInternalComment, setIsInternalComment] = useState(false);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [internalNoteText, setInternalNoteText] = useState('');
|
||||
|
||||
// Check if this is a platform ticket in sandbox mode (should be disabled)
|
||||
const isPlatformTicketInSandbox = ticketType === 'PLATFORM' && isSandbox;
|
||||
|
||||
// Fetch users for assignee dropdown
|
||||
const { data: users = [] } = useStaffForAssignment();
|
||||
@@ -96,20 +101,31 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAddComment = async (e: React.FormEvent) => {
|
||||
const handleAddReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ticket?.id || !newCommentText.trim()) return;
|
||||
if (!ticket?.id || !replyText.trim()) return;
|
||||
|
||||
const commentData: Partial<TicketComment> = {
|
||||
commentText: newCommentText.trim(),
|
||||
isInternal: isInternalComment,
|
||||
// author and ticket are handled by the backend
|
||||
commentText: replyText.trim(),
|
||||
isInternal: false,
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync({ ticketId: ticket.id, commentData });
|
||||
setNewCommentText('');
|
||||
setIsInternalComment(false);
|
||||
// Invalidate comments query to refetch new comment
|
||||
setReplyText('');
|
||||
queryClient.invalidateQueries({ queryKey: ['ticketComments', ticket.id] });
|
||||
};
|
||||
|
||||
const handleAddInternalNote = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ticket?.id || !internalNoteText.trim()) return;
|
||||
|
||||
const commentData: Partial<TicketComment> = {
|
||||
commentText: internalNoteText.trim(),
|
||||
isInternal: true,
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync({ ticketId: ticket.id, commentData });
|
||||
setInternalNoteText('');
|
||||
queryClient.invalidateQueries({ queryKey: ['ticketComments', ticket.id] });
|
||||
};
|
||||
|
||||
@@ -130,6 +146,23 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sandbox Warning for Platform Tickets */}
|
||||
{isPlatformTicketInSandbox && (
|
||||
<div className="mx-6 mt-4 p-4 bg-red-50 dark:bg-red-900/20 border-2 border-red-500 dark:border-red-600 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-red-800 dark:text-red-200">
|
||||
{t('tickets.sandboxRestriction', 'Platform Support Unavailable in Test Mode')}
|
||||
</h4>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{t('tickets.sandboxRestrictionMessage', 'You can only contact SmoothSchedule support in live mode. Please switch to live mode to create a support ticket.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form / Details */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<form onSubmit={handleSubmitTicket} className="space-y-4">
|
||||
@@ -143,9 +176,9 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
required
|
||||
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending} // Disable if viewing existing and not actively editing
|
||||
disabled={isPlatformTicketInSandbox || (!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending)} // Disable in sandbox or if viewing existing
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -159,14 +192,14 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
required
|
||||
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending} // Disable if viewing existing and not actively editing
|
||||
disabled={isPlatformTicketInSandbox || (!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending)} // Disable in sandbox or if viewing existing
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ticket Type (only for new tickets) */}
|
||||
{!ticket && (
|
||||
{/* Ticket Type (only for new tickets, and hide for platform tickets) */}
|
||||
{!ticket && ticketType !== 'PLATFORM' && (
|
||||
<div>
|
||||
<label htmlFor="ticketType" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.ticketType')}
|
||||
@@ -184,44 +217,46 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority & Category */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.priority')}
|
||||
</label>
|
||||
<select
|
||||
id="priority"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TicketPriority)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
|
||||
>
|
||||
{priorityOptions.map(opt => (
|
||||
<option key={opt} value={opt}>{t(`tickets.priorities.${opt.toLowerCase()}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Priority & Category - Hide for platform tickets when viewing/creating */}
|
||||
{ticketType !== 'PLATFORM' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.priority')}
|
||||
</label>
|
||||
<select
|
||||
id="priority"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TicketPriority)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
|
||||
>
|
||||
{priorityOptions.map(opt => (
|
||||
<option key={opt} value={opt}>{t(`tickets.priorities.${opt.toLowerCase()}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.category')}
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as TicketCategory)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
|
||||
>
|
||||
{availableCategories.map(cat => (
|
||||
<option key={cat} value={cat}>{t(`tickets.categories.${cat.toLowerCase()}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.category')}
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as TicketCategory)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
disabled={!!ticket && !createTicketMutation.isPending && !updateTicketMutation.isPending}
|
||||
>
|
||||
{availableCategories.map(cat => (
|
||||
<option key={cat} value={cat}>{t(`tickets.categories.${cat.toLowerCase()}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignee & Status (only visible for existing tickets or if user has permission to assign) */}
|
||||
{ticket && (
|
||||
{/* Assignee & Status (only visible for existing non-PLATFORM tickets) */}
|
||||
{ticket && ticketType !== 'PLATFORM' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="assignee" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
@@ -260,16 +295,26 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
|
||||
{/* Submit Button for Ticket */}
|
||||
{!ticket && ( // Only show submit for new tickets
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
disabled={createTicketMutation.isPending}
|
||||
>
|
||||
{createTicketMutation.isPending ? t('common.saving') : t('tickets.createTicket')}
|
||||
</button>
|
||||
{isPlatformTicketInSandbox ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
disabled={createTicketMutation.isPending}
|
||||
>
|
||||
{createTicketMutation.isPending ? t('common.saving') : t('tickets.createTicket')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ticket && ( // Show update button for existing tickets
|
||||
{ticket && ticketType !== 'PLATFORM' && ( // Show update button for existing non-PLATFORM tickets
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -311,35 +356,56 @@ const TicketModal: React.FC<TicketModalProps> = ({ ticket, onClose, defaultTicke
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">{t('tickets.noComments')}</p>
|
||||
)}
|
||||
|
||||
{/* Add Comment Form */}
|
||||
<form onSubmit={handleAddComment} className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
{/* Reply Form */}
|
||||
<form onSubmit={handleAddReply} className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('tickets.replyLabel', 'Reply to Customer')}
|
||||
</label>
|
||||
<textarea
|
||||
value={newCommentText}
|
||||
onChange={(e) => setNewCommentText(e.target.value)}
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder={t('tickets.addCommentPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isInternalComment}
|
||||
onChange={(e) => setIsInternalComment(e.target.checked)}
|
||||
className="form-checkbox h-4 w-4 text-brand-600 transition duration-150 ease-in-out rounded border-gray-300 focus:ring-brand-500 mr-2"
|
||||
/>
|
||||
{t('tickets.internalComment')}
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
disabled={createCommentMutation.isPending || !newCommentText.trim()}
|
||||
disabled={createCommentMutation.isPending || !replyText.trim()}
|
||||
>
|
||||
<Send size={16} /> {createCommentMutation.isPending ? t('common.sending') : t('tickets.postComment')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Internal Note Form - Only show for non-PLATFORM tickets */}
|
||||
{ticketType !== 'PLATFORM' && (
|
||||
<form onSubmit={handleAddInternalNote} className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<label className="block text-sm font-medium text-orange-600 dark:text-orange-400">
|
||||
{t('tickets.internalNoteLabel', 'Internal Note')}
|
||||
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
{t('tickets.internalNoteHint', '(Not visible to customer)')}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={internalNoteText}
|
||||
onChange={(e) => setInternalNoteText(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 rounded-lg border border-orange-300 dark:border-orange-600 bg-orange-50 dark:bg-orange-900/20 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder={t('tickets.internalNotePlaceholder', 'Add an internal note...')}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
disabled={createCommentMutation.isPending || !internalNoteText.trim()}
|
||||
>
|
||||
<Send size={16} /> {createCommentMutation.isPending ? t('common.sending') : t('tickets.addNote', 'Add Note')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, Search, Moon, Sun, Menu } from 'lucide-react';
|
||||
import { Search, Moon, Sun, Menu } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import UserProfileDropdown from './UserProfileDropdown';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
import NotificationDropdown from './NotificationDropdown';
|
||||
import SandboxToggle from './SandboxToggle';
|
||||
import { useSandbox } from '../contexts/SandboxContext';
|
||||
|
||||
interface TopBarProps {
|
||||
user: User;
|
||||
isDarkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
onMenuClick: () => void;
|
||||
onTicketClick?: (ticketId: string) => void;
|
||||
}
|
||||
|
||||
const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuClick }) => {
|
||||
const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuClick, onTicketClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { isSandbox, sandboxEnabled, toggleSandbox, isToggling } = useSandbox();
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between h-16 px-4 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 transition-colors duration-200 shrink-0">
|
||||
@@ -38,6 +43,14 @@ const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuCl
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Sandbox Mode Toggle */}
|
||||
<SandboxToggle
|
||||
isSandbox={isSandbox}
|
||||
sandboxEnabled={sandboxEnabled}
|
||||
onToggle={toggleSandbox}
|
||||
isToggling={isToggling}
|
||||
/>
|
||||
|
||||
<LanguageSelector />
|
||||
|
||||
<button
|
||||
@@ -47,10 +60,7 @@ const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuCl
|
||||
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
|
||||
<button className="relative p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
<NotificationDropdown onTicketClick={onTicketClick} />
|
||||
|
||||
<UserProfileDropdown user={user} />
|
||||
</div>
|
||||
|
||||
66
frontend/src/contexts/SandboxContext.tsx
Normal file
66
frontend/src/contexts/SandboxContext.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Sandbox Context
|
||||
* Provides sandbox mode state and toggle functionality throughout the app
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useEffect, ReactNode } from 'react';
|
||||
import { useSandboxStatus, useToggleSandbox } from '../hooks/useSandbox';
|
||||
|
||||
interface SandboxContextType {
|
||||
/** Whether the app is currently in sandbox/test mode */
|
||||
isSandbox: boolean;
|
||||
/** Whether sandbox mode is available for this business */
|
||||
sandboxEnabled: boolean;
|
||||
/** Whether the sandbox status is loading */
|
||||
isLoading: boolean;
|
||||
/** Toggle between live and sandbox mode */
|
||||
toggleSandbox: (enableSandbox: boolean) => Promise<void>;
|
||||
/** Whether a toggle operation is in progress */
|
||||
isToggling: boolean;
|
||||
}
|
||||
|
||||
const SandboxContext = createContext<SandboxContextType | undefined>(undefined);
|
||||
|
||||
interface SandboxProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SandboxProvider: React.FC<SandboxProviderProps> = ({ children }) => {
|
||||
const { data: status, isLoading } = useSandboxStatus();
|
||||
const toggleMutation = useToggleSandbox();
|
||||
|
||||
const toggleSandbox = async (enableSandbox: boolean) => {
|
||||
await toggleMutation.mutateAsync(enableSandbox);
|
||||
};
|
||||
|
||||
// Store sandbox mode in localStorage for persistence across tabs
|
||||
useEffect(() => {
|
||||
if (status?.sandbox_mode !== undefined) {
|
||||
localStorage.setItem('sandbox_mode', String(status.sandbox_mode));
|
||||
}
|
||||
}, [status?.sandbox_mode]);
|
||||
|
||||
const value: SandboxContextType = {
|
||||
isSandbox: status?.sandbox_mode ?? false,
|
||||
sandboxEnabled: status?.sandbox_enabled ?? false,
|
||||
isLoading,
|
||||
toggleSandbox,
|
||||
isToggling: toggleMutation.isPending,
|
||||
};
|
||||
|
||||
return (
|
||||
<SandboxContext.Provider value={value}>
|
||||
{children}
|
||||
</SandboxContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSandbox = (): SandboxContextType => {
|
||||
const context = useContext(SandboxContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSandbox must be used within a SandboxProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default SandboxContext;
|
||||
150
frontend/src/hooks/useApiTokens.ts
Normal file
150
frontend/src/hooks/useApiTokens.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
// Types
|
||||
export interface APIToken {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
scopes: string[];
|
||||
is_active: boolean;
|
||||
is_sandbox: boolean;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
created_by: {
|
||||
id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TestTokenForDocs {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface APITokenCreateResponse extends APIToken {
|
||||
key: string; // Full key, only returned on creation
|
||||
}
|
||||
|
||||
export interface CreateTokenData {
|
||||
name: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
is_sandbox?: boolean;
|
||||
}
|
||||
|
||||
export interface APIScope {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Available scopes
|
||||
export const API_SCOPES: APIScope[] = [
|
||||
{ value: 'services:read', label: 'Services (Read)', description: 'View services and pricing' },
|
||||
{ value: 'resources:read', label: 'Resources (Read)', description: 'View resources and staff' },
|
||||
{ value: 'availability:read', label: 'Availability (Read)', description: 'Check time slot availability' },
|
||||
{ value: 'bookings:read', label: 'Bookings (Read)', description: 'View appointments' },
|
||||
{ value: 'bookings:write', label: 'Bookings (Write)', description: 'Create, update, and cancel appointments' },
|
||||
{ value: 'customers:read', label: 'Customers (Read)', description: 'View customer information' },
|
||||
{ value: 'customers:write', label: 'Customers (Write)', description: 'Create and update customers' },
|
||||
{ value: 'business:read', label: 'Business (Read)', description: 'View business information' },
|
||||
{ value: 'webhooks:manage', label: 'Webhooks (Manage)', description: 'Manage webhook subscriptions' },
|
||||
];
|
||||
|
||||
// Scope presets for common use cases
|
||||
export const SCOPE_PRESETS = {
|
||||
booking_widget: {
|
||||
label: 'Booking Widget',
|
||||
description: 'Allow customers to book appointments',
|
||||
scopes: ['services:read', 'resources:read', 'availability:read', 'bookings:write', 'customers:write'],
|
||||
},
|
||||
read_only: {
|
||||
label: 'Read Only',
|
||||
description: 'View all data without making changes',
|
||||
scopes: ['services:read', 'resources:read', 'availability:read', 'bookings:read', 'customers:read', 'business:read'],
|
||||
},
|
||||
full_access: {
|
||||
label: 'Full Access',
|
||||
description: 'Complete access to all API features',
|
||||
scopes: API_SCOPES.map(s => s.value),
|
||||
},
|
||||
};
|
||||
|
||||
// API Functions
|
||||
const fetchApiTokens = async (): Promise<APIToken[]> => {
|
||||
const response = await apiClient.get('/api/v1/tokens/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const createApiToken = async (data: CreateTokenData): Promise<APITokenCreateResponse> => {
|
||||
const response = await apiClient.post('/api/v1/tokens/', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const revokeApiToken = async (tokenId: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/tokens/${tokenId}/`);
|
||||
};
|
||||
|
||||
const updateApiToken = async ({ tokenId, data }: { tokenId: string; data: Partial<CreateTokenData> & { is_active?: boolean } }): Promise<APIToken> => {
|
||||
const response = await apiClient.patch(`/api/v1/tokens/${tokenId}/`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const fetchTestTokensForDocs = async (): Promise<TestTokenForDocs[]> => {
|
||||
const response = await apiClient.get('/api/v1/tokens/test-tokens/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export const useApiTokens = () => {
|
||||
return useQuery({
|
||||
queryKey: ['apiTokens'],
|
||||
queryFn: fetchApiTokens,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateApiToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createApiToken,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiTokens'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRevokeApiToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: revokeApiToken,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiTokens'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateApiToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateApiToken,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiTokens'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTestTokensForDocs = () => {
|
||||
return useQuery({
|
||||
queryKey: ['testTokensForDocs'],
|
||||
queryFn: fetchTestTokensForDocs,
|
||||
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
|
||||
});
|
||||
};
|
||||
76
frontend/src/hooks/useNotifications.ts
Normal file
76
frontend/src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
clearAllNotifications,
|
||||
Notification,
|
||||
} from '../api/notifications';
|
||||
|
||||
/**
|
||||
* Hook to fetch all notifications
|
||||
*/
|
||||
export const useNotifications = (options?: { read?: boolean; limit?: number }) => {
|
||||
return useQuery<Notification[]>({
|
||||
queryKey: ['notifications', options],
|
||||
queryFn: () => getNotifications(options),
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch unread notification count
|
||||
*/
|
||||
export const useUnreadNotificationCount = () => {
|
||||
return useQuery<number>({
|
||||
queryKey: ['notificationsUnreadCount'],
|
||||
queryFn: getUnreadCount,
|
||||
staleTime: 30000, // 30 seconds
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to mark a notification as read
|
||||
*/
|
||||
export const useMarkNotificationRead = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: markNotificationRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationsUnreadCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to mark all notifications as read
|
||||
*/
|
||||
export const useMarkAllNotificationsRead = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: markAllNotificationsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationsUnreadCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to clear all read notifications
|
||||
*/
|
||||
export const useClearAllNotifications = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: clearAllNotifications,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
62
frontend/src/hooks/useSandbox.ts
Normal file
62
frontend/src/hooks/useSandbox.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* React Query hooks for sandbox mode management
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getSandboxStatus, toggleSandboxMode, resetSandboxData, SandboxStatus } from '../api/sandbox';
|
||||
|
||||
/**
|
||||
* Hook to fetch current sandbox status
|
||||
*/
|
||||
export const useSandboxStatus = () => {
|
||||
return useQuery<SandboxStatus, Error>({
|
||||
queryKey: ['sandboxStatus'],
|
||||
queryFn: getSandboxStatus,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to toggle sandbox mode
|
||||
*/
|
||||
export const useToggleSandbox = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: toggleSandboxMode,
|
||||
onSuccess: (data) => {
|
||||
// Update the sandbox status in cache
|
||||
queryClient.setQueryData(['sandboxStatus'], (old: SandboxStatus | undefined) => ({
|
||||
...old,
|
||||
sandbox_mode: data.sandbox_mode,
|
||||
}));
|
||||
|
||||
// Reload the page to ensure all components properly reflect the new mode
|
||||
// This is necessary because:
|
||||
// 1. Backend switches database schemas between live/sandbox
|
||||
// 2. Some UI elements need to reflect the new mode (e.g., warnings, disabled features)
|
||||
// 3. Prevents stale data from old mode appearing briefly
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reset sandbox data
|
||||
*/
|
||||
export const useResetSandbox = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: resetSandboxData,
|
||||
onSuccess: () => {
|
||||
// Invalidate all data queries after reset
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['services'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -5,16 +5,12 @@ import { User } from '../types';
|
||||
interface StaffUser {
|
||||
id: number | string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
full_name: string;
|
||||
name: string; // This is the full_name from the serializer
|
||||
username?: string;
|
||||
role: string;
|
||||
role_display: string;
|
||||
is_active: boolean;
|
||||
permissions: Record<string, boolean>;
|
||||
has_resource: boolean;
|
||||
resource_id?: string;
|
||||
resource_name?: string;
|
||||
can_invite_staff?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +38,9 @@ export const useStaffForAssignment = () => {
|
||||
const response = await apiClient.get('/api/staff/');
|
||||
return response.data.map((user: StaffUser) => ({
|
||||
id: String(user.id),
|
||||
name: user.full_name || `${user.first_name} ${user.last_name}`.trim() || user.email,
|
||||
name: user.name || user.email, // 'name' field from serializer (full_name)
|
||||
email: user.email,
|
||||
role: user.role_display || user.role,
|
||||
role: user.role,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,18 +12,12 @@ import en from './locales/en.json';
|
||||
import es from './locales/es.json';
|
||||
import fr from './locales/fr.json';
|
||||
import de from './locales/de.json';
|
||||
import pt from './locales/pt.json';
|
||||
import ja from './locales/ja.json';
|
||||
import zh from './locales/zh.json';
|
||||
|
||||
export const supportedLanguages = [
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'pt', name: 'Português', flag: '🇧🇷' },
|
||||
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
|
||||
{ code: 'zh', name: '中文', flag: '🇨🇳' },
|
||||
] as const;
|
||||
|
||||
export type SupportedLanguage = typeof supportedLanguages[number]['code'];
|
||||
@@ -33,9 +27,6 @@ const resources = {
|
||||
es: { translation: es },
|
||||
fr: { translation: fr },
|
||||
de: { translation: de },
|
||||
pt: { translation: pt },
|
||||
ja: { translation: ja },
|
||||
zh: { translation: zh },
|
||||
};
|
||||
|
||||
i18n
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"scheduler": "Terminplaner",
|
||||
"customers": "Kunden",
|
||||
"resources": "Ressourcen",
|
||||
"services": "Dienstleistungen",
|
||||
"payments": "Zahlungen",
|
||||
"messages": "Nachrichten",
|
||||
"staff": "Personal",
|
||||
@@ -61,7 +62,124 @@
|
||||
"businesses": "Unternehmen",
|
||||
"users": "Benutzer",
|
||||
"support": "Support",
|
||||
"platformSettings": "Plattform-Einstellungen"
|
||||
"platformSettings": "Plattform-Einstellungen",
|
||||
"tickets": "Tickets",
|
||||
"help": "Hilfe",
|
||||
"platformGuide": "Plattform-Handbuch",
|
||||
"ticketingHelp": "Ticket-System",
|
||||
"apiDocs": "API-Dokumentation"
|
||||
},
|
||||
"help": {
|
||||
"guide": {
|
||||
"title": "Plattform-Handbuch",
|
||||
"subtitle": "Lernen Sie, wie Sie SmoothSchedule effektiv nutzen",
|
||||
"comingSoon": "Demnächst Verfügbar",
|
||||
"comingSoonDesc": "Wir arbeiten an umfassender Dokumentation, um Ihnen zu helfen, das Beste aus SmoothSchedule herauszuholen. Schauen Sie bald wieder vorbei!"
|
||||
},
|
||||
"api": {
|
||||
"title": "API-Referenz",
|
||||
"interactiveExplorer": "Interaktiver Explorer",
|
||||
"introduction": "Einführung",
|
||||
"introDescription": "Die SmoothSchedule-API ist nach REST organisiert. Unsere API hat vorhersehbare ressourcenorientierte URLs, akzeptiert JSON-kodierte Anfragekörper, gibt JSON-kodierte Antworten zurück und verwendet standardmäßige HTTP-Antwortcodes.",
|
||||
"introTestMode": "Sie können die SmoothSchedule-API im Testmodus verwenden, der Ihre Live-Daten nicht beeinflusst. Der verwendete API-Schlüssel bestimmt, ob die Anfrage im Test- oder Live-Modus ist.",
|
||||
"baseUrl": "Basis-URL",
|
||||
"baseUrlDescription": "Alle API-Anfragen sollten an folgende Adresse gesendet werden:",
|
||||
"sandboxMode": "Sandbox-Modus:",
|
||||
"sandboxModeDescription": "Verwenden Sie die Sandbox-URL für Entwicklung und Tests. Alle Beispiele in dieser Dokumentation verwenden Test-API-Schlüssel, die mit der Sandbox funktionieren.",
|
||||
"authentication": "Authentifizierung",
|
||||
"authDescription": "Die SmoothSchedule-API verwendet API-Schlüssel zur Authentifizierung von Anfragen. Sie können Ihre API-Schlüssel in Ihren Geschäftseinstellungen anzeigen und verwalten.",
|
||||
"authBearer": "Die Authentifizierung bei der API erfolgt über Bearer-Token. Fügen Sie Ihren API-Schlüssel im Authorization-Header aller Anfragen ein.",
|
||||
"authWarning": "Ihre API-Schlüssel haben viele Berechtigungen, stellen Sie also sicher, dass Sie sie sicher aufbewahren. Teilen Sie Ihre geheimen API-Schlüssel nicht in öffentlich zugänglichen Bereichen wie GitHub, clientseitigem Code usw.",
|
||||
"apiKeyFormat": "API-Schlüssel-Format",
|
||||
"testKey": "Test-/Sandbox-Modus-Schlüssel",
|
||||
"liveKey": "Live-/Produktions-Modus-Schlüssel",
|
||||
"authenticatedRequest": "Authentifizierte Anfrage",
|
||||
"keepKeysSecret": "Halten Sie Ihre Schlüssel geheim!",
|
||||
"keepKeysSecretDescription": "Geben Sie API-Schlüssel niemals in clientseitigem Code, Versionskontrolle oder öffentlichen Foren preis.",
|
||||
"errors": "Fehler",
|
||||
"errorsDescription": "SmoothSchedule verwendet konventionelle HTTP-Antwortcodes, um Erfolg oder Misserfolg einer API-Anfrage anzuzeigen.",
|
||||
"httpStatusCodes": "HTTP-Statuscodes",
|
||||
"errorResponse": "Fehlerantwort",
|
||||
"statusOk": "Die Anfrage war erfolgreich.",
|
||||
"statusCreated": "Eine neue Ressource wurde erstellt.",
|
||||
"statusBadRequest": "Ungültige Anfrageparameter.",
|
||||
"statusUnauthorized": "Ungültiger oder fehlender API-Schlüssel.",
|
||||
"statusForbidden": "Der API-Schlüssel hat nicht die erforderlichen Berechtigungen.",
|
||||
"statusNotFound": "Die angeforderte Ressource existiert nicht.",
|
||||
"statusConflict": "Ressourcenkonflikt (z.B. Doppelbuchung).",
|
||||
"statusTooManyRequests": "Ratenlimit überschritten.",
|
||||
"statusServerError": "Auf unserer Seite ist etwas schief gelaufen.",
|
||||
"rateLimits": "Ratenlimits",
|
||||
"rateLimitsDescription": "Die API implementiert Ratenlimits, um faire Nutzung und Stabilität zu gewährleisten.",
|
||||
"limits": "Limits",
|
||||
"requestsPerHour": "Anfragen pro Stunde pro API-Schlüssel",
|
||||
"requestsPerMinute": "Anfragen pro Minute Burst-Limit",
|
||||
"rateLimitHeaders": "Ratenlimit-Header",
|
||||
"rateLimitHeadersDescription": "Jede Antwort enthält Header mit Ihrem aktuellen Ratenlimit-Status.",
|
||||
"business": "Unternehmen",
|
||||
"businessObject": "Das Business-Objekt",
|
||||
"businessObjectDescription": "Das Business-Objekt repräsentiert Ihre Geschäftskonfiguration und -einstellungen.",
|
||||
"attributes": "Attribute",
|
||||
"retrieveBusiness": "Unternehmen abrufen",
|
||||
"retrieveBusinessDescription": "Ruft das mit Ihrem API-Schlüssel verknüpfte Unternehmen ab.",
|
||||
"requiredScope": "Erforderlicher Bereich",
|
||||
"services": "Dienstleistungen",
|
||||
"serviceObject": "Das Service-Objekt",
|
||||
"serviceObjectDescription": "Dienstleistungen repräsentieren die Angebote, die Ihr Unternehmen bereitstellt und die Kunden buchen können.",
|
||||
"listServices": "Alle Dienstleistungen auflisten",
|
||||
"listServicesDescription": "Gibt eine Liste aller aktiven Dienstleistungen Ihres Unternehmens zurück.",
|
||||
"retrieveService": "Eine Dienstleistung abrufen",
|
||||
"resources": "Ressourcen",
|
||||
"resourceObject": "Das Resource-Objekt",
|
||||
"resourceObjectDescription": "Ressourcen sind die buchbaren Einheiten in Ihrem Unternehmen (Mitarbeiter, Räume, Ausrüstung).",
|
||||
"listResources": "Alle Ressourcen auflisten",
|
||||
"retrieveResource": "Eine Ressource abrufen",
|
||||
"availability": "Verfügbarkeit",
|
||||
"checkAvailability": "Verfügbarkeit prüfen",
|
||||
"checkAvailabilityDescription": "Gibt verfügbare Zeitfenster für einen bestimmten Service und Datumsbereich zurück.",
|
||||
"parameters": "Parameter",
|
||||
"appointments": "Termine",
|
||||
"appointmentObject": "Das Appointment-Objekt",
|
||||
"appointmentObjectDescription": "Termine repräsentieren geplante Buchungen zwischen Kunden und Ressourcen.",
|
||||
"createAppointment": "Einen Termin erstellen",
|
||||
"createAppointmentDescription": "Erstellt eine neue Terminbuchung.",
|
||||
"retrieveAppointment": "Einen Termin abrufen",
|
||||
"updateAppointment": "Einen Termin aktualisieren",
|
||||
"cancelAppointment": "Einen Termin stornieren",
|
||||
"listAppointments": "Alle Termine auflisten",
|
||||
"customers": "Kunden",
|
||||
"customerObject": "Das Customer-Objekt",
|
||||
"customerObjectDescription": "Kunden sind die Personen, die Termine bei Ihrem Unternehmen buchen.",
|
||||
"createCustomer": "Einen Kunden erstellen",
|
||||
"retrieveCustomer": "Einen Kunden abrufen",
|
||||
"updateCustomer": "Einen Kunden aktualisieren",
|
||||
"listCustomers": "Alle Kunden auflisten",
|
||||
"webhooks": "Webhooks",
|
||||
"webhookEvents": "Webhook-Ereignisse",
|
||||
"webhookEventsDescription": "Webhooks ermöglichen es Ihnen, Echtzeit-Benachrichtigungen zu erhalten, wenn Ereignisse in Ihrem Unternehmen auftreten.",
|
||||
"eventTypes": "Ereignistypen",
|
||||
"webhookPayload": "Webhook-Payload",
|
||||
"createWebhook": "Einen Webhook erstellen",
|
||||
"createWebhookDescription": "Erstellt ein neues Webhook-Abonnement. Die Antwort enthält ein Geheimnis, das Sie zur Verifizierung von Webhook-Signaturen verwenden.",
|
||||
"secretOnlyOnce": "Das Geheimnis wird nur einmal angezeigt",
|
||||
"secretOnlyOnceDescription": ", bewahren Sie es also sicher auf.",
|
||||
"listWebhooks": "Webhooks auflisten",
|
||||
"deleteWebhook": "Einen Webhook löschen",
|
||||
"verifySignatures": "Signaturen verifizieren",
|
||||
"verifySignaturesDescription": "Jede Webhook-Anfrage enthält eine Signatur im X-Webhook-Signature-Header. Sie sollten diese Signatur verifizieren, um sicherzustellen, dass die Anfrage von SmoothSchedule stammt.",
|
||||
"signatureFormat": "Signaturformat",
|
||||
"signatureFormatDescription": "Der Signatur-Header enthält zwei durch einen Punkt getrennte Werte: einen Zeitstempel und die HMAC-SHA256-Signatur.",
|
||||
"verificationSteps": "Verifizierungsschritte",
|
||||
"verificationStep1": "Zeitstempel und Signatur aus dem Header extrahieren",
|
||||
"verificationStep2": "Zeitstempel, einen Punkt und den rohen Anfragekörper verketten",
|
||||
"verificationStep3": "HMAC-SHA256 mit Ihrem Webhook-Geheimnis berechnen",
|
||||
"verificationStep4": "Die berechnete Signatur mit der empfangenen Signatur vergleichen",
|
||||
"saveYourSecret": "Bewahren Sie Ihr Geheimnis auf!",
|
||||
"saveYourSecretDescription": "Das Webhook-Geheimnis wird nur einmal zurückgegeben, wenn der Webhook erstellt wird. Bewahren Sie es sicher für die Signaturverifizierung auf.",
|
||||
"endpoint": "Endpunkt",
|
||||
"request": "Anfrage",
|
||||
"response": "Antwort"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
{
|
||||
"sandbox": {
|
||||
"live": "Live",
|
||||
"test": "Test",
|
||||
"liveMode": "Live Mode - Production data",
|
||||
"testMode": "Test Mode - Sandbox data",
|
||||
"bannerTitle": "TEST MODE",
|
||||
"bannerDescription": "You are viewing test data. Changes here won't affect your live business.",
|
||||
"switchToLive": "Switch to Live",
|
||||
"switching": "Switching...",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"openNotifications": "Open notifications",
|
||||
"noNotifications": "No notifications yet",
|
||||
"markAllRead": "Mark all as read",
|
||||
"clearRead": "Clear read",
|
||||
"viewAll": "View all",
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{{count}}m ago",
|
||||
"hoursAgo": "{{count}}h ago",
|
||||
"daysAgo": "{{count}}d ago"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
@@ -65,7 +88,124 @@
|
||||
"users": "Users",
|
||||
"support": "Support",
|
||||
"platformSettings": "Platform Settings",
|
||||
"tickets": "Tickets"
|
||||
"tickets": "Tickets",
|
||||
"help": "Help",
|
||||
"platformGuide": "Platform Guide",
|
||||
"ticketingHelp": "Ticketing System",
|
||||
"apiDocs": "API Docs",
|
||||
"contactSupport": "Contact Support"
|
||||
},
|
||||
"help": {
|
||||
"guide": {
|
||||
"title": "Platform Guide",
|
||||
"subtitle": "Learn how to use SmoothSchedule effectively",
|
||||
"comingSoon": "Coming Soon",
|
||||
"comingSoonDesc": "We are working on comprehensive documentation to help you get the most out of SmoothSchedule. Check back soon!"
|
||||
},
|
||||
"api": {
|
||||
"title": "API Reference",
|
||||
"interactiveExplorer": "Interactive Explorer",
|
||||
"introduction": "Introduction",
|
||||
"introDescription": "The SmoothSchedule API is organized around REST. Our API has predictable resource-oriented URLs, accepts JSON-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes.",
|
||||
"introTestMode": "You can use the SmoothSchedule API in test mode, which doesn't affect your live data. The API key you use determines whether the request is test mode or live mode.",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlDescription": "All API requests should be made to:",
|
||||
"sandboxMode": "Sandbox Mode:",
|
||||
"sandboxModeDescription": "Use the sandbox URL for development and testing. All examples in this documentation use test API keys that work with the sandbox.",
|
||||
"authentication": "Authentication",
|
||||
"authDescription": "The SmoothSchedule API uses API keys to authenticate requests. You can view and manage your API keys in your Business Settings.",
|
||||
"authBearer": "Authentication to the API is performed via Bearer token. Include your API key in the Authorization header of all requests.",
|
||||
"authWarning": "Your API keys carry many privileges, so be sure to keep them secure. Don't share your secret API keys in publicly accessible areas such as GitHub, client-side code, etc.",
|
||||
"apiKeyFormat": "API Key Format",
|
||||
"testKey": "Test/sandbox mode key",
|
||||
"liveKey": "Live/production mode key",
|
||||
"authenticatedRequest": "Authenticated Request",
|
||||
"keepKeysSecret": "Keep your keys secret!",
|
||||
"keepKeysSecretDescription": "Never expose API keys in client-side code, version control, or public forums.",
|
||||
"errors": "Errors",
|
||||
"errorsDescription": "SmoothSchedule uses conventional HTTP response codes to indicate the success or failure of an API request.",
|
||||
"httpStatusCodes": "HTTP Status Codes",
|
||||
"errorResponse": "Error Response",
|
||||
"statusOk": "The request succeeded.",
|
||||
"statusCreated": "A new resource was created.",
|
||||
"statusBadRequest": "Invalid request parameters.",
|
||||
"statusUnauthorized": "Invalid or missing API key.",
|
||||
"statusForbidden": "The API key lacks required permissions.",
|
||||
"statusNotFound": "The requested resource doesn't exist.",
|
||||
"statusConflict": "Resource conflict (e.g., double booking).",
|
||||
"statusTooManyRequests": "Rate limit exceeded.",
|
||||
"statusServerError": "Something went wrong on our end.",
|
||||
"rateLimits": "Rate Limits",
|
||||
"rateLimitsDescription": "The API implements rate limiting to ensure fair usage and stability.",
|
||||
"limits": "Limits",
|
||||
"requestsPerHour": "requests per hour per API key",
|
||||
"requestsPerMinute": "requests per minute burst limit",
|
||||
"rateLimitHeaders": "Rate Limit Headers",
|
||||
"rateLimitHeadersDescription": "Every response includes headers with your current rate limit status.",
|
||||
"business": "Business",
|
||||
"businessObject": "The Business object",
|
||||
"businessObjectDescription": "The Business object represents your business configuration and settings.",
|
||||
"attributes": "Attributes",
|
||||
"retrieveBusiness": "Retrieve business",
|
||||
"retrieveBusinessDescription": "Retrieves the business associated with your API key.",
|
||||
"requiredScope": "Required scope",
|
||||
"services": "Services",
|
||||
"serviceObject": "The Service object",
|
||||
"serviceObjectDescription": "Services represent the offerings your business provides that customers can book.",
|
||||
"listServices": "List all services",
|
||||
"listServicesDescription": "Returns a list of all active services for your business.",
|
||||
"retrieveService": "Retrieve a service",
|
||||
"resources": "Resources",
|
||||
"resourceObject": "The Resource object",
|
||||
"resourceObjectDescription": "Resources are the bookable entities in your business (staff members, rooms, equipment).",
|
||||
"listResources": "List all resources",
|
||||
"retrieveResource": "Retrieve a resource",
|
||||
"availability": "Availability",
|
||||
"checkAvailability": "Check availability",
|
||||
"checkAvailabilityDescription": "Returns available time slots for a given service and date range.",
|
||||
"parameters": "Parameters",
|
||||
"appointments": "Appointments",
|
||||
"appointmentObject": "The Appointment object",
|
||||
"appointmentObjectDescription": "Appointments represent scheduled bookings between customers and resources.",
|
||||
"createAppointment": "Create an appointment",
|
||||
"createAppointmentDescription": "Creates a new appointment booking.",
|
||||
"retrieveAppointment": "Retrieve an appointment",
|
||||
"updateAppointment": "Update an appointment",
|
||||
"cancelAppointment": "Cancel an appointment",
|
||||
"listAppointments": "List all appointments",
|
||||
"customers": "Customers",
|
||||
"customerObject": "The Customer object",
|
||||
"customerObjectDescription": "Customers are the people who book appointments with your business.",
|
||||
"createCustomer": "Create a customer",
|
||||
"retrieveCustomer": "Retrieve a customer",
|
||||
"updateCustomer": "Update a customer",
|
||||
"listCustomers": "List all customers",
|
||||
"webhooks": "Webhooks",
|
||||
"webhookEvents": "Webhook events",
|
||||
"webhookEventsDescription": "Webhooks allow you to receive real-time notifications when events occur in your business.",
|
||||
"eventTypes": "Event types",
|
||||
"webhookPayload": "Webhook Payload",
|
||||
"createWebhook": "Create a webhook",
|
||||
"createWebhookDescription": "Creates a new webhook subscription. The response includes a secret that you'll use to verify webhook signatures.",
|
||||
"secretOnlyOnce": "The secret is only shown once",
|
||||
"secretOnlyOnceDescription": ", so save it securely.",
|
||||
"listWebhooks": "List webhooks",
|
||||
"deleteWebhook": "Delete a webhook",
|
||||
"verifySignatures": "Verify signatures",
|
||||
"verifySignaturesDescription": "Every webhook request includes a signature in the X-Webhook-Signature header. You should verify this signature to ensure the request came from SmoothSchedule.",
|
||||
"signatureFormat": "Signature format",
|
||||
"signatureFormatDescription": "The signature header contains two values separated by a dot: a timestamp and the HMAC-SHA256 signature.",
|
||||
"verificationSteps": "Verification steps",
|
||||
"verificationStep1": "Extract the timestamp and signature from the header",
|
||||
"verificationStep2": "Concatenate the timestamp, a dot, and the raw request body",
|
||||
"verificationStep3": "Compute HMAC-SHA256 using your webhook secret",
|
||||
"verificationStep4": "Compare the computed signature with the received signature",
|
||||
"saveYourSecret": "Save your secret!",
|
||||
"saveYourSecretDescription": "The webhook secret is only returned once when the webhook is created. Store it securely for signature verification.",
|
||||
"endpoint": "Endpoint",
|
||||
"request": "Request",
|
||||
"response": "Response"
|
||||
}
|
||||
},
|
||||
"staff": {
|
||||
"title": "Staff & Management",
|
||||
@@ -102,12 +242,16 @@
|
||||
"ticketDetails": "Ticket Details",
|
||||
"createTicket": "Create Ticket",
|
||||
"updateTicket": "Update Ticket",
|
||||
"comments": "Comments",
|
||||
"noComments": "No comments yet.",
|
||||
"internal": "Internal",
|
||||
"addCommentPlaceholder": "Add a comment...",
|
||||
"internalComment": "Internal Comment",
|
||||
"postComment": "Post Comment",
|
||||
"comments": "Replies",
|
||||
"noComments": "No replies yet.",
|
||||
"internal": "Internal Note",
|
||||
"addCommentPlaceholder": "Write a reply...",
|
||||
"postComment": "Send Reply",
|
||||
"replyLabel": "Reply to Customer",
|
||||
"internalNoteLabel": "Internal Note",
|
||||
"internalNoteHint": "(Not visible to customer)",
|
||||
"internalNotePlaceholder": "Add an internal note...",
|
||||
"addNote": "Add Note",
|
||||
"tabs": {
|
||||
"all": "All",
|
||||
"open": "Open",
|
||||
@@ -148,7 +292,73 @@
|
||||
"schedule_change": "Schedule Change",
|
||||
"equipment": "Equipment Issue",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"sandboxRestriction": "Platform Support Unavailable in Test Mode",
|
||||
"sandboxRestrictionMessage": "You can only contact SmoothSchedule support in live mode. Please switch to live mode to create a support ticket.",
|
||||
"status": {
|
||||
"open": "Open",
|
||||
"in_progress": "In Progress",
|
||||
"awaiting_response": "Awaiting Response",
|
||||
"resolved": "Resolved",
|
||||
"closed": "Closed"
|
||||
},
|
||||
"ticketNumber": "Ticket #{{number}}",
|
||||
"createdAt": "Created {{date}}"
|
||||
},
|
||||
"customerSupport": {
|
||||
"title": "Support",
|
||||
"subtitle": "Get help with your appointments and account",
|
||||
"newRequest": "New Request",
|
||||
"submitRequest": "Submit Request",
|
||||
"quickHelp": "Quick Help",
|
||||
"contactUs": "Contact Us",
|
||||
"contactUsDesc": "Submit a support request",
|
||||
"emailUs": "Email Us",
|
||||
"emailUsDesc": "Get help via email",
|
||||
"myRequests": "My Support Requests",
|
||||
"noRequests": "You haven't submitted any support requests yet.",
|
||||
"submitFirst": "Submit your first request",
|
||||
"subjectPlaceholder": "Brief summary of your issue",
|
||||
"descriptionPlaceholder": "Please describe your issue in detail...",
|
||||
"statusOpen": "Your request has been received. Our team will review it shortly.",
|
||||
"statusInProgress": "Our team is currently working on your request.",
|
||||
"statusAwaitingResponse": "We need additional information from you. Please reply below.",
|
||||
"statusResolved": "Your request has been resolved. Thank you for contacting us!",
|
||||
"statusClosed": "This ticket has been closed.",
|
||||
"conversation": "Conversation",
|
||||
"noRepliesYet": "No replies yet. Our team will respond soon.",
|
||||
"yourReply": "Your Reply",
|
||||
"replyPlaceholder": "Type your message here...",
|
||||
"sendReply": "Send Reply",
|
||||
"ticketClosedNoReply": "This ticket is closed. If you need further assistance, please open a new support request."
|
||||
},
|
||||
"platformSupport": {
|
||||
"title": "SmoothSchedule Support",
|
||||
"subtitle": "Get help from the SmoothSchedule team",
|
||||
"newRequest": "Contact Support",
|
||||
"quickHelp": "Quick Help",
|
||||
"platformGuide": "Platform Guide",
|
||||
"platformGuideDesc": "Learn the basics",
|
||||
"apiDocs": "API Docs",
|
||||
"apiDocsDesc": "Integration help",
|
||||
"contactUs": "Contact Support",
|
||||
"contactUsDesc": "Get personalized help",
|
||||
"myRequests": "My Support Requests",
|
||||
"noRequests": "You haven't submitted any support requests yet.",
|
||||
"submitFirst": "Submit your first request",
|
||||
"sandboxWarning": "You are in Test Mode",
|
||||
"sandboxWarningMessage": "Platform support is only available in Live Mode. Switch to Live Mode to contact SmoothSchedule support.",
|
||||
"statusOpen": "Your request has been received. Our support team will review it shortly.",
|
||||
"statusInProgress": "Our support team is currently working on your request.",
|
||||
"statusAwaitingResponse": "We need additional information from you. Please reply below.",
|
||||
"statusResolved": "Your request has been resolved. Thank you for contacting SmoothSchedule support!",
|
||||
"statusClosed": "This ticket has been closed.",
|
||||
"conversation": "Conversation",
|
||||
"noRepliesYet": "No replies yet. Our support team will respond soon.",
|
||||
"yourReply": "Your Reply",
|
||||
"replyPlaceholder": "Type your message here...",
|
||||
"sendReply": "Send Reply",
|
||||
"ticketClosedNoReply": "This ticket is closed. If you need further assistance, please open a new support request."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"scheduler": "Agenda",
|
||||
"customers": "Clientes",
|
||||
"resources": "Recursos",
|
||||
"services": "Servicios",
|
||||
"payments": "Pagos",
|
||||
"messages": "Mensajes",
|
||||
"staff": "Personal",
|
||||
@@ -61,7 +62,124 @@
|
||||
"businesses": "Negocios",
|
||||
"users": "Usuarios",
|
||||
"support": "Soporte",
|
||||
"platformSettings": "Configuración de Plataforma"
|
||||
"platformSettings": "Configuración de Plataforma",
|
||||
"tickets": "Tickets",
|
||||
"help": "Ayuda",
|
||||
"platformGuide": "Guía de Plataforma",
|
||||
"ticketingHelp": "Sistema de Tickets",
|
||||
"apiDocs": "Documentación API"
|
||||
},
|
||||
"help": {
|
||||
"guide": {
|
||||
"title": "Guía de Plataforma",
|
||||
"subtitle": "Aprende a usar SmoothSchedule de manera efectiva",
|
||||
"comingSoon": "Próximamente",
|
||||
"comingSoonDesc": "Estamos trabajando en documentación completa para ayudarte a aprovechar al máximo SmoothSchedule. ¡Vuelve pronto!"
|
||||
},
|
||||
"api": {
|
||||
"title": "Referencia de API",
|
||||
"interactiveExplorer": "Explorador Interactivo",
|
||||
"introduction": "Introducción",
|
||||
"introDescription": "La API de SmoothSchedule está organizada según REST. Nuestra API tiene URLs predecibles orientadas a recursos, acepta cuerpos de solicitud codificados en JSON, devuelve respuestas codificadas en JSON y utiliza códigos de respuesta HTTP estándar.",
|
||||
"introTestMode": "Puedes usar la API de SmoothSchedule en modo de prueba, que no afecta tus datos en vivo. La clave API que uses determina si la solicitud es en modo de prueba o en vivo.",
|
||||
"baseUrl": "URL Base",
|
||||
"baseUrlDescription": "Todas las solicitudes API deben realizarse a:",
|
||||
"sandboxMode": "Modo Sandbox:",
|
||||
"sandboxModeDescription": "Usa la URL de sandbox para desarrollo y pruebas. Todos los ejemplos en esta documentación usan claves API de prueba que funcionan con el sandbox.",
|
||||
"authentication": "Autenticación",
|
||||
"authDescription": "La API de SmoothSchedule usa claves API para autenticar solicitudes. Puedes ver y gestionar tus claves API en la Configuración de tu Negocio.",
|
||||
"authBearer": "La autenticación a la API se realiza mediante token Bearer. Incluye tu clave API en el encabezado Authorization de todas las solicitudes.",
|
||||
"authWarning": "Tus claves API tienen muchos privilegios, así que asegúrate de mantenerlas seguras. No compartas tus claves API secretas en áreas públicamente accesibles como GitHub, código del lado del cliente, etc.",
|
||||
"apiKeyFormat": "Formato de Clave API",
|
||||
"testKey": "Clave de modo prueba/sandbox",
|
||||
"liveKey": "Clave de modo en vivo/producción",
|
||||
"authenticatedRequest": "Solicitud Autenticada",
|
||||
"keepKeysSecret": "¡Mantén tus claves en secreto!",
|
||||
"keepKeysSecretDescription": "Nunca expongas las claves API en código del lado del cliente, control de versiones o foros públicos.",
|
||||
"errors": "Errores",
|
||||
"errorsDescription": "SmoothSchedule usa códigos de respuesta HTTP convencionales para indicar el éxito o fracaso de una solicitud API.",
|
||||
"httpStatusCodes": "Códigos de Estado HTTP",
|
||||
"errorResponse": "Respuesta de Error",
|
||||
"statusOk": "La solicitud fue exitosa.",
|
||||
"statusCreated": "Se creó un nuevo recurso.",
|
||||
"statusBadRequest": "Parámetros de solicitud inválidos.",
|
||||
"statusUnauthorized": "Clave API inválida o faltante.",
|
||||
"statusForbidden": "La clave API carece de los permisos requeridos.",
|
||||
"statusNotFound": "El recurso solicitado no existe.",
|
||||
"statusConflict": "Conflicto de recursos (ej., doble reserva).",
|
||||
"statusTooManyRequests": "Límite de tasa excedido.",
|
||||
"statusServerError": "Algo salió mal de nuestro lado.",
|
||||
"rateLimits": "Límites de Tasa",
|
||||
"rateLimitsDescription": "La API implementa límites de tasa para asegurar un uso justo y estabilidad.",
|
||||
"limits": "Límites",
|
||||
"requestsPerHour": "solicitudes por hora por clave API",
|
||||
"requestsPerMinute": "solicitudes por minuto límite de ráfaga",
|
||||
"rateLimitHeaders": "Encabezados de Límite de Tasa",
|
||||
"rateLimitHeadersDescription": "Cada respuesta incluye encabezados con tu estado actual de límite de tasa.",
|
||||
"business": "Negocio",
|
||||
"businessObject": "El objeto Negocio",
|
||||
"businessObjectDescription": "El objeto Negocio representa la configuración y ajustes de tu negocio.",
|
||||
"attributes": "Atributos",
|
||||
"retrieveBusiness": "Obtener negocio",
|
||||
"retrieveBusinessDescription": "Obtiene el negocio asociado con tu clave API.",
|
||||
"requiredScope": "Alcance requerido",
|
||||
"services": "Servicios",
|
||||
"serviceObject": "El objeto Servicio",
|
||||
"serviceObjectDescription": "Los servicios representan las ofertas que tu negocio proporciona y que los clientes pueden reservar.",
|
||||
"listServices": "Listar todos los servicios",
|
||||
"listServicesDescription": "Devuelve una lista de todos los servicios activos de tu negocio.",
|
||||
"retrieveService": "Obtener un servicio",
|
||||
"resources": "Recursos",
|
||||
"resourceObject": "El objeto Recurso",
|
||||
"resourceObjectDescription": "Los recursos son las entidades reservables en tu negocio (miembros del personal, salas, equipos).",
|
||||
"listResources": "Listar todos los recursos",
|
||||
"retrieveResource": "Obtener un recurso",
|
||||
"availability": "Disponibilidad",
|
||||
"checkAvailability": "Verificar disponibilidad",
|
||||
"checkAvailabilityDescription": "Devuelve los horarios disponibles para un servicio y rango de fechas dado.",
|
||||
"parameters": "Parámetros",
|
||||
"appointments": "Citas",
|
||||
"appointmentObject": "El objeto Cita",
|
||||
"appointmentObjectDescription": "Las citas representan reservas programadas entre clientes y recursos.",
|
||||
"createAppointment": "Crear una cita",
|
||||
"createAppointmentDescription": "Crea una nueva reserva de cita.",
|
||||
"retrieveAppointment": "Obtener una cita",
|
||||
"updateAppointment": "Actualizar una cita",
|
||||
"cancelAppointment": "Cancelar una cita",
|
||||
"listAppointments": "Listar todas las citas",
|
||||
"customers": "Clientes",
|
||||
"customerObject": "El objeto Cliente",
|
||||
"customerObjectDescription": "Los clientes son las personas que reservan citas con tu negocio.",
|
||||
"createCustomer": "Crear un cliente",
|
||||
"retrieveCustomer": "Obtener un cliente",
|
||||
"updateCustomer": "Actualizar un cliente",
|
||||
"listCustomers": "Listar todos los clientes",
|
||||
"webhooks": "Webhooks",
|
||||
"webhookEvents": "Eventos de webhook",
|
||||
"webhookEventsDescription": "Los webhooks te permiten recibir notificaciones en tiempo real cuando ocurren eventos en tu negocio.",
|
||||
"eventTypes": "Tipos de eventos",
|
||||
"webhookPayload": "Carga de Webhook",
|
||||
"createWebhook": "Crear un webhook",
|
||||
"createWebhookDescription": "Crea una nueva suscripción de webhook. La respuesta incluye un secreto que usarás para verificar las firmas de webhook.",
|
||||
"secretOnlyOnce": "El secreto solo se muestra una vez",
|
||||
"secretOnlyOnceDescription": ", así que guárdalo de forma segura.",
|
||||
"listWebhooks": "Listar webhooks",
|
||||
"deleteWebhook": "Eliminar un webhook",
|
||||
"verifySignatures": "Verificar firmas",
|
||||
"verifySignaturesDescription": "Cada solicitud de webhook incluye una firma en el encabezado X-Webhook-Signature. Debes verificar esta firma para asegurar que la solicitud provino de SmoothSchedule.",
|
||||
"signatureFormat": "Formato de firma",
|
||||
"signatureFormatDescription": "El encabezado de firma contiene dos valores separados por un punto: una marca de tiempo y la firma HMAC-SHA256.",
|
||||
"verificationSteps": "Pasos de verificación",
|
||||
"verificationStep1": "Extrae la marca de tiempo y la firma del encabezado",
|
||||
"verificationStep2": "Concatena la marca de tiempo, un punto y el cuerpo crudo de la solicitud",
|
||||
"verificationStep3": "Calcula HMAC-SHA256 usando tu secreto de webhook",
|
||||
"verificationStep4": "Compara la firma calculada con la firma recibida",
|
||||
"saveYourSecret": "¡Guarda tu secreto!",
|
||||
"saveYourSecretDescription": "El secreto del webhook solo se devuelve una vez cuando se crea el webhook. Guárdalo de forma segura para la verificación de firmas.",
|
||||
"endpoint": "Endpoint",
|
||||
"request": "Solicitud",
|
||||
"response": "Respuesta"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"scheduler": "Planificateur",
|
||||
"customers": "Clients",
|
||||
"resources": "Ressources",
|
||||
"services": "Services",
|
||||
"payments": "Paiements",
|
||||
"messages": "Messages",
|
||||
"staff": "Personnel",
|
||||
@@ -61,7 +62,124 @@
|
||||
"businesses": "Entreprises",
|
||||
"users": "Utilisateurs",
|
||||
"support": "Support",
|
||||
"platformSettings": "Paramètres Plateforme"
|
||||
"platformSettings": "Paramètres Plateforme",
|
||||
"tickets": "Tickets",
|
||||
"help": "Aide",
|
||||
"platformGuide": "Guide de la Plateforme",
|
||||
"ticketingHelp": "Système de Tickets",
|
||||
"apiDocs": "Documentation API"
|
||||
},
|
||||
"help": {
|
||||
"guide": {
|
||||
"title": "Guide de la Plateforme",
|
||||
"subtitle": "Apprenez à utiliser SmoothSchedule efficacement",
|
||||
"comingSoon": "Bientôt Disponible",
|
||||
"comingSoonDesc": "Nous travaillons sur une documentation complète pour vous aider à tirer le meilleur parti de SmoothSchedule. Revenez bientôt !"
|
||||
},
|
||||
"api": {
|
||||
"title": "Référence API",
|
||||
"interactiveExplorer": "Explorateur Interactif",
|
||||
"introduction": "Introduction",
|
||||
"introDescription": "L'API SmoothSchedule est organisée selon REST. Notre API a des URLs prévisibles orientées ressources, accepte des corps de requête encodés en JSON, renvoie des réponses encodées en JSON et utilise des codes de réponse HTTP standard.",
|
||||
"introTestMode": "Vous pouvez utiliser l'API SmoothSchedule en mode test, qui n'affecte pas vos données en direct. La clé API que vous utilisez détermine si la requête est en mode test ou en direct.",
|
||||
"baseUrl": "URL de Base",
|
||||
"baseUrlDescription": "Toutes les requêtes API doivent être faites à :",
|
||||
"sandboxMode": "Mode Sandbox :",
|
||||
"sandboxModeDescription": "Utilisez l'URL sandbox pour le développement et les tests. Tous les exemples dans cette documentation utilisent des clés API de test qui fonctionnent avec le sandbox.",
|
||||
"authentication": "Authentification",
|
||||
"authDescription": "L'API SmoothSchedule utilise des clés API pour authentifier les requêtes. Vous pouvez voir et gérer vos clés API dans les Paramètres de votre Entreprise.",
|
||||
"authBearer": "L'authentification à l'API se fait via un token Bearer. Incluez votre clé API dans l'en-tête Authorization de toutes les requêtes.",
|
||||
"authWarning": "Vos clés API ont de nombreux privilèges, alors assurez-vous de les garder en sécurité. Ne partagez pas vos clés API secrètes dans des zones publiquement accessibles comme GitHub, le code côté client, etc.",
|
||||
"apiKeyFormat": "Format de Clé API",
|
||||
"testKey": "Clé mode test/sandbox",
|
||||
"liveKey": "Clé mode production",
|
||||
"authenticatedRequest": "Requête Authentifiée",
|
||||
"keepKeysSecret": "Gardez vos clés secrètes !",
|
||||
"keepKeysSecretDescription": "N'exposez jamais les clés API dans le code côté client, le contrôle de version ou les forums publics.",
|
||||
"errors": "Erreurs",
|
||||
"errorsDescription": "SmoothSchedule utilise des codes de réponse HTTP conventionnels pour indiquer le succès ou l'échec d'une requête API.",
|
||||
"httpStatusCodes": "Codes de Statut HTTP",
|
||||
"errorResponse": "Réponse d'Erreur",
|
||||
"statusOk": "La requête a réussi.",
|
||||
"statusCreated": "Une nouvelle ressource a été créée.",
|
||||
"statusBadRequest": "Paramètres de requête invalides.",
|
||||
"statusUnauthorized": "Clé API invalide ou manquante.",
|
||||
"statusForbidden": "La clé API n'a pas les permissions requises.",
|
||||
"statusNotFound": "La ressource demandée n'existe pas.",
|
||||
"statusConflict": "Conflit de ressources (ex., double réservation).",
|
||||
"statusTooManyRequests": "Limite de taux dépassée.",
|
||||
"statusServerError": "Quelque chose s'est mal passé de notre côté.",
|
||||
"rateLimits": "Limites de Taux",
|
||||
"rateLimitsDescription": "L'API implémente des limites de taux pour assurer une utilisation équitable et la stabilité.",
|
||||
"limits": "Limites",
|
||||
"requestsPerHour": "requêtes par heure par clé API",
|
||||
"requestsPerMinute": "requêtes par minute limite de rafale",
|
||||
"rateLimitHeaders": "En-têtes de Limite de Taux",
|
||||
"rateLimitHeadersDescription": "Chaque réponse inclut des en-têtes avec votre statut actuel de limite de taux.",
|
||||
"business": "Entreprise",
|
||||
"businessObject": "L'objet Entreprise",
|
||||
"businessObjectDescription": "L'objet Entreprise représente la configuration et les paramètres de votre entreprise.",
|
||||
"attributes": "Attributs",
|
||||
"retrieveBusiness": "Récupérer l'entreprise",
|
||||
"retrieveBusinessDescription": "Récupère l'entreprise associée à votre clé API.",
|
||||
"requiredScope": "Portée requise",
|
||||
"services": "Services",
|
||||
"serviceObject": "L'objet Service",
|
||||
"serviceObjectDescription": "Les services représentent les offres que votre entreprise propose et que les clients peuvent réserver.",
|
||||
"listServices": "Lister tous les services",
|
||||
"listServicesDescription": "Renvoie une liste de tous les services actifs de votre entreprise.",
|
||||
"retrieveService": "Récupérer un service",
|
||||
"resources": "Ressources",
|
||||
"resourceObject": "L'objet Ressource",
|
||||
"resourceObjectDescription": "Les ressources sont les entités réservables dans votre entreprise (membres du personnel, salles, équipements).",
|
||||
"listResources": "Lister toutes les ressources",
|
||||
"retrieveResource": "Récupérer une ressource",
|
||||
"availability": "Disponibilité",
|
||||
"checkAvailability": "Vérifier la disponibilité",
|
||||
"checkAvailabilityDescription": "Renvoie les créneaux horaires disponibles pour un service et une plage de dates donnés.",
|
||||
"parameters": "Paramètres",
|
||||
"appointments": "Rendez-vous",
|
||||
"appointmentObject": "L'objet Rendez-vous",
|
||||
"appointmentObjectDescription": "Les rendez-vous représentent des réservations planifiées entre les clients et les ressources.",
|
||||
"createAppointment": "Créer un rendez-vous",
|
||||
"createAppointmentDescription": "Crée une nouvelle réservation de rendez-vous.",
|
||||
"retrieveAppointment": "Récupérer un rendez-vous",
|
||||
"updateAppointment": "Mettre à jour un rendez-vous",
|
||||
"cancelAppointment": "Annuler un rendez-vous",
|
||||
"listAppointments": "Lister tous les rendez-vous",
|
||||
"customers": "Clients",
|
||||
"customerObject": "L'objet Client",
|
||||
"customerObjectDescription": "Les clients sont les personnes qui réservent des rendez-vous avec votre entreprise.",
|
||||
"createCustomer": "Créer un client",
|
||||
"retrieveCustomer": "Récupérer un client",
|
||||
"updateCustomer": "Mettre à jour un client",
|
||||
"listCustomers": "Lister tous les clients",
|
||||
"webhooks": "Webhooks",
|
||||
"webhookEvents": "Événements webhook",
|
||||
"webhookEventsDescription": "Les webhooks vous permettent de recevoir des notifications en temps réel lorsque des événements se produisent dans votre entreprise.",
|
||||
"eventTypes": "Types d'événements",
|
||||
"webhookPayload": "Charge Webhook",
|
||||
"createWebhook": "Créer un webhook",
|
||||
"createWebhookDescription": "Crée un nouvel abonnement webhook. La réponse inclut un secret que vous utiliserez pour vérifier les signatures webhook.",
|
||||
"secretOnlyOnce": "Le secret n'est affiché qu'une seule fois",
|
||||
"secretOnlyOnceDescription": ", alors conservez-le en sécurité.",
|
||||
"listWebhooks": "Lister les webhooks",
|
||||
"deleteWebhook": "Supprimer un webhook",
|
||||
"verifySignatures": "Vérifier les signatures",
|
||||
"verifySignaturesDescription": "Chaque requête webhook inclut une signature dans l'en-tête X-Webhook-Signature. Vous devez vérifier cette signature pour vous assurer que la requête provient de SmoothSchedule.",
|
||||
"signatureFormat": "Format de signature",
|
||||
"signatureFormatDescription": "L'en-tête de signature contient deux valeurs séparées par un point : un horodatage et la signature HMAC-SHA256.",
|
||||
"verificationSteps": "Étapes de vérification",
|
||||
"verificationStep1": "Extraire l'horodatage et la signature de l'en-tête",
|
||||
"verificationStep2": "Concaténer l'horodatage, un point et le corps brut de la requête",
|
||||
"verificationStep3": "Calculer HMAC-SHA256 en utilisant votre secret webhook",
|
||||
"verificationStep4": "Comparer la signature calculée avec la signature reçue",
|
||||
"saveYourSecret": "Conservez votre secret !",
|
||||
"saveYourSecretDescription": "Le secret webhook n'est renvoyé qu'une seule fois lors de la création du webhook. Conservez-le en sécurité pour la vérification des signatures.",
|
||||
"endpoint": "Point de terminaison",
|
||||
"request": "Requête",
|
||||
"response": "Réponse"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de Bord",
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "読み込み中...",
|
||||
"error": "エラー",
|
||||
"success": "成功",
|
||||
"save": "保存",
|
||||
"saveChanges": "変更を保存",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"create": "作成",
|
||||
"update": "更新",
|
||||
"close": "閉じる",
|
||||
"confirm": "確認",
|
||||
"back": "戻る",
|
||||
"next": "次へ",
|
||||
"search": "検索",
|
||||
"filter": "フィルター",
|
||||
"actions": "アクション",
|
||||
"settings": "設定",
|
||||
"reload": "再読み込み",
|
||||
"viewAll": "すべて表示",
|
||||
"learnMore": "詳細を見る",
|
||||
"poweredBy": "提供元",
|
||||
"required": "必須",
|
||||
"optional": "任意",
|
||||
"masquerade": "なりすまし",
|
||||
"masqueradeAsUser": "ユーザーになりすます"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "ログイン",
|
||||
"signOut": "ログアウト",
|
||||
"signingIn": "ログイン中...",
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"enterUsername": "ユーザー名を入力",
|
||||
"enterPassword": "パスワードを入力",
|
||||
"welcomeBack": "おかえりなさい",
|
||||
"pleaseEnterDetails": "ログインするには詳細を入力してください。",
|
||||
"authError": "認証エラー",
|
||||
"invalidCredentials": "無効な認証情報",
|
||||
"orContinueWith": "または以下でログイン",
|
||||
"loginAtSubdomain": "ビジネスのサブドメインでログインしてください。スタッフと顧客はメインサイトからログインできません。",
|
||||
"forgotPassword": "パスワードをお忘れですか?",
|
||||
"rememberMe": "ログイン状態を保持",
|
||||
"twoFactorRequired": "二要素認証が必要です",
|
||||
"enterCode": "確認コードを入力",
|
||||
"verifyCode": "コードを確認"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "ダッシュボード",
|
||||
"scheduler": "スケジューラー",
|
||||
"customers": "顧客",
|
||||
"resources": "リソース",
|
||||
"payments": "支払い",
|
||||
"messages": "メッセージ",
|
||||
"staff": "スタッフ",
|
||||
"businessSettings": "ビジネス設定",
|
||||
"profile": "プロフィール",
|
||||
"platformDashboard": "プラットフォームダッシュボード",
|
||||
"businesses": "ビジネス",
|
||||
"users": "ユーザー",
|
||||
"support": "サポート",
|
||||
"platformSettings": "プラットフォーム設定"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "ダッシュボード",
|
||||
"welcome": "ようこそ、{{name}}さん!",
|
||||
"todayOverview": "今日の概要",
|
||||
"upcomingAppointments": "今後の予約",
|
||||
"recentActivity": "最近のアクティビティ",
|
||||
"quickActions": "クイックアクション",
|
||||
"totalRevenue": "総収益",
|
||||
"totalAppointments": "予約総数",
|
||||
"newCustomers": "新規顧客",
|
||||
"pendingPayments": "保留中の支払い"
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "スケジューラー",
|
||||
"newAppointment": "新規予約",
|
||||
"editAppointment": "予約を編集",
|
||||
"deleteAppointment": "予約を削除",
|
||||
"selectResource": "リソースを選択",
|
||||
"selectService": "サービスを選択",
|
||||
"selectCustomer": "顧客を選択",
|
||||
"selectDate": "日付を選択",
|
||||
"selectTime": "時間を選択",
|
||||
"duration": "所要時間",
|
||||
"notes": "メモ",
|
||||
"status": "ステータス",
|
||||
"confirmed": "確認済み",
|
||||
"pending": "保留中",
|
||||
"cancelled": "キャンセル",
|
||||
"completed": "完了",
|
||||
"noShow": "無断キャンセル",
|
||||
"today": "今日",
|
||||
"week": "週",
|
||||
"month": "月",
|
||||
"day": "日",
|
||||
"timeline": "タイムライン",
|
||||
"agenda": "アジェンダ",
|
||||
"allResources": "全リソース"
|
||||
},
|
||||
"customers": {
|
||||
"title": "顧客",
|
||||
"description": "顧客データベースを管理し、履歴を表示します。",
|
||||
"addCustomer": "顧客を追加",
|
||||
"editCustomer": "顧客を編集",
|
||||
"customerDetails": "顧客詳細",
|
||||
"name": "名前",
|
||||
"fullName": "氏名",
|
||||
"email": "メールアドレス",
|
||||
"emailAddress": "メールアドレス",
|
||||
"phone": "電話番号",
|
||||
"phoneNumber": "電話番号",
|
||||
"address": "住所",
|
||||
"city": "市区町村",
|
||||
"state": "都道府県",
|
||||
"zipCode": "郵便番号",
|
||||
"tags": "タグ",
|
||||
"tagsPlaceholder": "例: VIP, 紹介",
|
||||
"tagsCommaSeparated": "タグ(カンマ区切り)",
|
||||
"appointmentHistory": "予約履歴",
|
||||
"noAppointments": "まだ予約がありません",
|
||||
"totalSpent": "総支払額",
|
||||
"totalSpend": "総利用額",
|
||||
"lastVisit": "最終来店",
|
||||
"nextAppointment": "次回の予約",
|
||||
"contactInfo": "連絡先情報",
|
||||
"status": "ステータス",
|
||||
"active": "有効",
|
||||
"inactive": "無効",
|
||||
"never": "なし",
|
||||
"customer": "顧客",
|
||||
"searchPlaceholder": "名前、メール、電話番号で検索...",
|
||||
"filters": "フィルター",
|
||||
"noCustomersFound": "検索条件に一致する顧客が見つかりません。",
|
||||
"addNewCustomer": "新規顧客を追加",
|
||||
"createCustomer": "顧客を作成",
|
||||
"errorLoading": "顧客の読み込みエラー"
|
||||
},
|
||||
"staff": {
|
||||
"title": "スタッフと管理",
|
||||
"description": "ユーザーアカウントと権限を管理します。",
|
||||
"inviteStaff": "スタッフを招待",
|
||||
"name": "名前",
|
||||
"role": "役割",
|
||||
"bookableResource": "予約可能リソース",
|
||||
"makeBookable": "予約可能にする",
|
||||
"yes": "はい",
|
||||
"errorLoading": "スタッフの読み込みエラー",
|
||||
"inviteModalTitle": "スタッフを招待",
|
||||
"inviteModalDescription": "ユーザー招待フローがここに表示されます。"
|
||||
},
|
||||
"resources": {
|
||||
"title": "リソース",
|
||||
"description": "スタッフ、部屋、機材を管理します。",
|
||||
"addResource": "リソースを追加",
|
||||
"editResource": "リソースを編集",
|
||||
"resourceDetails": "リソース詳細",
|
||||
"resourceName": "リソース名",
|
||||
"name": "名前",
|
||||
"type": "タイプ",
|
||||
"resourceType": "リソースタイプ",
|
||||
"availability": "空き状況",
|
||||
"services": "サービス",
|
||||
"schedule": "スケジュール",
|
||||
"active": "有効",
|
||||
"inactive": "無効",
|
||||
"upcoming": "今後",
|
||||
"appointments": "予約",
|
||||
"viewCalendar": "カレンダーを見る",
|
||||
"noResourcesFound": "リソースが見つかりません。",
|
||||
"addNewResource": "新規リソースを追加",
|
||||
"createResource": "リソースを作成",
|
||||
"staffMember": "スタッフメンバー",
|
||||
"room": "部屋",
|
||||
"equipment": "機材",
|
||||
"resourceNote": "リソースはスケジューリングのためのプレースホルダーです。スタッフは予約に個別に割り当てることができます。",
|
||||
"errorLoading": "リソースの読み込みエラー"
|
||||
},
|
||||
"services": {
|
||||
"title": "サービス",
|
||||
"addService": "サービスを追加",
|
||||
"editService": "サービスを編集",
|
||||
"name": "名前",
|
||||
"description": "説明",
|
||||
"duration": "所要時間",
|
||||
"price": "価格",
|
||||
"category": "カテゴリー",
|
||||
"active": "有効"
|
||||
},
|
||||
"payments": {
|
||||
"title": "支払い",
|
||||
"transactions": "取引",
|
||||
"invoices": "請求書",
|
||||
"amount": "金額",
|
||||
"status": "ステータス",
|
||||
"date": "日付",
|
||||
"method": "方法",
|
||||
"paid": "支払い済み",
|
||||
"unpaid": "未払い",
|
||||
"refunded": "返金済み",
|
||||
"pending": "保留中",
|
||||
"viewDetails": "詳細を見る",
|
||||
"issueRefund": "返金を発行",
|
||||
"sendReminder": "リマインダーを送信",
|
||||
"paymentSettings": "支払い設定",
|
||||
"stripeConnect": "Stripe Connect",
|
||||
"apiKeys": "APIキー"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"businessSettings": "ビジネス設定",
|
||||
"businessSettingsDescription": "ブランディング、ドメイン、ポリシーを管理します。",
|
||||
"domainIdentity": "ドメインとアイデンティティ",
|
||||
"bookingPolicy": "予約とキャンセルポリシー",
|
||||
"savedSuccessfully": "設定が正常に保存されました",
|
||||
"general": "一般",
|
||||
"branding": "ブランディング",
|
||||
"notifications": "通知",
|
||||
"security": "セキュリティ",
|
||||
"integrations": "連携",
|
||||
"billing": "請求",
|
||||
"businessName": "ビジネス名",
|
||||
"subdomain": "サブドメイン",
|
||||
"primaryColor": "メインカラー",
|
||||
"secondaryColor": "サブカラー",
|
||||
"logo": "ロゴ",
|
||||
"uploadLogo": "ロゴをアップロード",
|
||||
"timezone": "タイムゾーン",
|
||||
"language": "言語",
|
||||
"currency": "通貨",
|
||||
"dateFormat": "日付形式",
|
||||
"timeFormat": "時間形式",
|
||||
"oauth": {
|
||||
"title": "OAuth設定",
|
||||
"enabledProviders": "有効なプロバイダー",
|
||||
"allowRegistration": "OAuthでの登録を許可",
|
||||
"autoLinkByEmail": "メールアドレスで自動リンク",
|
||||
"customCredentials": "カスタムOAuth認証情報",
|
||||
"customCredentialsDesc": "ホワイトラベル体験のために独自のOAuth認証情報を使用",
|
||||
"platformCredentials": "プラットフォーム認証情報",
|
||||
"platformCredentialsDesc": "プラットフォーム提供のOAuth認証情報を使用",
|
||||
"clientId": "クライアントID",
|
||||
"clientSecret": "クライアントシークレット",
|
||||
"paidTierOnly": "カスタムOAuth認証情報は有料プランでのみ利用可能です"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "プロフィール設定",
|
||||
"personalInfo": "個人情報",
|
||||
"changePassword": "パスワードを変更",
|
||||
"twoFactor": "二要素認証",
|
||||
"sessions": "アクティブセッション",
|
||||
"emails": "メールアドレス",
|
||||
"preferences": "設定",
|
||||
"currentPassword": "現在のパスワード",
|
||||
"newPassword": "新しいパスワード",
|
||||
"confirmPassword": "パスワードを確認",
|
||||
"passwordChanged": "パスワードが正常に変更されました",
|
||||
"enable2FA": "二要素認証を有効にする",
|
||||
"disable2FA": "二要素認証を無効にする",
|
||||
"scanQRCode": "QRコードをスキャン",
|
||||
"enterBackupCode": "バックアップコードを入力",
|
||||
"recoveryCodes": "リカバリーコード"
|
||||
},
|
||||
"platform": {
|
||||
"title": "プラットフォーム管理",
|
||||
"dashboard": "プラットフォームダッシュボード",
|
||||
"overview": "プラットフォーム概要",
|
||||
"overviewDescription": "全テナントのグローバル指標。",
|
||||
"mrrGrowth": "MRR成長率",
|
||||
"totalBusinesses": "ビジネス総数",
|
||||
"totalUsers": "ユーザー総数",
|
||||
"monthlyRevenue": "月間収益",
|
||||
"activeSubscriptions": "アクティブなサブスクリプション",
|
||||
"recentSignups": "最近の登録",
|
||||
"supportTickets": "サポートチケット",
|
||||
"supportDescription": "テナントから報告された問題を解決。",
|
||||
"reportedBy": "報告者",
|
||||
"priority": "優先度",
|
||||
"businessManagement": "ビジネス管理",
|
||||
"userManagement": "ユーザー管理",
|
||||
"masquerade": "なりすまし",
|
||||
"masqueradeAs": "なりすまし対象",
|
||||
"exitMasquerade": "なりすましを終了",
|
||||
"businesses": "ビジネス",
|
||||
"businessesDescription": "テナント、プラン、アクセスを管理。",
|
||||
"addNewTenant": "新しいテナントを追加",
|
||||
"searchBusinesses": "ビジネスを検索...",
|
||||
"businessName": "ビジネス名",
|
||||
"subdomain": "サブドメイン",
|
||||
"plan": "プラン",
|
||||
"status": "ステータス",
|
||||
"joined": "登録日",
|
||||
"userDirectory": "ユーザーディレクトリ",
|
||||
"userDirectoryDescription": "プラットフォーム全体のユーザーを表示・管理。",
|
||||
"searchUsers": "名前またはメールでユーザーを検索...",
|
||||
"allRoles": "全ての役割",
|
||||
"user": "ユーザー",
|
||||
"role": "役割",
|
||||
"email": "メール",
|
||||
"noUsersFound": "フィルターに一致するユーザーが見つかりません。",
|
||||
"roles": {
|
||||
"superuser": "スーパーユーザー",
|
||||
"platformManager": "プラットフォーム管理者",
|
||||
"businessOwner": "ビジネスオーナー",
|
||||
"staff": "スタッフ",
|
||||
"customer": "顧客"
|
||||
},
|
||||
"settings": {
|
||||
"title": "プラットフォーム設定",
|
||||
"description": "プラットフォーム全体の設定と連携を構成",
|
||||
"tiersPricing": "プランと料金",
|
||||
"oauthProviders": "OAuthプロバイダー",
|
||||
"general": "一般",
|
||||
"oauth": "OAuthプロバイダー",
|
||||
"payments": "支払い",
|
||||
"email": "メール",
|
||||
"branding": "ブランディング"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"generic": "エラーが発生しました。もう一度お試しください。",
|
||||
"networkError": "ネットワークエラー。接続を確認してください。",
|
||||
"unauthorized": "この操作を行う権限がありません。",
|
||||
"notFound": "リクエストされたリソースが見つかりませんでした。",
|
||||
"validation": "入力内容を確認して、もう一度お試しください。",
|
||||
"businessNotFound": "ビジネスが見つかりません",
|
||||
"wrongLocation": "場所が違います",
|
||||
"accessDenied": "アクセス拒否"
|
||||
},
|
||||
"validation": {
|
||||
"required": "この項目は必須です",
|
||||
"email": "有効なメールアドレスを入力してください",
|
||||
"minLength": "{{min}}文字以上で入力してください",
|
||||
"maxLength": "{{max}}文字以下で入力してください",
|
||||
"passwordMatch": "パスワードが一致しません",
|
||||
"invalidPhone": "有効な電話番号を入力してください"
|
||||
},
|
||||
"time": {
|
||||
"minutes": "分",
|
||||
"hours": "時間",
|
||||
"days": "日",
|
||||
"today": "今日",
|
||||
"tomorrow": "明日",
|
||||
"yesterday": "昨日",
|
||||
"thisWeek": "今週",
|
||||
"thisMonth": "今月",
|
||||
"am": "午前",
|
||||
"pm": "午後"
|
||||
},
|
||||
"marketing": {
|
||||
"tagline": "ビジネスを精密に調整する。",
|
||||
"description": "あらゆる規模のビジネス向けオールインワンスケジューリングプラットフォーム。リソース、スタッフ、予約を簡単に管理。",
|
||||
"copyright": "Smooth Schedule Inc.",
|
||||
"nav": {
|
||||
"features": "機能",
|
||||
"pricing": "料金",
|
||||
"about": "会社概要",
|
||||
"contact": "お問い合わせ",
|
||||
"login": "ログイン",
|
||||
"getStarted": "はじめる",
|
||||
"startFreeTrial": "無料トライアル"
|
||||
},
|
||||
"hero": {
|
||||
"headline": "シンプルな予約管理",
|
||||
"subheadline": "予約、リソース、顧客を一元管理するオールインワンプラットフォーム。無料で始めて、成長に合わせて拡張。",
|
||||
"cta": "無料トライアルを開始",
|
||||
"secondaryCta": "デモを見る",
|
||||
"trustedBy": "1,000社以上の企業に信頼されています"
|
||||
},
|
||||
"features": {
|
||||
"title": "必要なすべてを",
|
||||
"subtitle": "サービスビジネスのための強力な機能",
|
||||
"scheduling": {
|
||||
"title": "スマートスケジューリング",
|
||||
"description": "ドラッグ&ドロップカレンダー、リアルタイム空き状況、自動リマインダー、重複検出機能を搭載。"
|
||||
},
|
||||
"resources": {
|
||||
"title": "リソース管理",
|
||||
"description": "スタッフ、部屋、設備を管理。空き状況、スキル、予約ルールを設定。"
|
||||
},
|
||||
"customers": {
|
||||
"title": "顧客ポータル",
|
||||
"description": "セルフサービス予約ポータル。履歴確認、予約管理、決済方法の保存が可能。"
|
||||
},
|
||||
"payments": {
|
||||
"title": "統合決済",
|
||||
"description": "Stripeでオンライン決済を受付。デポジット、全額払い、自動請求に対応。"
|
||||
},
|
||||
"multiTenant": {
|
||||
"title": "複数拠点サポート",
|
||||
"description": "複数の拠点やブランドを単一ダッシュボードで管理。データは完全分離。"
|
||||
},
|
||||
"whiteLabel": {
|
||||
"title": "ホワイトラベル対応",
|
||||
"description": "カスタムドメイン、ブランディング、SmoothScheduleロゴの非表示で一体感のある体験を。"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "分析とレポート",
|
||||
"description": "売上、予約、顧客トレンドを美しいダッシュボードで追跡。"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "豊富な連携機能",
|
||||
"description": "Google カレンダー、Zoom、Stripeなどと連携。カスタム連携用APIも利用可能。"
|
||||
}
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "数分で始められます",
|
||||
"subtitle": "3つの簡単なステップでスケジューリングを変革",
|
||||
"step1": {
|
||||
"title": "アカウント作成",
|
||||
"description": "無料登録して、数分でビジネスプロフィールを設定。"
|
||||
},
|
||||
"step2": {
|
||||
"title": "サービスを追加",
|
||||
"description": "サービス、料金、利用可能なリソースを設定。"
|
||||
},
|
||||
"step3": {
|
||||
"title": "予約を開始",
|
||||
"description": "予約リンクを共有して、顧客に即座に予約してもらいましょう。"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"title": "シンプルで透明な料金",
|
||||
"subtitle": "無料から始めて、成長に合わせてアップグレード。隠れた費用なし。",
|
||||
"monthly": "月払い",
|
||||
"annual": "年払い",
|
||||
"annualSave": "20%お得",
|
||||
"perMonth": "/月",
|
||||
"period": "月",
|
||||
"popular": "人気No.1",
|
||||
"mostPopular": "人気No.1",
|
||||
"getStarted": "はじめる",
|
||||
"contactSales": "営業に問い合わせ",
|
||||
"freeTrial": "14日間無料トライアル",
|
||||
"noCredit": "クレジットカード不要",
|
||||
"features": "機能",
|
||||
"tiers": {
|
||||
"free": {
|
||||
"name": "無料",
|
||||
"description": "お試しに最適",
|
||||
"price": "0",
|
||||
"features": [
|
||||
"リソース2件まで",
|
||||
"基本スケジューリング",
|
||||
"顧客管理",
|
||||
"Stripe直接連携",
|
||||
"サブドメイン (business.smoothschedule.com)",
|
||||
"コミュニティサポート"
|
||||
],
|
||||
"transactionFee": "取引あたり2.5% + ¥50"
|
||||
},
|
||||
"professional": {
|
||||
"name": "プロフェッショナル",
|
||||
"description": "成長中のビジネス向け",
|
||||
"price": "29",
|
||||
"annualPrice": "290",
|
||||
"features": [
|
||||
"リソース10件まで",
|
||||
"カスタムドメイン",
|
||||
"Stripe Connect (手数料削減)",
|
||||
"ホワイトラベル",
|
||||
"メールリマインダー",
|
||||
"優先メールサポート"
|
||||
],
|
||||
"transactionFee": "取引あたり1.5% + ¥40"
|
||||
},
|
||||
"business": {
|
||||
"name": "ビジネス",
|
||||
"description": "確立したチーム向け",
|
||||
"price": "79",
|
||||
"annualPrice": "790",
|
||||
"features": [
|
||||
"リソース無制限",
|
||||
"全プロフェッショナル機能",
|
||||
"チーム管理",
|
||||
"高度な分析",
|
||||
"APIアクセス",
|
||||
"電話サポート"
|
||||
],
|
||||
"transactionFee": "取引あたり0.5% + ¥30"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "エンタープライズ",
|
||||
"description": "大規模組織向け",
|
||||
"price": "お問い合わせ",
|
||||
"features": [
|
||||
"全ビジネス機能",
|
||||
"カスタム連携",
|
||||
"専任サクセスマネージャー",
|
||||
"SLA保証",
|
||||
"カスタム契約",
|
||||
"オンプレミス対応"
|
||||
],
|
||||
"transactionFee": "カスタム取引手数料"
|
||||
}
|
||||
}
|
||||
},
|
||||
"testimonials": {
|
||||
"title": "世界中の企業に愛されています",
|
||||
"subtitle": "お客様の声をご覧ください"
|
||||
},
|
||||
"stats": {
|
||||
"appointments": "予約件数",
|
||||
"businesses": "企業数",
|
||||
"countries": "対応国数",
|
||||
"uptime": "稼働率"
|
||||
},
|
||||
"signup": {
|
||||
"title": "アカウント作成",
|
||||
"subtitle": "今すぐ無料トライアルを開始。クレジットカード不要。",
|
||||
"steps": {
|
||||
"business": "ビジネス",
|
||||
"account": "アカウント",
|
||||
"plan": "プラン",
|
||||
"confirm": "確認"
|
||||
},
|
||||
"businessInfo": {
|
||||
"title": "ビジネスについて教えてください",
|
||||
"name": "ビジネス名",
|
||||
"namePlaceholder": "例:Acme サロン&スパ",
|
||||
"subdomain": "サブドメインを選択",
|
||||
"checking": "利用可能か確認中...",
|
||||
"available": "利用可能です!",
|
||||
"taken": "既に使用されています"
|
||||
},
|
||||
"accountInfo": {
|
||||
"title": "管理者アカウントを作成",
|
||||
"firstName": "名",
|
||||
"lastName": "姓",
|
||||
"email": "メールアドレス",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワード(確認)"
|
||||
},
|
||||
"planSelection": {
|
||||
"title": "プランを選択"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "内容を確認",
|
||||
"business": "ビジネス",
|
||||
"account": "アカウント",
|
||||
"plan": "選択したプラン",
|
||||
"terms": "アカウントを作成することで、利用規約とプライバシーポリシーに同意したことになります。"
|
||||
},
|
||||
"errors": {
|
||||
"businessNameRequired": "ビジネス名は必須です",
|
||||
"subdomainRequired": "サブドメインは必須です",
|
||||
"subdomainTooShort": "サブドメインは3文字以上必要です",
|
||||
"subdomainInvalid": "サブドメインには小文字、数字、ハイフンのみ使用できます",
|
||||
"subdomainTaken": "このサブドメインは既に使用されています",
|
||||
"firstNameRequired": "名は必須です",
|
||||
"lastNameRequired": "姓は必須です",
|
||||
"emailRequired": "メールアドレスは必須です",
|
||||
"emailInvalid": "有効なメールアドレスを入力してください",
|
||||
"passwordRequired": "パスワードは必須です",
|
||||
"passwordTooShort": "パスワードは8文字以上必要です",
|
||||
"passwordMismatch": "パスワードが一致しません",
|
||||
"generic": "問題が発生しました。もう一度お試しください。"
|
||||
},
|
||||
"success": {
|
||||
"title": "Smooth Schedule へようこそ!",
|
||||
"message": "アカウントが正常に作成されました。",
|
||||
"yourUrl": "予約URL",
|
||||
"checkEmail": "確認メールを送信しました。すべての機能を有効にするには、メールを確認してください。",
|
||||
"goToLogin": "ログインへ"
|
||||
},
|
||||
"back": "戻る",
|
||||
"next": "次へ",
|
||||
"creating": "アカウント作成中...",
|
||||
"createAccount": "アカウント作成",
|
||||
"haveAccount": "すでにアカウントをお持ちですか?",
|
||||
"signIn": "ログイン"
|
||||
},
|
||||
"faq": {
|
||||
"title": "よくある質問",
|
||||
"subtitle": "ご質問にお答えします",
|
||||
"questions": {
|
||||
"trial": {
|
||||
"question": "無料トライアルはありますか?",
|
||||
"answer": "はい!すべての有料プランに14日間の無料トライアルが含まれています。開始時にクレジットカードは不要です。"
|
||||
},
|
||||
"cancel": {
|
||||
"question": "いつでも解約できますか?",
|
||||
"answer": "はい。いつでもキャンセル料なしでサブスクリプションを解約できます。"
|
||||
},
|
||||
"payment": {
|
||||
"question": "どの支払い方法に対応していますか?",
|
||||
"answer": "Stripe経由でVisa、Mastercard、American Expressなど主要なクレジットカードに対応しています。"
|
||||
},
|
||||
"migrate": {
|
||||
"question": "他のプラットフォームから移行できますか?",
|
||||
"answer": "はい!他のスケジューリングプラットフォームからの既存データの移行をお手伝いします。"
|
||||
},
|
||||
"support": {
|
||||
"question": "どのようなサポートがありますか?",
|
||||
"answer": "無料プランはコミュニティサポート、プロフェッショナル以上はメールサポート、ビジネス/エンタープライズは電話サポートが利用可能です。"
|
||||
},
|
||||
"customDomain": {
|
||||
"question": "カスタムドメインはどのように機能しますか?",
|
||||
"answer": "プロフェッショナル以上のプランでは、サブドメインの代わりに独自のドメイン(例:booking.yourcompany.com)を使用できます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Smooth Schedule について",
|
||||
"subtitle": "世界中の企業のスケジューリングをシンプルにすることが私たちの使命です。",
|
||||
"story": {
|
||||
"title": "私たちのストーリー",
|
||||
"content": "Smooth Schedule は「スケジューリングは複雑であるべきではない」というシンプルな信念のもとに設立されました。あらゆる規模の企業が予約、リソース、顧客を簡単に管理できるプラットフォームを構築しました。"
|
||||
},
|
||||
"mission": {
|
||||
"title": "私たちの使命",
|
||||
"content": "サービスビジネスが成長に必要なツールを提供し、顧客にシームレスな予約体験を提供すること。"
|
||||
},
|
||||
"values": {
|
||||
"title": "私たちの価値観",
|
||||
"simplicity": {
|
||||
"title": "シンプルさ",
|
||||
"description": "パワフルなソフトウェアでも、使いやすさは両立できると信じています。"
|
||||
},
|
||||
"reliability": {
|
||||
"title": "信頼性",
|
||||
"description": "お客様のビジネスは私たちにかかっています。稼働率に妥協はしません。"
|
||||
},
|
||||
"transparency": {
|
||||
"title": "透明性",
|
||||
"description": "隠れた費用なし、サプライズなし。見たままの料金です。"
|
||||
},
|
||||
"support": {
|
||||
"title": "サポート",
|
||||
"description": "お客様の成功のために、あらゆるステップでお手伝いします。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "お問い合わせ",
|
||||
"subtitle": "ご質問がありましたらお気軽にどうぞ。",
|
||||
"form": {
|
||||
"name": "お名前",
|
||||
"namePlaceholder": "山田 太郎",
|
||||
"email": "メールアドレス",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"subject": "件名",
|
||||
"subjectPlaceholder": "どのようなご用件ですか?",
|
||||
"message": "メッセージ",
|
||||
"messagePlaceholder": "ご要望をお聞かせください...",
|
||||
"submit": "メッセージを送信",
|
||||
"sending": "送信中...",
|
||||
"success": "お問い合わせありがとうございます!近日中にご連絡いたします。",
|
||||
"error": "問題が発生しました。もう一度お試しください。"
|
||||
},
|
||||
"info": {
|
||||
"email": "support@smoothschedule.com",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"address": "123 Schedule Street, San Francisco, CA 94102"
|
||||
},
|
||||
"sales": {
|
||||
"title": "営業へのお問い合わせ",
|
||||
"description": "エンタープライズプランにご興味がありますか?営業チームがお話しします。"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"ready": "始める準備はできましたか?",
|
||||
"readySubtitle": "SmoothScheduleを利用する数千の企業に加わりましょう。",
|
||||
"startFree": "無料トライアルを開始",
|
||||
"noCredit": "クレジットカード不要"
|
||||
},
|
||||
"footer": {
|
||||
"product": "製品",
|
||||
"company": "企業情報",
|
||||
"legal": "法的情報",
|
||||
"features": "機能",
|
||||
"pricing": "料金",
|
||||
"integrations": "連携",
|
||||
"about": "会社概要",
|
||||
"blog": "ブログ",
|
||||
"careers": "採用情報",
|
||||
"contact": "お問い合わせ",
|
||||
"terms": "利用規約",
|
||||
"privacy": "プライバシー",
|
||||
"cookies": "Cookie",
|
||||
"allRightsReserved": "All rights reserved."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Carregando...",
|
||||
"error": "Erro",
|
||||
"success": "Sucesso",
|
||||
"save": "Salvar",
|
||||
"saveChanges": "Salvar Alterações",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Excluir",
|
||||
"edit": "Editar",
|
||||
"create": "Criar",
|
||||
"update": "Atualizar",
|
||||
"close": "Fechar",
|
||||
"confirm": "Confirmar",
|
||||
"back": "Voltar",
|
||||
"next": "Próximo",
|
||||
"search": "Pesquisar",
|
||||
"filter": "Filtrar",
|
||||
"actions": "Ações",
|
||||
"settings": "Configurações",
|
||||
"reload": "Recarregar",
|
||||
"viewAll": "Ver Tudo",
|
||||
"learnMore": "Saiba Mais",
|
||||
"poweredBy": "Desenvolvido por",
|
||||
"required": "Obrigatório",
|
||||
"optional": "Opcional",
|
||||
"masquerade": "Personificar",
|
||||
"masqueradeAsUser": "Personificar como Usuário"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Entrar",
|
||||
"signOut": "Sair",
|
||||
"signingIn": "Entrando...",
|
||||
"username": "Nome de usuário",
|
||||
"password": "Senha",
|
||||
"enterUsername": "Digite seu nome de usuário",
|
||||
"enterPassword": "Digite sua senha",
|
||||
"welcomeBack": "Bem-vindo de volta",
|
||||
"pleaseEnterDetails": "Por favor, insira seus dados para entrar.",
|
||||
"authError": "Erro de Autenticação",
|
||||
"invalidCredentials": "Credenciais inválidas",
|
||||
"orContinueWith": "Ou continuar com",
|
||||
"loginAtSubdomain": "Por favor, faça login no subdomínio do seu negócio. Funcionários e clientes não podem fazer login no site principal.",
|
||||
"forgotPassword": "Esqueceu a senha?",
|
||||
"rememberMe": "Lembrar de mim",
|
||||
"twoFactorRequired": "Autenticação de dois fatores necessária",
|
||||
"enterCode": "Digite o código de verificação",
|
||||
"verifyCode": "Verificar Código"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Painel",
|
||||
"scheduler": "Agenda",
|
||||
"customers": "Clientes",
|
||||
"resources": "Recursos",
|
||||
"payments": "Pagamentos",
|
||||
"messages": "Mensagens",
|
||||
"staff": "Equipe",
|
||||
"businessSettings": "Configurações do Negócio",
|
||||
"profile": "Perfil",
|
||||
"platformDashboard": "Painel da Plataforma",
|
||||
"businesses": "Negócios",
|
||||
"users": "Usuários",
|
||||
"support": "Suporte",
|
||||
"platformSettings": "Configurações da Plataforma"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
"welcome": "Bem-vindo, {{name}}!",
|
||||
"todayOverview": "Resumo de Hoje",
|
||||
"upcomingAppointments": "Próximos Agendamentos",
|
||||
"recentActivity": "Atividade Recente",
|
||||
"quickActions": "Ações Rápidas",
|
||||
"totalRevenue": "Receita Total",
|
||||
"totalAppointments": "Total de Agendamentos",
|
||||
"newCustomers": "Novos Clientes",
|
||||
"pendingPayments": "Pagamentos Pendentes"
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Agenda",
|
||||
"newAppointment": "Novo Agendamento",
|
||||
"editAppointment": "Editar Agendamento",
|
||||
"deleteAppointment": "Excluir Agendamento",
|
||||
"selectResource": "Selecionar Recurso",
|
||||
"selectService": "Selecionar Serviço",
|
||||
"selectCustomer": "Selecionar Cliente",
|
||||
"selectDate": "Selecionar Data",
|
||||
"selectTime": "Selecionar Hora",
|
||||
"duration": "Duração",
|
||||
"notes": "Notas",
|
||||
"status": "Status",
|
||||
"confirmed": "Confirmado",
|
||||
"pending": "Pendente",
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
"noShow": "Não Compareceu",
|
||||
"today": "Hoje",
|
||||
"week": "Semana",
|
||||
"month": "Mês",
|
||||
"day": "Dia",
|
||||
"timeline": "Linha do Tempo",
|
||||
"agenda": "Agenda",
|
||||
"allResources": "Todos os Recursos"
|
||||
},
|
||||
"customers": {
|
||||
"title": "Clientes",
|
||||
"description": "Gerencie sua base de clientes e veja o histórico.",
|
||||
"addCustomer": "Adicionar Cliente",
|
||||
"editCustomer": "Editar Cliente",
|
||||
"customerDetails": "Detalhes do Cliente",
|
||||
"name": "Nome",
|
||||
"fullName": "Nome Completo",
|
||||
"email": "Email",
|
||||
"emailAddress": "Endereço de Email",
|
||||
"phone": "Telefone",
|
||||
"phoneNumber": "Número de Telefone",
|
||||
"address": "Endereço",
|
||||
"city": "Cidade",
|
||||
"state": "Estado",
|
||||
"zipCode": "CEP",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "ex. VIP, Indicação",
|
||||
"tagsCommaSeparated": "Tags (separadas por vírgula)",
|
||||
"appointmentHistory": "Histórico de Agendamentos",
|
||||
"noAppointments": "Nenhum agendamento ainda",
|
||||
"totalSpent": "Total Gasto",
|
||||
"totalSpend": "Gasto Total",
|
||||
"lastVisit": "Última Visita",
|
||||
"nextAppointment": "Próximo Agendamento",
|
||||
"contactInfo": "Informações de Contato",
|
||||
"status": "Status",
|
||||
"active": "Ativo",
|
||||
"inactive": "Inativo",
|
||||
"never": "Nunca",
|
||||
"customer": "Cliente",
|
||||
"searchPlaceholder": "Pesquisar por nome, email ou telefone...",
|
||||
"filters": "Filtros",
|
||||
"noCustomersFound": "Nenhum cliente encontrado com sua pesquisa.",
|
||||
"addNewCustomer": "Adicionar Novo Cliente",
|
||||
"createCustomer": "Criar Cliente",
|
||||
"errorLoading": "Erro ao carregar clientes"
|
||||
},
|
||||
"staff": {
|
||||
"title": "Equipe e Gestão",
|
||||
"description": "Gerencie contas de usuários e permissões.",
|
||||
"inviteStaff": "Convidar Equipe",
|
||||
"name": "Nome",
|
||||
"role": "Papel",
|
||||
"bookableResource": "Recurso Reservável",
|
||||
"makeBookable": "Tornar Reservável",
|
||||
"yes": "Sim",
|
||||
"errorLoading": "Erro ao carregar equipe",
|
||||
"inviteModalTitle": "Convidar Equipe",
|
||||
"inviteModalDescription": "O fluxo de convite de usuários iria aqui."
|
||||
},
|
||||
"resources": {
|
||||
"title": "Recursos",
|
||||
"description": "Gerencie sua equipe, salas e equipamentos.",
|
||||
"addResource": "Adicionar Recurso",
|
||||
"editResource": "Editar Recurso",
|
||||
"resourceDetails": "Detalhes do Recurso",
|
||||
"resourceName": "Nome do Recurso",
|
||||
"name": "Nome",
|
||||
"type": "Tipo",
|
||||
"resourceType": "Tipo de Recurso",
|
||||
"availability": "Disponibilidade",
|
||||
"services": "Serviços",
|
||||
"schedule": "Horário",
|
||||
"active": "Ativo",
|
||||
"inactive": "Inativo",
|
||||
"upcoming": "Próximos",
|
||||
"appointments": "agend.",
|
||||
"viewCalendar": "Ver Calendário",
|
||||
"noResourcesFound": "Nenhum recurso encontrado.",
|
||||
"addNewResource": "Adicionar Novo Recurso",
|
||||
"createResource": "Criar Recurso",
|
||||
"staffMember": "Membro da Equipe",
|
||||
"room": "Sala",
|
||||
"equipment": "Equipamento",
|
||||
"resourceNote": "Recursos são marcadores de posição para agendamento. A equipe pode ser atribuída aos agendamentos separadamente.",
|
||||
"errorLoading": "Erro ao carregar recursos"
|
||||
},
|
||||
"services": {
|
||||
"title": "Serviços",
|
||||
"addService": "Adicionar Serviço",
|
||||
"editService": "Editar Serviço",
|
||||
"name": "Nome",
|
||||
"description": "Descrição",
|
||||
"duration": "Duração",
|
||||
"price": "Preço",
|
||||
"category": "Categoria",
|
||||
"active": "Ativo"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Pagamentos",
|
||||
"transactions": "Transações",
|
||||
"invoices": "Faturas",
|
||||
"amount": "Valor",
|
||||
"status": "Status",
|
||||
"date": "Data",
|
||||
"method": "Método",
|
||||
"paid": "Pago",
|
||||
"unpaid": "Não Pago",
|
||||
"refunded": "Reembolsado",
|
||||
"pending": "Pendente",
|
||||
"viewDetails": "Ver Detalhes",
|
||||
"issueRefund": "Emitir Reembolso",
|
||||
"sendReminder": "Enviar Lembrete",
|
||||
"paymentSettings": "Configurações de Pagamento",
|
||||
"stripeConnect": "Stripe Connect",
|
||||
"apiKeys": "Chaves API"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
"businessSettings": "Configurações do Negócio",
|
||||
"businessSettingsDescription": "Gerencie sua marca, domínio e políticas.",
|
||||
"domainIdentity": "Domínio e Identidade",
|
||||
"bookingPolicy": "Política de Reservas e Cancelamento",
|
||||
"savedSuccessfully": "Configurações salvas com sucesso",
|
||||
"general": "Geral",
|
||||
"branding": "Marca",
|
||||
"notifications": "Notificações",
|
||||
"security": "Segurança",
|
||||
"integrations": "Integrações",
|
||||
"billing": "Cobrança",
|
||||
"businessName": "Nome do Negócio",
|
||||
"subdomain": "Subdomínio",
|
||||
"primaryColor": "Cor Primária",
|
||||
"secondaryColor": "Cor Secundária",
|
||||
"logo": "Logo",
|
||||
"uploadLogo": "Enviar Logo",
|
||||
"timezone": "Fuso Horário",
|
||||
"language": "Idioma",
|
||||
"currency": "Moeda",
|
||||
"dateFormat": "Formato de Data",
|
||||
"timeFormat": "Formato de Hora",
|
||||
"oauth": {
|
||||
"title": "Configurações OAuth",
|
||||
"enabledProviders": "Provedores Habilitados",
|
||||
"allowRegistration": "Permitir Registro via OAuth",
|
||||
"autoLinkByEmail": "Vincular contas automaticamente por email",
|
||||
"customCredentials": "Credenciais OAuth Personalizadas",
|
||||
"customCredentialsDesc": "Use suas próprias credenciais OAuth para uma experiência white-label",
|
||||
"platformCredentials": "Credenciais da Plataforma",
|
||||
"platformCredentialsDesc": "Usando credenciais OAuth fornecidas pela plataforma",
|
||||
"clientId": "ID do Cliente",
|
||||
"clientSecret": "Segredo do Cliente",
|
||||
"paidTierOnly": "Credenciais OAuth personalizadas estão disponíveis apenas para planos pagos"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Configurações de Perfil",
|
||||
"personalInfo": "Informações Pessoais",
|
||||
"changePassword": "Alterar Senha",
|
||||
"twoFactor": "Autenticação de Dois Fatores",
|
||||
"sessions": "Sessões Ativas",
|
||||
"emails": "Endereços de Email",
|
||||
"preferences": "Preferências",
|
||||
"currentPassword": "Senha Atual",
|
||||
"newPassword": "Nova Senha",
|
||||
"confirmPassword": "Confirmar Senha",
|
||||
"passwordChanged": "Senha alterada com sucesso",
|
||||
"enable2FA": "Habilitar Autenticação de Dois Fatores",
|
||||
"disable2FA": "Desabilitar Autenticação de Dois Fatores",
|
||||
"scanQRCode": "Escanear Código QR",
|
||||
"enterBackupCode": "Inserir Código de Backup",
|
||||
"recoveryCodes": "Códigos de Recuperação"
|
||||
},
|
||||
"platform": {
|
||||
"title": "Administração da Plataforma",
|
||||
"dashboard": "Painel da Plataforma",
|
||||
"overview": "Visão Geral da Plataforma",
|
||||
"overviewDescription": "Métricas globais de todos os inquilinos.",
|
||||
"mrrGrowth": "Crescimento MRR",
|
||||
"totalBusinesses": "Total de Negócios",
|
||||
"totalUsers": "Total de Usuários",
|
||||
"monthlyRevenue": "Receita Mensal",
|
||||
"activeSubscriptions": "Assinaturas Ativas",
|
||||
"recentSignups": "Cadastros Recentes",
|
||||
"supportTickets": "Tickets de Suporte",
|
||||
"supportDescription": "Resolver problemas relatados pelos inquilinos.",
|
||||
"reportedBy": "Relatado por",
|
||||
"priority": "Prioridade",
|
||||
"businessManagement": "Gestão de Negócios",
|
||||
"userManagement": "Gestão de Usuários",
|
||||
"masquerade": "Personificar",
|
||||
"masqueradeAs": "Personificar como",
|
||||
"exitMasquerade": "Sair da Personificação",
|
||||
"businesses": "Negócios",
|
||||
"businessesDescription": "Gerenciar inquilinos, planos e acessos.",
|
||||
"addNewTenant": "Adicionar Novo Inquilino",
|
||||
"searchBusinesses": "Pesquisar negócios...",
|
||||
"businessName": "Nome do Negócio",
|
||||
"subdomain": "Subdomínio",
|
||||
"plan": "Plano",
|
||||
"status": "Status",
|
||||
"joined": "Cadastrado em",
|
||||
"userDirectory": "Diretório de Usuários",
|
||||
"userDirectoryDescription": "Visualizar e gerenciar todos os usuários da plataforma.",
|
||||
"searchUsers": "Pesquisar usuários por nome ou email...",
|
||||
"allRoles": "Todos os Papéis",
|
||||
"user": "Usuário",
|
||||
"role": "Papel",
|
||||
"email": "Email",
|
||||
"noUsersFound": "Nenhum usuário encontrado com os filtros selecionados.",
|
||||
"roles": {
|
||||
"superuser": "Superusuário",
|
||||
"platformManager": "Gerente de Plataforma",
|
||||
"businessOwner": "Proprietário do Negócio",
|
||||
"staff": "Equipe",
|
||||
"customer": "Cliente"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações da Plataforma",
|
||||
"description": "Configurar ajustes e integrações da plataforma",
|
||||
"tiersPricing": "Níveis e Preços",
|
||||
"oauthProviders": "Provedores OAuth",
|
||||
"general": "Geral",
|
||||
"oauth": "Provedores OAuth",
|
||||
"payments": "Pagamentos",
|
||||
"email": "Email",
|
||||
"branding": "Marca"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Algo deu errado. Por favor, tente novamente.",
|
||||
"networkError": "Erro de rede. Por favor, verifique sua conexão.",
|
||||
"unauthorized": "Você não está autorizado a realizar esta ação.",
|
||||
"notFound": "O recurso solicitado não foi encontrado.",
|
||||
"validation": "Por favor, verifique sua entrada e tente novamente.",
|
||||
"businessNotFound": "Negócio Não Encontrado",
|
||||
"wrongLocation": "Localização Incorreta",
|
||||
"accessDenied": "Acesso Negado"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Este campo é obrigatório",
|
||||
"email": "Por favor, insira um endereço de email válido",
|
||||
"minLength": "Deve ter pelo menos {{min}} caracteres",
|
||||
"maxLength": "Deve ter no máximo {{max}} caracteres",
|
||||
"passwordMatch": "As senhas não coincidem",
|
||||
"invalidPhone": "Por favor, insira um número de telefone válido"
|
||||
},
|
||||
"time": {
|
||||
"minutes": "minutos",
|
||||
"hours": "horas",
|
||||
"days": "dias",
|
||||
"today": "Hoje",
|
||||
"tomorrow": "Amanhã",
|
||||
"yesterday": "Ontem",
|
||||
"thisWeek": "Esta Semana",
|
||||
"thisMonth": "Este Mês",
|
||||
"am": "AM",
|
||||
"pm": "PM"
|
||||
},
|
||||
"marketing": {
|
||||
"tagline": "Orquestre seu negócio com precisão.",
|
||||
"description": "A plataforma de agendamento completa para negócios de todos os tamanhos. Gerencie recursos, equipe e reservas sem esforço.",
|
||||
"copyright": "Smooth Schedule Inc.",
|
||||
"nav": {
|
||||
"features": "Recursos",
|
||||
"pricing": "Preços",
|
||||
"about": "Sobre",
|
||||
"contact": "Contato",
|
||||
"login": "Entrar",
|
||||
"getStarted": "Começar",
|
||||
"startFreeTrial": "Teste Grátis"
|
||||
},
|
||||
"hero": {
|
||||
"headline": "Agendamento Simplificado",
|
||||
"subheadline": "A plataforma completa para gerenciar compromissos, recursos e clientes. Comece grátis, escale conforme crescer.",
|
||||
"cta": "Começar Teste Grátis",
|
||||
"secondaryCta": "Ver Demo",
|
||||
"trustedBy": "Mais de 1.000 empresas confiam em nós"
|
||||
},
|
||||
"features": {
|
||||
"title": "Tudo que Você Precisa",
|
||||
"subtitle": "Recursos poderosos para seu negócio de serviços",
|
||||
"scheduling": {
|
||||
"title": "Agendamento Inteligente",
|
||||
"description": "Calendário arraste-e-solte com disponibilidade em tempo real, lembretes automáticos e detecção de conflitos."
|
||||
},
|
||||
"resources": {
|
||||
"title": "Gestão de Recursos",
|
||||
"description": "Gerencie equipe, salas e equipamentos. Configure disponibilidade, habilidades e regras de reserva."
|
||||
},
|
||||
"customers": {
|
||||
"title": "Portal do Cliente",
|
||||
"description": "Portal de autoatendimento para clientes. Visualize histórico, gerencie compromissos e salve métodos de pagamento."
|
||||
},
|
||||
"payments": {
|
||||
"title": "Pagamentos Integrados",
|
||||
"description": "Aceite pagamentos online com Stripe. Depósitos, pagamentos completos e faturamento automático."
|
||||
},
|
||||
"multiTenant": {
|
||||
"title": "Suporte Multi-Localização",
|
||||
"description": "Gerencie múltiplas localizações ou marcas de um único painel com dados isolados."
|
||||
},
|
||||
"whiteLabel": {
|
||||
"title": "Marca Branca",
|
||||
"description": "Domínio personalizado, branding e remova a marca SmoothSchedule para uma experiência perfeita."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Análises e Relatórios",
|
||||
"description": "Acompanhe receita, compromissos e tendências de clientes com dashboards bonitos."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrações Poderosas",
|
||||
"description": "Conecte com Google Calendar, Zoom, Stripe e mais. Acesso à API para integrações personalizadas."
|
||||
}
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "Comece em Minutos",
|
||||
"subtitle": "Três passos simples para transformar seu agendamento",
|
||||
"step1": {
|
||||
"title": "Crie Sua Conta",
|
||||
"description": "Cadastre-se gratuitamente e configure seu perfil de negócio em minutos."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Adicione Seus Serviços",
|
||||
"description": "Configure seus serviços, preços e recursos disponíveis."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Comece a Reservar",
|
||||
"description": "Compartilhe seu link de reserva e deixe os clientes agendarem instantaneamente."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Preços Simples e Transparentes",
|
||||
"subtitle": "Comece grátis, faça upgrade conforme crescer. Sem taxas ocultas.",
|
||||
"monthly": "Mensal",
|
||||
"annual": "Anual",
|
||||
"annualSave": "Economize 20%",
|
||||
"perMonth": "/mês",
|
||||
"period": "mês",
|
||||
"popular": "Mais Popular",
|
||||
"mostPopular": "Mais Popular",
|
||||
"getStarted": "Começar",
|
||||
"contactSales": "Contatar Vendas",
|
||||
"freeTrial": "14 dias de teste grátis",
|
||||
"noCredit": "Sem cartão de crédito",
|
||||
"features": "Recursos",
|
||||
"tiers": {
|
||||
"free": {
|
||||
"name": "Grátis",
|
||||
"description": "Perfeito para começar",
|
||||
"price": "0",
|
||||
"features": [
|
||||
"Até 2 recursos",
|
||||
"Agendamento básico",
|
||||
"Gestão de clientes",
|
||||
"Integração direta com Stripe",
|
||||
"Subdomínio (negocio.smoothschedule.com)",
|
||||
"Suporte da comunidade"
|
||||
],
|
||||
"transactionFee": "2,5% + R$1,50 por transação"
|
||||
},
|
||||
"professional": {
|
||||
"name": "Profissional",
|
||||
"description": "Para negócios em crescimento",
|
||||
"price": "29",
|
||||
"annualPrice": "290",
|
||||
"features": [
|
||||
"Até 10 recursos",
|
||||
"Domínio personalizado",
|
||||
"Stripe Connect (taxas menores)",
|
||||
"Marca branca",
|
||||
"Lembretes por email",
|
||||
"Suporte prioritário por email"
|
||||
],
|
||||
"transactionFee": "1,5% + R$1,25 por transação"
|
||||
},
|
||||
"business": {
|
||||
"name": "Empresarial",
|
||||
"description": "Para equipes estabelecidas",
|
||||
"price": "79",
|
||||
"annualPrice": "790",
|
||||
"features": [
|
||||
"Recursos ilimitados",
|
||||
"Todos os recursos Profissional",
|
||||
"Gestão de equipe",
|
||||
"Análises avançadas",
|
||||
"Acesso à API",
|
||||
"Suporte por telefone"
|
||||
],
|
||||
"transactionFee": "0,5% + R$1,00 por transação"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Corporativo",
|
||||
"description": "Para grandes organizações",
|
||||
"price": "Personalizado",
|
||||
"features": [
|
||||
"Todos os recursos Empresarial",
|
||||
"Integrações personalizadas",
|
||||
"Gerente de sucesso dedicado",
|
||||
"Garantias SLA",
|
||||
"Contratos personalizados",
|
||||
"Opção on-premise"
|
||||
],
|
||||
"transactionFee": "Taxas de transação personalizadas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"testimonials": {
|
||||
"title": "Amado por Empresas em Todo Lugar",
|
||||
"subtitle": "Veja o que nossos clientes dizem"
|
||||
},
|
||||
"stats": {
|
||||
"appointments": "Compromissos Agendados",
|
||||
"businesses": "Empresas",
|
||||
"countries": "Países",
|
||||
"uptime": "Disponibilidade"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Crie Sua Conta",
|
||||
"subtitle": "Comece seu teste grátis hoje. Sem cartão de crédito.",
|
||||
"steps": {
|
||||
"business": "Negócio",
|
||||
"account": "Conta",
|
||||
"plan": "Plano",
|
||||
"confirm": "Confirmar"
|
||||
},
|
||||
"businessInfo": {
|
||||
"title": "Conte-nos sobre seu negócio",
|
||||
"name": "Nome do Negócio",
|
||||
"namePlaceholder": "ex., Salão e Spa Acme",
|
||||
"subdomain": "Escolha Seu Subdomínio",
|
||||
"checking": "Verificando disponibilidade...",
|
||||
"available": "Disponível!",
|
||||
"taken": "Já está em uso"
|
||||
},
|
||||
"accountInfo": {
|
||||
"title": "Crie sua conta de administrador",
|
||||
"firstName": "Nome",
|
||||
"lastName": "Sobrenome",
|
||||
"email": "Endereço de Email",
|
||||
"password": "Senha",
|
||||
"confirmPassword": "Confirmar Senha"
|
||||
},
|
||||
"planSelection": {
|
||||
"title": "Escolha Seu Plano"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Revise Seus Dados",
|
||||
"business": "Negócio",
|
||||
"account": "Conta",
|
||||
"plan": "Plano Selecionado",
|
||||
"terms": "Ao criar sua conta, você concorda com nossos Termos de Serviço e Política de Privacidade."
|
||||
},
|
||||
"errors": {
|
||||
"businessNameRequired": "Nome do negócio é obrigatório",
|
||||
"subdomainRequired": "Subdomínio é obrigatório",
|
||||
"subdomainTooShort": "Subdomínio deve ter pelo menos 3 caracteres",
|
||||
"subdomainInvalid": "Subdomínio só pode conter letras minúsculas, números e hífens",
|
||||
"subdomainTaken": "Este subdomínio já está em uso",
|
||||
"firstNameRequired": "Nome é obrigatório",
|
||||
"lastNameRequired": "Sobrenome é obrigatório",
|
||||
"emailRequired": "Email é obrigatório",
|
||||
"emailInvalid": "Digite um endereço de email válido",
|
||||
"passwordRequired": "Senha é obrigatória",
|
||||
"passwordTooShort": "Senha deve ter pelo menos 8 caracteres",
|
||||
"passwordMismatch": "As senhas não coincidem",
|
||||
"generic": "Algo deu errado. Por favor, tente novamente."
|
||||
},
|
||||
"success": {
|
||||
"title": "Bem-vindo ao Smooth Schedule!",
|
||||
"message": "Sua conta foi criada com sucesso.",
|
||||
"yourUrl": "Sua URL de reserva",
|
||||
"checkEmail": "Enviamos um email de verificação. Por favor, verifique seu email para ativar todos os recursos.",
|
||||
"goToLogin": "Ir para Login"
|
||||
},
|
||||
"back": "Voltar",
|
||||
"next": "Próximo",
|
||||
"creating": "Criando conta...",
|
||||
"createAccount": "Criar Conta",
|
||||
"haveAccount": "Já tem uma conta?",
|
||||
"signIn": "Entrar"
|
||||
},
|
||||
"faq": {
|
||||
"title": "Perguntas Frequentes",
|
||||
"subtitle": "Tem perguntas? Temos respostas.",
|
||||
"questions": {
|
||||
"trial": {
|
||||
"question": "Vocês oferecem teste grátis?",
|
||||
"answer": "Sim! Todos os planos pagos incluem 14 dias de teste grátis. Sem cartão de crédito para começar."
|
||||
},
|
||||
"cancel": {
|
||||
"question": "Posso cancelar a qualquer momento?",
|
||||
"answer": "Absolutamente. Você pode cancelar sua assinatura a qualquer momento sem taxas de cancelamento."
|
||||
},
|
||||
"payment": {
|
||||
"question": "Quais métodos de pagamento vocês aceitam?",
|
||||
"answer": "Aceitamos todos os principais cartões de crédito através do Stripe, incluindo Visa, Mastercard e American Express."
|
||||
},
|
||||
"migrate": {
|
||||
"question": "Posso migrar de outra plataforma?",
|
||||
"answer": "Sim! Nossa equipe pode ajudar você a migrar seus dados existentes de outras plataformas de agendamento."
|
||||
},
|
||||
"support": {
|
||||
"question": "Que tipo de suporte vocês oferecem?",
|
||||
"answer": "O plano grátis inclui suporte da comunidade. Profissional e acima têm suporte por email, e Empresarial/Corporativo têm suporte por telefone."
|
||||
},
|
||||
"customDomain": {
|
||||
"question": "Como funcionam os domínios personalizados?",
|
||||
"answer": "Planos Profissional e acima podem usar seu próprio domínio (ex., reservas.seunegocio.com) em vez do nosso subdomínio."
|
||||
}
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre o Smooth Schedule",
|
||||
"subtitle": "Nossa missão é simplificar o agendamento para empresas em todos os lugares.",
|
||||
"story": {
|
||||
"title": "Nossa História",
|
||||
"content": "O Smooth Schedule foi fundado com uma crença simples: agendamento não deveria ser complicado. Construímos uma plataforma que facilita para empresas de todos os tamanhos gerenciar seus compromissos, recursos e clientes."
|
||||
},
|
||||
"mission": {
|
||||
"title": "Nossa Missão",
|
||||
"content": "Capacitar empresas de serviços com as ferramentas que precisam para crescer, enquanto dão a seus clientes uma experiência de reserva perfeita."
|
||||
},
|
||||
"values": {
|
||||
"title": "Nossos Valores",
|
||||
"simplicity": {
|
||||
"title": "Simplicidade",
|
||||
"description": "Acreditamos que software poderoso ainda pode ser simples de usar."
|
||||
},
|
||||
"reliability": {
|
||||
"title": "Confiabilidade",
|
||||
"description": "Seu negócio depende de nós, então nunca comprometemos a disponibilidade."
|
||||
},
|
||||
"transparency": {
|
||||
"title": "Transparência",
|
||||
"description": "Sem taxas ocultas, sem surpresas. O que você vê é o que você recebe."
|
||||
},
|
||||
"support": {
|
||||
"title": "Suporte",
|
||||
"description": "Estamos aqui para ajudá-lo a ter sucesso, a cada passo do caminho."
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "Entre em Contato",
|
||||
"subtitle": "Tem perguntas? Adoraríamos ouvir você.",
|
||||
"form": {
|
||||
"name": "Seu Nome",
|
||||
"namePlaceholder": "João Silva",
|
||||
"email": "Endereço de Email",
|
||||
"emailPlaceholder": "voce@exemplo.com",
|
||||
"subject": "Assunto",
|
||||
"subjectPlaceholder": "Como podemos ajudar?",
|
||||
"message": "Mensagem",
|
||||
"messagePlaceholder": "Conte-nos mais sobre suas necessidades...",
|
||||
"submit": "Enviar Mensagem",
|
||||
"sending": "Enviando...",
|
||||
"success": "Obrigado por nos contatar! Responderemos em breve.",
|
||||
"error": "Algo deu errado. Por favor, tente novamente."
|
||||
},
|
||||
"info": {
|
||||
"email": "suporte@smoothschedule.com",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"address": "123 Schedule Street, San Francisco, CA 94102"
|
||||
},
|
||||
"sales": {
|
||||
"title": "Fale com Vendas",
|
||||
"description": "Interessado em nosso plano Corporativo? Nossa equipe de vendas adoraria conversar."
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"ready": "Pronto para começar?",
|
||||
"readySubtitle": "Junte-se a milhares de empresas que já usam o SmoothSchedule.",
|
||||
"startFree": "Começar Teste Grátis",
|
||||
"noCredit": "Sem cartão de crédito"
|
||||
},
|
||||
"footer": {
|
||||
"product": "Produto",
|
||||
"company": "Empresa",
|
||||
"legal": "Legal",
|
||||
"features": "Recursos",
|
||||
"pricing": "Preços",
|
||||
"integrations": "Integrações",
|
||||
"about": "Sobre",
|
||||
"blog": "Blog",
|
||||
"careers": "Carreiras",
|
||||
"contact": "Contato",
|
||||
"terms": "Termos",
|
||||
"privacy": "Privacidade",
|
||||
"cookies": "Cookies",
|
||||
"allRightsReserved": "Todos os direitos reservados."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,713 +0,0 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
"save": "保存",
|
||||
"saveChanges": "保存更改",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"search": "搜索",
|
||||
"filter": "筛选",
|
||||
"actions": "操作",
|
||||
"settings": "设置",
|
||||
"reload": "重新加载",
|
||||
"viewAll": "查看全部",
|
||||
"learnMore": "了解更多",
|
||||
"poweredBy": "技术支持",
|
||||
"required": "必填",
|
||||
"optional": "可选",
|
||||
"masquerade": "模拟身份",
|
||||
"masqueradeAsUser": "模拟用户身份"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "登录",
|
||||
"signOut": "退出登录",
|
||||
"signingIn": "登录中...",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"enterUsername": "请输入用户名",
|
||||
"enterPassword": "请输入密码",
|
||||
"welcomeBack": "欢迎回来",
|
||||
"pleaseEnterDetails": "请输入您的信息以登录。",
|
||||
"authError": "认证错误",
|
||||
"invalidCredentials": "无效的凭据",
|
||||
"orContinueWith": "或使用以下方式登录",
|
||||
"loginAtSubdomain": "请在您的业务子域名登录。员工和客户不能从主站点登录。",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"rememberMe": "记住我",
|
||||
"twoFactorRequired": "需要双因素认证",
|
||||
"enterCode": "输入验证码",
|
||||
"verifyCode": "验证代码"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表板",
|
||||
"scheduler": "日程表",
|
||||
"customers": "客户",
|
||||
"resources": "资源",
|
||||
"payments": "支付",
|
||||
"messages": "消息",
|
||||
"staff": "员工",
|
||||
"businessSettings": "业务设置",
|
||||
"profile": "个人资料",
|
||||
"platformDashboard": "平台仪表板",
|
||||
"businesses": "企业",
|
||||
"users": "用户",
|
||||
"support": "支持",
|
||||
"platformSettings": "平台设置"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表板",
|
||||
"welcome": "欢迎,{{name}}!",
|
||||
"todayOverview": "今日概览",
|
||||
"upcomingAppointments": "即将到来的预约",
|
||||
"recentActivity": "最近活动",
|
||||
"quickActions": "快捷操作",
|
||||
"totalRevenue": "总收入",
|
||||
"totalAppointments": "预约总数",
|
||||
"newCustomers": "新客户",
|
||||
"pendingPayments": "待处理付款"
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "日程表",
|
||||
"newAppointment": "新建预约",
|
||||
"editAppointment": "编辑预约",
|
||||
"deleteAppointment": "删除预约",
|
||||
"selectResource": "选择资源",
|
||||
"selectService": "选择服务",
|
||||
"selectCustomer": "选择客户",
|
||||
"selectDate": "选择日期",
|
||||
"selectTime": "选择时间",
|
||||
"duration": "时长",
|
||||
"notes": "备注",
|
||||
"status": "状态",
|
||||
"confirmed": "已确认",
|
||||
"pending": "待处理",
|
||||
"cancelled": "已取消",
|
||||
"completed": "已完成",
|
||||
"noShow": "未到场",
|
||||
"today": "今天",
|
||||
"week": "周",
|
||||
"month": "月",
|
||||
"day": "日",
|
||||
"timeline": "时间线",
|
||||
"agenda": "议程",
|
||||
"allResources": "所有资源"
|
||||
},
|
||||
"customers": {
|
||||
"title": "客户",
|
||||
"description": "管理您的客户群并查看历史记录。",
|
||||
"addCustomer": "添加客户",
|
||||
"editCustomer": "编辑客户",
|
||||
"customerDetails": "客户详情",
|
||||
"name": "姓名",
|
||||
"fullName": "全名",
|
||||
"email": "邮箱",
|
||||
"emailAddress": "邮箱地址",
|
||||
"phone": "电话",
|
||||
"phoneNumber": "电话号码",
|
||||
"address": "地址",
|
||||
"city": "城市",
|
||||
"state": "省份",
|
||||
"zipCode": "邮编",
|
||||
"tags": "标签",
|
||||
"tagsPlaceholder": "例如:VIP, 推荐",
|
||||
"tagsCommaSeparated": "标签(逗号分隔)",
|
||||
"appointmentHistory": "预约历史",
|
||||
"noAppointments": "暂无预约",
|
||||
"totalSpent": "总消费",
|
||||
"totalSpend": "消费总额",
|
||||
"lastVisit": "上次访问",
|
||||
"nextAppointment": "下次预约",
|
||||
"contactInfo": "联系方式",
|
||||
"status": "状态",
|
||||
"active": "活跃",
|
||||
"inactive": "未活跃",
|
||||
"never": "从未",
|
||||
"customer": "客户",
|
||||
"searchPlaceholder": "按姓名、邮箱或电话搜索...",
|
||||
"filters": "筛选",
|
||||
"noCustomersFound": "未找到符合搜索条件的客户。",
|
||||
"addNewCustomer": "添加新客户",
|
||||
"createCustomer": "创建客户",
|
||||
"errorLoading": "加载客户时出错"
|
||||
},
|
||||
"staff": {
|
||||
"title": "员工与管理",
|
||||
"description": "管理用户账户和权限。",
|
||||
"inviteStaff": "邀请员工",
|
||||
"name": "姓名",
|
||||
"role": "角色",
|
||||
"bookableResource": "可预约资源",
|
||||
"makeBookable": "设为可预约",
|
||||
"yes": "是",
|
||||
"errorLoading": "加载员工时出错",
|
||||
"inviteModalTitle": "邀请员工",
|
||||
"inviteModalDescription": "用户邀请流程将在此处。"
|
||||
},
|
||||
"resources": {
|
||||
"title": "资源",
|
||||
"description": "管理您的员工、房间和设备。",
|
||||
"addResource": "添加资源",
|
||||
"editResource": "编辑资源",
|
||||
"resourceDetails": "资源详情",
|
||||
"resourceName": "资源名称",
|
||||
"name": "名称",
|
||||
"type": "类型",
|
||||
"resourceType": "资源类型",
|
||||
"availability": "可用性",
|
||||
"services": "服务",
|
||||
"schedule": "时间表",
|
||||
"active": "活跃",
|
||||
"inactive": "未活跃",
|
||||
"upcoming": "即将到来",
|
||||
"appointments": "预约",
|
||||
"viewCalendar": "查看日历",
|
||||
"noResourcesFound": "未找到资源。",
|
||||
"addNewResource": "添加新资源",
|
||||
"createResource": "创建资源",
|
||||
"staffMember": "员工",
|
||||
"room": "房间",
|
||||
"equipment": "设备",
|
||||
"resourceNote": "资源是用于日程安排的占位符。员工可以单独分配到预约。",
|
||||
"errorLoading": "加载资源时出错"
|
||||
},
|
||||
"services": {
|
||||
"title": "服务",
|
||||
"addService": "添加服务",
|
||||
"editService": "编辑服务",
|
||||
"name": "名称",
|
||||
"description": "描述",
|
||||
"duration": "时长",
|
||||
"price": "价格",
|
||||
"category": "类别",
|
||||
"active": "活跃"
|
||||
},
|
||||
"payments": {
|
||||
"title": "支付",
|
||||
"transactions": "交易",
|
||||
"invoices": "发票",
|
||||
"amount": "金额",
|
||||
"status": "状态",
|
||||
"date": "日期",
|
||||
"method": "方式",
|
||||
"paid": "已支付",
|
||||
"unpaid": "未支付",
|
||||
"refunded": "已退款",
|
||||
"pending": "待处理",
|
||||
"viewDetails": "查看详情",
|
||||
"issueRefund": "发起退款",
|
||||
"sendReminder": "发送提醒",
|
||||
"paymentSettings": "支付设置",
|
||||
"stripeConnect": "Stripe Connect",
|
||||
"apiKeys": "API密钥"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"businessSettings": "业务设置",
|
||||
"businessSettingsDescription": "管理您的品牌、域名和政策。",
|
||||
"domainIdentity": "域名和身份",
|
||||
"bookingPolicy": "预订和取消政策",
|
||||
"savedSuccessfully": "设置保存成功",
|
||||
"general": "常规",
|
||||
"branding": "品牌",
|
||||
"notifications": "通知",
|
||||
"security": "安全",
|
||||
"integrations": "集成",
|
||||
"billing": "账单",
|
||||
"businessName": "企业名称",
|
||||
"subdomain": "子域名",
|
||||
"primaryColor": "主色调",
|
||||
"secondaryColor": "副色调",
|
||||
"logo": "标志",
|
||||
"uploadLogo": "上传标志",
|
||||
"timezone": "时区",
|
||||
"language": "语言",
|
||||
"currency": "货币",
|
||||
"dateFormat": "日期格式",
|
||||
"timeFormat": "时间格式",
|
||||
"oauth": {
|
||||
"title": "OAuth设置",
|
||||
"enabledProviders": "已启用的提供商",
|
||||
"allowRegistration": "允许通过OAuth注册",
|
||||
"autoLinkByEmail": "通过邮箱自动关联账户",
|
||||
"customCredentials": "自定义OAuth凭据",
|
||||
"customCredentialsDesc": "使用您自己的OAuth凭据以获得白标体验",
|
||||
"platformCredentials": "平台凭据",
|
||||
"platformCredentialsDesc": "使用平台提供的OAuth凭据",
|
||||
"clientId": "客户端ID",
|
||||
"clientSecret": "客户端密钥",
|
||||
"paidTierOnly": "自定义OAuth凭据仅适用于付费计划"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "个人资料设置",
|
||||
"personalInfo": "个人信息",
|
||||
"changePassword": "更改密码",
|
||||
"twoFactor": "双因素认证",
|
||||
"sessions": "活跃会话",
|
||||
"emails": "邮箱地址",
|
||||
"preferences": "偏好设置",
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"enable2FA": "启用双因素认证",
|
||||
"disable2FA": "禁用双因素认证",
|
||||
"scanQRCode": "扫描二维码",
|
||||
"enterBackupCode": "输入备用代码",
|
||||
"recoveryCodes": "恢复代码"
|
||||
},
|
||||
"platform": {
|
||||
"title": "平台管理",
|
||||
"dashboard": "平台仪表板",
|
||||
"overview": "平台概览",
|
||||
"overviewDescription": "所有租户的全局指标。",
|
||||
"mrrGrowth": "MRR增长",
|
||||
"totalBusinesses": "企业总数",
|
||||
"totalUsers": "用户总数",
|
||||
"monthlyRevenue": "月收入",
|
||||
"activeSubscriptions": "活跃订阅",
|
||||
"recentSignups": "最近注册",
|
||||
"supportTickets": "支持工单",
|
||||
"supportDescription": "解决租户报告的问题。",
|
||||
"reportedBy": "报告人",
|
||||
"priority": "优先级",
|
||||
"businessManagement": "企业管理",
|
||||
"userManagement": "用户管理",
|
||||
"masquerade": "模拟身份",
|
||||
"masqueradeAs": "模拟为",
|
||||
"exitMasquerade": "退出模拟",
|
||||
"businesses": "企业",
|
||||
"businessesDescription": "管理租户、计划和访问权限。",
|
||||
"addNewTenant": "添加新租户",
|
||||
"searchBusinesses": "搜索企业...",
|
||||
"businessName": "企业名称",
|
||||
"subdomain": "子域名",
|
||||
"plan": "计划",
|
||||
"status": "状态",
|
||||
"joined": "加入时间",
|
||||
"userDirectory": "用户目录",
|
||||
"userDirectoryDescription": "查看和管理平台上的所有用户。",
|
||||
"searchUsers": "按姓名或邮箱搜索用户...",
|
||||
"allRoles": "所有角色",
|
||||
"user": "用户",
|
||||
"role": "角色",
|
||||
"email": "邮箱",
|
||||
"noUsersFound": "未找到符合筛选条件的用户。",
|
||||
"roles": {
|
||||
"superuser": "超级用户",
|
||||
"platformManager": "平台管理员",
|
||||
"businessOwner": "企业所有者",
|
||||
"staff": "员工",
|
||||
"customer": "客户"
|
||||
},
|
||||
"settings": {
|
||||
"title": "平台设置",
|
||||
"description": "配置平台范围的设置和集成",
|
||||
"tiersPricing": "等级和定价",
|
||||
"oauthProviders": "OAuth提供商",
|
||||
"general": "常规",
|
||||
"oauth": "OAuth提供商",
|
||||
"payments": "支付",
|
||||
"email": "邮件",
|
||||
"branding": "品牌"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"generic": "出现错误。请重试。",
|
||||
"networkError": "网络错误。请检查您的连接。",
|
||||
"unauthorized": "您无权执行此操作。",
|
||||
"notFound": "未找到请求的资源。",
|
||||
"validation": "请检查您的输入并重试。",
|
||||
"businessNotFound": "未找到企业",
|
||||
"wrongLocation": "位置错误",
|
||||
"accessDenied": "访问被拒绝"
|
||||
},
|
||||
"validation": {
|
||||
"required": "此字段为必填项",
|
||||
"email": "请输入有效的邮箱地址",
|
||||
"minLength": "至少需要{{min}}个字符",
|
||||
"maxLength": "最多允许{{max}}个字符",
|
||||
"passwordMatch": "密码不匹配",
|
||||
"invalidPhone": "请输入有效的电话号码"
|
||||
},
|
||||
"time": {
|
||||
"minutes": "分钟",
|
||||
"hours": "小时",
|
||||
"days": "天",
|
||||
"today": "今天",
|
||||
"tomorrow": "明天",
|
||||
"yesterday": "昨天",
|
||||
"thisWeek": "本周",
|
||||
"thisMonth": "本月",
|
||||
"am": "上午",
|
||||
"pm": "下午"
|
||||
},
|
||||
"marketing": {
|
||||
"tagline": "精准管理您的业务。",
|
||||
"description": "适用于各种规模企业的一体化日程管理平台。轻松管理资源、员工和预约。",
|
||||
"copyright": "Smooth Schedule Inc.",
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"features": "功能",
|
||||
"pricing": "价格",
|
||||
"about": "关于我们",
|
||||
"contact": "联系我们",
|
||||
"login": "登录",
|
||||
"signup": "注册",
|
||||
"getStarted": "开始使用"
|
||||
},
|
||||
"hero": {
|
||||
"title": "简化您的日程安排",
|
||||
"subtitle": "强大的预约调度平台,专为现代企业打造。高效管理预订、资源和客户。",
|
||||
"cta": {
|
||||
"primary": "免费开始",
|
||||
"secondary": "观看演示"
|
||||
},
|
||||
"trustedBy": "受到众多企业信赖"
|
||||
},
|
||||
"features": {
|
||||
"title": "强大功能助力您的业务增长",
|
||||
"subtitle": "提升日程管理效率、改善客户体验所需的一切工具。",
|
||||
"scheduling": {
|
||||
"title": "智能日程安排",
|
||||
"description": "先进的预约系统,配备冲突检测、重复预约和自动提醒功能。"
|
||||
},
|
||||
"resources": {
|
||||
"title": "资源管理",
|
||||
"description": "高效管理员工、房间和设备。优化资源利用率并防止重复预订。"
|
||||
},
|
||||
"customers": {
|
||||
"title": "客户管理",
|
||||
"description": "详细的客户档案、预约历史和通讯工具,帮助建立良好客户关系。"
|
||||
},
|
||||
"payments": {
|
||||
"title": "集成支付",
|
||||
"description": "通过 Stripe 安全处理支付。支持押金、发票和自动账单。"
|
||||
},
|
||||
"multiTenant": {
|
||||
"title": "多租户架构",
|
||||
"description": "每个企业拥有独立子域名。完全的数据隔离和自定义品牌设置。"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "分析与报告",
|
||||
"description": "深入了解您的业务,包含预订趋势、收入报告和客户分析。"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "日历同步",
|
||||
"description": "与 Google 日历、Outlook 等同步。自动双向同步,无需手动更新。"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "自动通知",
|
||||
"description": "邮件和短信提醒让客户和员工了解预约信息。"
|
||||
},
|
||||
"customization": {
|
||||
"title": "深度定制",
|
||||
"description": "根据品牌定制外观,配置工作时间,设置自定义预订规则。"
|
||||
}
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "如何使用",
|
||||
"subtitle": "几分钟内即可开始,操作简单便捷。",
|
||||
"step1": {
|
||||
"title": "创建账户",
|
||||
"description": "免费注册,获得专属企业子域名。无需信用卡。"
|
||||
},
|
||||
"step2": {
|
||||
"title": "配置服务",
|
||||
"description": "添加您的服务、员工和可用时间。根据需求定制系统。"
|
||||
},
|
||||
"step3": {
|
||||
"title": "开始接受预约",
|
||||
"description": "分享您的预订链接,让客户开始预约。提供实时更新和自动通知。"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"businesses": "企业信赖",
|
||||
"appointments": "已处理预约",
|
||||
"uptime": "系统稳定性",
|
||||
"support": "客户支持"
|
||||
},
|
||||
"testimonials": {
|
||||
"title": "客户评价",
|
||||
"subtitle": "了解其他企业如何使用 SmoothSchedule。",
|
||||
"testimonial1": {
|
||||
"content": "SmoothSchedule 彻底改变了我们的预约管理流程。多资源日程功能对我们团队来说是革命性的突破。",
|
||||
"author": "王明",
|
||||
"role": "美发沙龙老板"
|
||||
},
|
||||
"testimonial2": {
|
||||
"content": "我们每周节省数小时的管理时间。客户也喜欢便捷的在线预约体验。",
|
||||
"author": "李华",
|
||||
"role": "健身工作室经理"
|
||||
},
|
||||
"testimonial3": {
|
||||
"content": "支付集成完美无缝。我们再也不用追讨欠款或进行烦人的付款跟进了。",
|
||||
"author": "张伟",
|
||||
"role": "诊所管理员"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"title": "透明简洁的定价方案",
|
||||
"subtitle": "选择适合您业务规模的方案。随时升级或降级。",
|
||||
"period": "/月",
|
||||
"popular": "最受欢迎",
|
||||
"free": {
|
||||
"name": "免费版",
|
||||
"price": "¥0",
|
||||
"description": "适合个人和小型企业起步使用。",
|
||||
"features": [
|
||||
"1 个资源/员工",
|
||||
"最多 50 个预约/月",
|
||||
"基础日历视图",
|
||||
"邮件通知",
|
||||
"标准支持"
|
||||
],
|
||||
"cta": "免费开始"
|
||||
},
|
||||
"professional": {
|
||||
"name": "专业版",
|
||||
"price": "¥199",
|
||||
"description": "适合成长中的企业,需要更多功能。",
|
||||
"features": [
|
||||
"最多 5 个资源/员工",
|
||||
"无限预约",
|
||||
"高级日程视图",
|
||||
"邮件和短信通知",
|
||||
"支付处理",
|
||||
"自定义品牌",
|
||||
"优先支持"
|
||||
],
|
||||
"cta": "开始免费试用"
|
||||
},
|
||||
"business": {
|
||||
"name": "商业版",
|
||||
"price": "¥549",
|
||||
"description": "适合多地点或团队的企业。",
|
||||
"features": [
|
||||
"无限资源/员工",
|
||||
"无限预约",
|
||||
"所有专业版功能",
|
||||
"多地点支持",
|
||||
"高级分析",
|
||||
"API 访问",
|
||||
"专属客户经理",
|
||||
"自定义集成"
|
||||
],
|
||||
"cta": "联系销售"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "企业版",
|
||||
"price": "定制",
|
||||
"description": "为大型组织提供定制方案。",
|
||||
"features": [
|
||||
"所有商业版功能",
|
||||
"自定义开发",
|
||||
"本地化部署选项",
|
||||
"SLA 保障",
|
||||
"专属技术支持",
|
||||
"培训和入职服务",
|
||||
"安全审计",
|
||||
"合规支持"
|
||||
],
|
||||
"cta": "联系我们"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "准备好简化您的日程管理了吗?",
|
||||
"subtitle": "加入数千家已经使用 SmoothSchedule 优化运营的企业。",
|
||||
"button": "免费开始",
|
||||
"noCreditCard": "无需信用卡。免费版永久免费。"
|
||||
},
|
||||
"faq": {
|
||||
"title": "常见问题",
|
||||
"subtitle": "找到关于 SmoothSchedule 的常见问题解答。",
|
||||
"q1": {
|
||||
"question": "可以免费试用吗?",
|
||||
"answer": "是的!我们提供功能完整的免费版本,无时间限制。您也可以试用付费版14天的所有高级功能。"
|
||||
},
|
||||
"q2": {
|
||||
"question": "如何计算定价?",
|
||||
"answer": "定价基于您需要的资源数量(员工、房间、设备)。所有方案均包含无限客户和按方案限制的预约数量。"
|
||||
},
|
||||
"q3": {
|
||||
"question": "我可以随时取消吗?",
|
||||
"answer": "可以,您可以随时取消订阅。无长期合约。取消后,您的方案将保持到当前账单周期结束。"
|
||||
},
|
||||
"q4": {
|
||||
"question": "你们支持哪些支付方式?",
|
||||
"answer": "我们通过 Stripe 支持所有主要的信用卡和借记卡。企业版可使用发票和银行转账支付。"
|
||||
},
|
||||
"q5": {
|
||||
"question": "可以从其他日程软件迁移数据吗?",
|
||||
"answer": "可以!我们提供从大多数流行日程软件的数据导入工具。我们的团队也可以协助手动迁移。"
|
||||
},
|
||||
"q6": {
|
||||
"question": "我的数据安全吗?",
|
||||
"answer": "绝对安全。我们使用银行级加密、符合 SOC 2 标准,并定期进行安全审计。您的数据由完全隔离的多租户架构保护。"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"product": {
|
||||
"title": "产品",
|
||||
"features": "功能",
|
||||
"pricing": "价格",
|
||||
"integrations": "集成",
|
||||
"changelog": "更新日志"
|
||||
},
|
||||
"company": {
|
||||
"title": "公司",
|
||||
"about": "关于我们",
|
||||
"blog": "博客",
|
||||
"careers": "招聘",
|
||||
"contact": "联系我们"
|
||||
},
|
||||
"resources": {
|
||||
"title": "资源",
|
||||
"documentation": "文档",
|
||||
"helpCenter": "帮助中心",
|
||||
"guides": "指南",
|
||||
"apiReference": "API 参考"
|
||||
},
|
||||
"legal": {
|
||||
"title": "法律条款",
|
||||
"privacy": "隐私政策",
|
||||
"terms": "服务条款",
|
||||
"cookies": "Cookie 政策"
|
||||
},
|
||||
"social": {
|
||||
"title": "关注我们"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "关于 SmoothSchedule",
|
||||
"subtitle": "我们正在构建日程管理软件的未来。",
|
||||
"mission": {
|
||||
"title": "我们的使命",
|
||||
"description": "让各类企业都能轻松管理时间和预约,帮助从业者专注于最重要的事情——服务客户。"
|
||||
},
|
||||
"story": {
|
||||
"title": "我们的故事",
|
||||
"description": "SmoothSchedule 诞生于一个简单的挫折——预约安排太复杂了。创始人在经营服务业务时,经历了笨拙的日程系统、频繁的重复预订和效率低下的工作流程后,决定打造更好的解决方案。"
|
||||
},
|
||||
"team": {
|
||||
"title": "我们的团队",
|
||||
"description": "我们是一支由工程师、设计师和客户成功专家组成的团队,致力于让日程管理对每个人都变得轻松。"
|
||||
},
|
||||
"values": {
|
||||
"title": "我们的价值观",
|
||||
"simplicity": {
|
||||
"title": "简洁",
|
||||
"description": "我们相信软件应该简单易用。没有臃肿的功能,只有真正有价值的工具。"
|
||||
},
|
||||
"reliability": {
|
||||
"title": "可靠",
|
||||
"description": "您的业务依赖于我们。我们认真对待这份责任,提供99.9%的正常运行时间。"
|
||||
},
|
||||
"customerFocus": {
|
||||
"title": "客户至上",
|
||||
"description": "每一个功能决策都从'这如何帮助我们的用户?'开始。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "联系我们",
|
||||
"subtitle": "有问题或反馈?我们很乐意听取您的意见。",
|
||||
"form": {
|
||||
"name": "姓名",
|
||||
"email": "邮箱",
|
||||
"subject": "主题",
|
||||
"message": "留言",
|
||||
"submit": "发送消息",
|
||||
"sending": "发送中...",
|
||||
"success": "感谢您的留言!我们会尽快回复您。",
|
||||
"error": "发送消息时出现问题。请重试。"
|
||||
},
|
||||
"info": {
|
||||
"title": "联系方式",
|
||||
"email": "support@smoothschedule.com",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"address": "旧金山市场街123号,CA 94102"
|
||||
},
|
||||
"hours": {
|
||||
"title": "工作时间",
|
||||
"weekdays": "周一至周五:上午9点 - 下午6点(太平洋时间)",
|
||||
"weekend": "周末:邮件支持"
|
||||
}
|
||||
},
|
||||
"signup": {
|
||||
"title": "创建您的账户",
|
||||
"subtitle": "免费开始,几分钟内即可上线。",
|
||||
"steps": {
|
||||
"business": "企业",
|
||||
"account": "账户",
|
||||
"plan": "方案",
|
||||
"confirm": "确认"
|
||||
},
|
||||
"businessInfo": {
|
||||
"title": "您的企业信息",
|
||||
"name": "企业名称",
|
||||
"namePlaceholder": "Acme 公司",
|
||||
"subdomain": "选择您的子域名",
|
||||
"subdomainPlaceholder": "acme",
|
||||
"subdomainSuffix": ".smoothschedule.com",
|
||||
"checking": "检查中...",
|
||||
"available": "子域名可用!",
|
||||
"taken": "子域名已被占用"
|
||||
},
|
||||
"accountInfo": {
|
||||
"title": "创建您的账户",
|
||||
"firstName": "名字",
|
||||
"lastName": "姓氏",
|
||||
"email": "邮箱地址",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码"
|
||||
},
|
||||
"planSelection": {
|
||||
"title": "选择您的方案",
|
||||
"subtitle": "您可以随时更改方案。"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "确认您的详细信息",
|
||||
"business": "企业",
|
||||
"account": "账户",
|
||||
"plan": "方案",
|
||||
"terms": "创建账户即表示您同意我们的",
|
||||
"termsLink": "服务条款",
|
||||
"and": "和",
|
||||
"privacyLink": "隐私政策",
|
||||
"submit": "创建账户",
|
||||
"creating": "创建中..."
|
||||
},
|
||||
"errors": {
|
||||
"businessNameRequired": "请输入企业名称",
|
||||
"subdomainRequired": "请输入子域名",
|
||||
"subdomainInvalid": "子域名只能包含小写字母、数字和连字符",
|
||||
"subdomainTaken": "此子域名已被占用",
|
||||
"firstNameRequired": "请输入名字",
|
||||
"lastNameRequired": "请输入姓氏",
|
||||
"emailRequired": "请输入邮箱地址",
|
||||
"emailInvalid": "请输入有效的邮箱地址",
|
||||
"passwordRequired": "请输入密码",
|
||||
"passwordTooShort": "密码至少需要8个字符",
|
||||
"passwordMismatch": "两次密码输入不匹配",
|
||||
"signupFailed": "创建账户失败,请重试"
|
||||
},
|
||||
"success": {
|
||||
"title": "欢迎使用 SmoothSchedule!",
|
||||
"message": "您的账户已创建成功。",
|
||||
"yourUrl": "您的企业网址",
|
||||
"checkEmail": "我们已向您发送验证邮件。",
|
||||
"goToLogin": "前往登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import { Outlet, useLocation, useSearchParams, useNavigate } from 'react-router-
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import TopBar from '../components/TopBar';
|
||||
import TrialBanner from '../components/TrialBanner';
|
||||
import SandboxBanner from '../components/SandboxBanner';
|
||||
import { Business, User } from '../types';
|
||||
import MasqueradeBanner from '../components/MasqueradeBanner';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
import TicketModal from '../components/TicketModal';
|
||||
import { useStopMasquerade } from '../hooks/useAuth';
|
||||
import { useNotificationWebSocket } from '../hooks/useNotificationWebSocket'; // Import the new hook
|
||||
import { useNotificationWebSocket } from '../hooks/useNotificationWebSocket';
|
||||
import { useTicket } from '../hooks/useTickets';
|
||||
import { MasqueradeStackEntry } from '../api/auth';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
import { SandboxProvider, useSandbox } from '../contexts/SandboxContext';
|
||||
|
||||
/**
|
||||
* Convert a hex color to HSL values
|
||||
@@ -102,10 +106,26 @@ interface BusinessLayoutProps {
|
||||
updateBusiness: (updates: Partial<Business>) => void;
|
||||
}
|
||||
|
||||
const BusinessLayout: React.FC<BusinessLayoutProps> = ({ business, user, darkMode, toggleTheme, onSignOut, updateBusiness }) => {
|
||||
/**
|
||||
* Wrapper component for SandboxBanner that uses the sandbox context
|
||||
*/
|
||||
const SandboxBannerWrapper: React.FC = () => {
|
||||
const { isSandbox, toggleSandbox, isToggling } = useSandbox();
|
||||
|
||||
return (
|
||||
<SandboxBanner
|
||||
isSandbox={isSandbox}
|
||||
onSwitchToLive={() => toggleSandbox(false)}
|
||||
isSwitching={isToggling}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user, darkMode, toggleTheme, onSignOut, updateBusiness }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [ticketModalId, setTicketModalId] = useState<string | null>(null);
|
||||
const mainContentRef = useRef<HTMLElement>(null);
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -113,6 +133,17 @@ const BusinessLayout: React.FC<BusinessLayoutProps> = ({ business, user, darkMod
|
||||
|
||||
useScrollToTop();
|
||||
|
||||
// Fetch ticket data when modal is opened from notification
|
||||
const { data: ticketFromNotification } = useTicket(ticketModalId || undefined);
|
||||
|
||||
const handleTicketClick = (ticketId: string) => {
|
||||
setTicketModalId(ticketId);
|
||||
};
|
||||
|
||||
const closeTicketModal = () => {
|
||||
setTicketModalId(null);
|
||||
};
|
||||
|
||||
// Generate brand color palette from business primary color
|
||||
const brandPalette = useMemo(() => {
|
||||
return generateColorPalette(business.primaryColor || '#2563eb');
|
||||
@@ -252,6 +283,8 @@ const BusinessLayout: React.FC<BusinessLayoutProps> = ({ business, user, darkMod
|
||||
onStop={handleStopMasquerade}
|
||||
/>
|
||||
)}
|
||||
{/* Sandbox mode banner */}
|
||||
<SandboxBannerWrapper />
|
||||
{/* Show trial banner if trial is active and payments not yet enabled */}
|
||||
{business.isTrialActive && !business.paymentsEnabled && business.plan !== 'Free' && (
|
||||
<TrialBanner business={business} />
|
||||
@@ -261,6 +294,7 @@ const BusinessLayout: React.FC<BusinessLayoutProps> = ({ business, user, darkMod
|
||||
isDarkMode={darkMode}
|
||||
toggleTheme={toggleTheme}
|
||||
onMenuClick={() => setIsMobileMenuOpen(true)}
|
||||
onTicketClick={handleTicketClick}
|
||||
/>
|
||||
|
||||
<main ref={mainContentRef} tabIndex={-1} className="flex-1 overflow-auto focus:outline-none">
|
||||
@@ -277,8 +311,27 @@ const BusinessLayout: React.FC<BusinessLayoutProps> = ({ business, user, darkMod
|
||||
onSkip={handleOnboardingSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ticket modal opened from notification */}
|
||||
{ticketModalId && ticketFromNotification && (
|
||||
<TicketModal
|
||||
ticket={ticketFromNotification}
|
||||
onClose={closeTicketModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Business Layout with Sandbox Provider
|
||||
*/
|
||||
const BusinessLayout: React.FC<BusinessLayoutProps> = (props) => {
|
||||
return (
|
||||
<SandboxProvider>
|
||||
<BusinessLayoutContent {...props} />
|
||||
</SandboxProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessLayout;
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||
import { User, Business } from '../types';
|
||||
import { LayoutDashboard, CalendarPlus, CreditCard } from 'lucide-react';
|
||||
import { LayoutDashboard, CalendarPlus, CreditCard, HelpCircle, Sun, Moon } from 'lucide-react';
|
||||
import MasqueradeBanner from '../components/MasqueradeBanner';
|
||||
import UserProfileDropdown from '../components/UserProfileDropdown';
|
||||
import NotificationDropdown from '../components/NotificationDropdown';
|
||||
import { useStopMasquerade } from '../hooks/useAuth';
|
||||
import { MasqueradeStackEntry } from '../api/auth';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
@@ -11,15 +12,24 @@ import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
interface CustomerLayoutProps {
|
||||
business: Business;
|
||||
user: User;
|
||||
darkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const CustomerLayout: React.FC<CustomerLayoutProps> = ({ business, user }) => {
|
||||
const CustomerLayout: React.FC<CustomerLayoutProps> = ({ business, user, darkMode, toggleTheme }) => {
|
||||
const navigate = useNavigate();
|
||||
useScrollToTop();
|
||||
|
||||
// Masquerade logic
|
||||
const [masqueradeStack, setMasqueradeStack] = useState<MasqueradeStackEntry[]>([]);
|
||||
const stopMasqueradeMutation = useStopMasquerade();
|
||||
|
||||
// Handle ticket notification click - navigate to support page
|
||||
const handleTicketClick = (ticketId: string) => {
|
||||
// Navigate to support page - the CustomerSupport component will handle showing tickets
|
||||
navigate('/support');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stackJson = localStorage.getItem('masquerade_stack');
|
||||
if (stackJson) {
|
||||
@@ -84,8 +94,23 @@ const CustomerLayout: React.FC<CustomerLayoutProps> = ({ business, user }) => {
|
||||
<Link to="/payments" className="text-sm font-medium text-white/80 hover:text-white transition-colors flex items-center gap-2 px-3 py-2 rounded-md hover:bg-white/10">
|
||||
<CreditCard size={16} /> Billing
|
||||
</Link>
|
||||
<Link to="/support" className="text-sm font-medium text-white/80 hover:text-white transition-colors flex items-center gap-2 px-3 py-2 rounded-md hover:bg-white/10">
|
||||
<HelpCircle size={16} /> Support
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationDropdown variant="light" onTicketClick={handleTicketClick} />
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-md text-white/80 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
|
||||
<UserProfileDropdown user={user} variant="light" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Moon, Sun, Bell, Globe, Menu } from 'lucide-react';
|
||||
import { Moon, Sun, Globe, Menu } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import PlatformSidebar from '../components/PlatformSidebar';
|
||||
import UserProfileDropdown from '../components/UserProfileDropdown';
|
||||
import NotificationDropdown from '../components/NotificationDropdown';
|
||||
import TicketModal from '../components/TicketModal';
|
||||
import { useTicket } from '../hooks/useTickets';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
interface PlatformLayoutProps {
|
||||
@@ -16,9 +19,21 @@ interface PlatformLayoutProps {
|
||||
const PlatformLayout: React.FC<PlatformLayoutProps> = ({ user, darkMode, toggleTheme, onSignOut }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [ticketModalId, setTicketModalId] = useState<string | null>(null);
|
||||
|
||||
useScrollToTop();
|
||||
|
||||
// Fetch ticket data when modal is opened from notification
|
||||
const { data: ticketFromNotification } = useTicket(ticketModalId || undefined);
|
||||
|
||||
const handleTicketClick = (ticketId: string) => {
|
||||
setTicketModalId(ticketId);
|
||||
};
|
||||
|
||||
const closeTicketModal = () => {
|
||||
setTicketModalId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
|
||||
{/* Mobile menu */}
|
||||
@@ -59,9 +74,7 @@ const PlatformLayout: React.FC<PlatformLayoutProps> = ({ user, darkMode, toggleT
|
||||
>
|
||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
<NotificationDropdown onTicketClick={handleTicketClick} />
|
||||
<UserProfileDropdown user={user} />
|
||||
</div>
|
||||
</header>
|
||||
@@ -70,6 +83,14 @@ const PlatformLayout: React.FC<PlatformLayoutProps> = ({ user, darkMode, toggleT
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Ticket modal opened from notification */}
|
||||
{ticketModalId && ticketFromNotification && (
|
||||
<TicketModal
|
||||
ticket={ticketFromNotification}
|
||||
onClose={closeTicketModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
1789
frontend/src/pages/HelpApiDocs.tsx
Normal file
1789
frontend/src/pages/HelpApiDocs.tsx
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/src/pages/HelpGuide.tsx
Normal file
33
frontend/src/pages/HelpGuide.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BookOpen, Construction } from 'lucide-react';
|
||||
|
||||
const HelpGuide: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<BookOpen className="text-brand-600" />
|
||||
{t('help.guide.title', 'Platform Guide')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">
|
||||
{t('help.guide.subtitle', 'Learn how to use SmoothSchedule effectively')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<Construction size={64} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('help.guide.comingSoon', 'Coming Soon')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
{t('help.guide.comingSoonDesc', 'We are working on comprehensive documentation to help you get the most out of SmoothSchedule. Check back soon!')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpGuide;
|
||||
422
frontend/src/pages/HelpTicketing.tsx
Normal file
422
frontend/src/pages/HelpTicketing.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Ticket,
|
||||
MessageSquare,
|
||||
Users,
|
||||
Shield,
|
||||
Bell,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
HelpCircle,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
const HelpTicketing: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-6"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<BookOpen size={24} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.title', 'Ticketing System Guide')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.subtitle', 'Learn how to use the support ticketing system')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Ticket size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.overview.title', 'Overview')}
|
||||
</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">
|
||||
{t('helpTicketing.overview.description',
|
||||
'The ticketing system allows you to manage support requests, customer inquiries, staff requests, and internal communications all in one place. Tickets can be categorized, prioritized, and assigned to team members for efficient handling.'
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<MessageSquare size={20} className="text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.overview.customerSupport', 'Customer Support')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.overview.customerSupportDesc', 'Handle customer inquiries, complaints, and refund requests')}
|
||||
</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-green-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.overview.staffRequests', 'Staff Requests')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.overview.staffRequestsDesc', 'Manage time-off requests, schedule changes, and equipment needs')}
|
||||
</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-purple-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.overview.internal', 'Internal Tickets')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.overview.internalDesc', 'Track internal issues and communications within your team')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<HelpCircle size={20} className="text-orange-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.overview.platformSupport', 'Platform Support')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.overview.platformSupportDesc', 'Get help from the SmoothSchedule support team')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ticket Types Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<MessageSquare size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.ticketTypes.title', 'Ticket Types')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('helpTicketing.ticketTypes.type', 'Type')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('helpTicketing.ticketTypes.description', 'Description')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('helpTicketing.ticketTypes.categories', 'Categories')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Customer
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.ticketTypes.customerDesc', 'Requests and inquiries from your customers')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Appointment, Refund, Complaint, General Inquiry
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
Staff Request
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.ticketTypes.staffDesc', 'Internal requests from your staff members')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Time Off, Schedule Change, Equipment
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
Internal
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.ticketTypes.internalDesc', 'Internal team communications and issues')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Equipment, General Inquiry, Other
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||
Platform
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.ticketTypes.platformDesc', 'Support requests to SmoothSchedule team')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Billing, Technical, Feature Request, Account
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ticket Statuses 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" />
|
||||
{t('helpTicketing.statuses.title', 'Ticket Statuses')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 min-w-[140px]">
|
||||
<AlertCircle size={14} /> Open
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.openDesc', 'Ticket has been submitted and is waiting to be reviewed')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 min-w-[140px]">
|
||||
<Clock size={14} /> In Progress
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.inProgressDesc', 'Ticket is being actively worked on by a team member')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 min-w-[140px]">
|
||||
<HelpCircle size={14} /> Awaiting Response
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.awaitingDesc', 'Waiting for additional information from the requester')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 min-w-[140px]">
|
||||
<CheckCircle size={14} /> Resolved
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.resolvedDesc', 'Issue has been resolved but ticket remains open for follow-up')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 min-w-[140px]">
|
||||
<CheckCircle size={14} /> Closed
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.closedDesc', 'Ticket has been completed and closed')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Priority Levels Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<AlertCircle size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.priorities.title', 'Priority Levels')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Low
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{t('helpTicketing.priorities.lowDesc', 'General inquiries and non-urgent requests. No immediate action required.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
Medium
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{t('helpTicketing.priorities.mediumDesc', 'Standard requests that should be addressed within normal business hours.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400">
|
||||
High
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{t('helpTicketing.priorities.highDesc', 'Important issues that require prompt attention and resolution.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400">
|
||||
Urgent
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{t('helpTicketing.priorities.urgentDesc', 'Critical issues requiring immediate attention. Business-impacting problems.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Permissions 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('helpTicketing.permissions.title', 'Access & Permissions')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('helpTicketing.permissions.ownersManagers', 'Business Owners & Managers')}
|
||||
</h4>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li>{t('helpTicketing.permissions.ownerPerm1', 'View and manage all tickets for your business')}</li>
|
||||
<li>{t('helpTicketing.permissions.ownerPerm2', 'Assign tickets to staff members')}</li>
|
||||
<li>{t('helpTicketing.permissions.ownerPerm3', 'Change ticket status and priority')}</li>
|
||||
<li>{t('helpTicketing.permissions.ownerPerm4', 'Add comments (public and internal)')}</li>
|
||||
<li>{t('helpTicketing.permissions.ownerPerm5', 'Control staff access to the ticketing system')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('helpTicketing.permissions.staff', 'Staff Members')}
|
||||
</h4>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li>{t('helpTicketing.permissions.staffPerm1', 'Access requires permission from owner/manager')}</li>
|
||||
<li>{t('helpTicketing.permissions.staffPerm2', 'View tickets assigned to them or in their department')}</li>
|
||||
<li>{t('helpTicketing.permissions.staffPerm3', 'Update ticket status and add comments')}</li>
|
||||
<li>{t('helpTicketing.permissions.staffPerm4', 'Create new support requests')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('helpTicketing.permissions.customers', 'Customers')}
|
||||
</h4>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li>{t('helpTicketing.permissions.customerPerm1', 'Submit support requests through the Support page')}</li>
|
||||
<li>{t('helpTicketing.permissions.customerPerm2', 'View only their own submitted tickets')}</li>
|
||||
<li>{t('helpTicketing.permissions.customerPerm3', 'Track the status of their requests')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Bell size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.notifications.title', 'Notifications')}
|
||||
</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">
|
||||
{t('helpTicketing.notifications.description',
|
||||
'The system automatically sends notifications for important ticket events. You will receive notifications for:'
|
||||
)}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>{t('helpTicketing.notifications.event1', 'New tickets assigned to you')}</li>
|
||||
<li>{t('helpTicketing.notifications.event2', 'Comments added to your tickets')}</li>
|
||||
<li>{t('helpTicketing.notifications.event3', 'Status changes on tickets you created or are assigned to')}</li>
|
||||
<li>{t('helpTicketing.notifications.event4', 'Priority escalations')}</li>
|
||||
</ul>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
{t('helpTicketing.notifications.bellIcon',
|
||||
'Access your notifications by clicking the bell icon in the navigation bar.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Tips */}
|
||||
<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" />
|
||||
{t('helpTicketing.tips.title', 'Quick Tips')}
|
||||
</h2>
|
||||
<div className="bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-900/20 dark:to-blue-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip1', 'Use clear, descriptive subjects to help prioritize and categorize tickets quickly.')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip2', 'Assign tickets to specific team members to ensure accountability.')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip3', 'Use internal comments for team discussions that customers should not see.')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip4', 'Regularly review and close resolved tickets to keep your queue organized.')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip5', 'Set appropriate priorities to ensure urgent issues are addressed first.')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Need More Help */}
|
||||
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center">
|
||||
<HelpCircle size={32} className="mx-auto text-brand-500 mb-3" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('helpTicketing.moreHelp.title', 'Need More Help?')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t('helpTicketing.moreHelp.description',
|
||||
"If you have questions about the ticketing system that aren't covered here, please submit a Platform Support ticket and our team will assist you."
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/tickets')}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('helpTicketing.moreHelp.goToTickets', 'Go to Tickets')}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpTicketing;
|
||||
400
frontend/src/pages/PlatformSupport.tsx
Normal file
400
frontend/src/pages/PlatformSupport.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory } from '../types';
|
||||
import { useTickets, useTicketComments, useCreateTicketComment } from '../hooks/useTickets';
|
||||
import { MessageSquare, Plus, Clock, CheckCircle, AlertCircle, HelpCircle, ChevronRight, Send, User as UserIcon, BookOpen, Code, LifeBuoy } from 'lucide-react';
|
||||
import TicketModal from '../components/TicketModal';
|
||||
import { useSandbox } from '../contexts/SandboxContext';
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge: React.FC<{ status: TicketStatus }> = ({ status }) => {
|
||||
const { t } = useTranslation();
|
||||
const statusConfig: Record<TicketStatus, { color: string; icon: React.ReactNode }> = {
|
||||
OPEN: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: <AlertCircle size={12} /> },
|
||||
IN_PROGRESS: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', icon: <Clock size={12} /> },
|
||||
AWAITING_RESPONSE: { color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', icon: <HelpCircle size={12} /> },
|
||||
RESOLVED: { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: <CheckCircle size={12} /> },
|
||||
CLOSED: { color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', icon: <CheckCircle size={12} /> },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.icon}
|
||||
{t(`tickets.status.${status.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Priority badge component
|
||||
const PriorityBadge: React.FC<{ priority: TicketPriority }> = ({ priority }) => {
|
||||
const { t } = useTranslation();
|
||||
const priorityConfig: Record<TicketPriority, string> = {
|
||||
LOW: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
MEDIUM: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
HIGH: 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
URGENT: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${priorityConfig[priority]}`}>
|
||||
{t(`tickets.priorities.${priority.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Ticket detail view (read-only for business users viewing platform tickets)
|
||||
const TicketDetail: React.FC<{ ticket: Ticket; onBack: () => void }> = ({ ticket, onBack }) => {
|
||||
const { t } = useTranslation();
|
||||
const [replyText, setReplyText] = useState('');
|
||||
|
||||
// Fetch comments for this ticket
|
||||
const { data: comments = [], isLoading: isLoadingComments } = useTicketComments(ticket.id);
|
||||
const createCommentMutation = useCreateTicketComment();
|
||||
|
||||
// Filter out internal comments (business users shouldn't see platform's internal notes)
|
||||
const visibleComments = comments.filter((comment: TicketComment) => !comment.isInternal);
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyText.trim()) return;
|
||||
|
||||
await createCommentMutation.mutateAsync({
|
||||
ticketId: ticket.id,
|
||||
commentData: {
|
||||
commentText: replyText.trim(),
|
||||
isInternal: false, // Business replies are never internal
|
||||
},
|
||||
});
|
||||
setReplyText('');
|
||||
};
|
||||
|
||||
const isTicketClosed = ticket.status === 'CLOSED';
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-2 flex items-center gap-1"
|
||||
>
|
||||
← {t('common.back', 'Back to support tickets')}
|
||||
</button>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{ticket.subject}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('tickets.ticketNumber', 'Ticket #{{number}}', { number: ticket.ticketNumber })}
|
||||
{' • '}
|
||||
{t('tickets.createdAt', 'Created {{date}}', { date: new Date(ticket.createdAt).toLocaleDateString() })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ticket.status} />
|
||||
<PriorityBadge priority={ticket.priority} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t('tickets.description')}</h3>
|
||||
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">{ticket.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
{ticket.status === 'OPEN' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('platformSupport.statusOpen', 'Your request has been received. Our support team will review it shortly.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'IN_PROGRESS' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('platformSupport.statusInProgress', 'Our support team is currently working on your request.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'AWAITING_RESPONSE' && (
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t('platformSupport.statusAwaitingResponse', 'We need additional information from you. Please reply below.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'RESOLVED' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('platformSupport.statusResolved', 'Your request has been resolved. Thank you for contacting SmoothSchedule support!')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'CLOSED' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('platformSupport.statusClosed', 'This ticket has been closed.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments / Conversation */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
<MessageSquare size={16} />
|
||||
{t('platformSupport.conversation', 'Conversation')}
|
||||
</h3>
|
||||
|
||||
{isLoadingComments ? (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : visibleComments.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 py-4">
|
||||
{t('platformSupport.noRepliesYet', 'No replies yet. Our support team will respond soon.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
{visibleComments.map((comment: TicketComment) => (
|
||||
<div key={comment.id} className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<UserIcon size={14} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{comment.authorFullName || comment.authorEmail}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap pl-10">
|
||||
{comment.commentText}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
{!isTicketClosed ? (
|
||||
<div className="px-6 py-4">
|
||||
<form onSubmit={handleSubmitReply} className="space-y-3">
|
||||
<label htmlFor="reply" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('platformSupport.yourReply', 'Your Reply')}
|
||||
</label>
|
||||
<textarea
|
||||
id="reply"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={t('platformSupport.replyPlaceholder', 'Type your message here...')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
disabled={createCommentMutation.isPending}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createCommentMutation.isPending || !replyText.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send size={16} />
|
||||
{createCommentMutation.isPending
|
||||
? t('common.sending', 'Sending...')
|
||||
: t('platformSupport.sendReply', 'Send Reply')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.ticketClosedNoReply', 'This ticket is closed. If you need further assistance, please open a new support request.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformSupport: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isSandbox } = useSandbox();
|
||||
const [showNewTicketModal, setShowNewTicketModal] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
|
||||
const { data: tickets = [], isLoading, refetch } = useTickets();
|
||||
|
||||
// Filter to only show platform support tickets
|
||||
const platformTickets = tickets.filter(ticket => ticket.ticketType === 'PLATFORM');
|
||||
|
||||
if (selectedTicket) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('platformSupport.title', 'SmoothSchedule Support')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('platformSupport.subtitle', 'Get help from the SmoothSchedule team')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewTicketModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('platformSupport.newRequest', 'Contact Support')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sandbox Warning Banner */}
|
||||
{isSandbox && (
|
||||
<div className="mb-6 p-4 bg-orange-50 dark:bg-orange-900/20 border-2 border-orange-500 dark:border-orange-600 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-orange-800 dark:text-orange-200">
|
||||
{t('platformSupport.sandboxWarning', 'You are in Test Mode')}
|
||||
</h4>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300 mt-1">
|
||||
{t('platformSupport.sandboxWarningMessage', 'Platform support is only available in Live Mode. Switch to Live Mode to contact SmoothSchedule support.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Help Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('platformSupport.quickHelp', 'Quick Help')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Link
|
||||
to="/help/guide"
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<BookOpen size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('platformSupport.platformGuide', 'Platform Guide')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.platformGuideDesc', 'Learn the basics')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/help/api"
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Code size={20} className="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('platformSupport.apiDocs', 'API Docs')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.apiDocsDesc', 'Integration help')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowNewTicketModal(true)}
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<LifeBuoy size={20} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('platformSupport.contactUs', 'Contact Support')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.contactUsDesc', 'Get personalized help')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My Support Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('platformSupport.myRequests', 'My Support Requests')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : platformTickets.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<MessageSquare size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.noRequests', "You haven't submitted any support requests yet.")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewTicketModal(true)}
|
||||
className="mt-4 text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
||||
>
|
||||
{t('platformSupport.submitFirst', 'Submit your first request')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{platformTickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
onClick={() => setSelectedTicket(ticket)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{ticket.subject}
|
||||
</h3>
|
||||
<StatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('tickets.ticketNumber', 'Ticket #{{number}}', { number: ticket.ticketNumber })}
|
||||
{' • '}
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={20} className="text-gray-400 flex-shrink-0 ml-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Ticket Modal */}
|
||||
{showNewTicketModal && (
|
||||
<TicketModal
|
||||
onClose={() => {
|
||||
setShowNewTicketModal(false);
|
||||
refetch();
|
||||
}}
|
||||
defaultTicketType="PLATFORM"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformSupport;
|
||||
@@ -9,6 +9,7 @@ import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyC
|
||||
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
|
||||
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
import ApiTokensSection from '../components/ApiTokensSection';
|
||||
|
||||
// Curated color palettes with complementary primary and secondary colors
|
||||
const colorPalettes = [
|
||||
@@ -98,7 +99,7 @@ const colorPalettes = [
|
||||
},
|
||||
];
|
||||
|
||||
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources';
|
||||
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources' | 'api-tokens';
|
||||
|
||||
// Resource Types Management Section Component
|
||||
const ResourceTypesSection: React.FC = () => {
|
||||
@@ -645,6 +646,7 @@ const SettingsPage: React.FC = () => {
|
||||
{ id: 'resources' as const, label: 'Resource Types', icon: Layers },
|
||||
{ id: 'domains' as const, label: 'Domains', icon: Globe },
|
||||
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
||||
{ id: 'api-tokens' as const, label: 'API Tokens', icon: Key },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1853,6 +1855,11 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API TOKENS TAB */}
|
||||
{activeTab === 'api-tokens' && isOwner && (
|
||||
<ApiTokensSection />
|
||||
)}
|
||||
|
||||
{/* Floating Action Buttons */}
|
||||
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Power,
|
||||
} from 'lucide-react';
|
||||
import Portal from '../components/Portal';
|
||||
import StaffPermissions from '../components/StaffPermissions';
|
||||
|
||||
interface StaffProps {
|
||||
onMasquerade: (user: User) => void;
|
||||
@@ -535,222 +536,23 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Manager Permissions */}
|
||||
{/* Permissions - Using shared component */}
|
||||
{inviteRole === 'TENANT_MANAGER' && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('staff.managerPermissions', 'Manager Permissions')}
|
||||
</h4>
|
||||
|
||||
{/* Can Invite Staff */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_invite_staff ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_invite_staff: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canInviteStaff', 'Can invite new staff members')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canInviteStaffHint', 'Allow this manager to send invitations to new staff members')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Resources */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_manage_resources ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_manage_resources: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageResources', 'Can manage resources')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageResourcesHint', 'Create, edit, and delete bookable resources')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Services */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_manage_services ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_manage_services: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageServices', 'Can manage services')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageServicesHint', 'Create, edit, and delete service offerings')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can View Reports */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_view_reports ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_view_reports: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canViewReports', 'Can view reports')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canViewReportsHint', 'Access business analytics and financial reports')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Settings */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_access_settings ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_access_settings: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessSettings', 'Can access business settings')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessSettingsHint', 'Modify business profile, branding, and configuration')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Refund Payments */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_refund_payments ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_refund_payments: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canRefundPayments', 'Can refund payments')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canRefundPaymentsHint', 'Process refunds for customer payments')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Tickets */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_access_tickets ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_access_tickets: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessTickets', 'Can access support tickets')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessTicketsHint', 'View and manage customer support tickets')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<StaffPermissions
|
||||
role="manager"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Staff Permissions */}
|
||||
{inviteRole === 'TENANT_STAFF' && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('staff.staffPermissions', 'Staff Permissions')}
|
||||
</h4>
|
||||
|
||||
{/* Can View All Schedules */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_view_all_schedules ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_view_all_schedules: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canViewAllSchedules', 'Can view all schedules')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canViewAllSchedulesHint', 'View schedules of other staff members (otherwise only their own)')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Own Appointments */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_manage_own_appointments ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_manage_own_appointments: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageOwnAppointments', 'Can manage own appointments')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageOwnAppointmentsHint', 'Create, reschedule, and cancel their own appointments')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Tickets */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_access_tickets ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_access_tickets: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessTickets', 'Can access support tickets')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessTicketsHint', 'View and manage customer support tickets')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Make Bookable Option */}
|
||||
@@ -873,222 +675,23 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Manager Permissions Section */}
|
||||
{/* Permissions - Using shared component */}
|
||||
{editingStaff.role === 'manager' && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('staff.managerPermissions', 'Manager Permissions')}
|
||||
</h4>
|
||||
|
||||
{/* Can Invite Staff */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_invite_staff ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_invite_staff: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canInviteStaff', 'Can invite new staff members')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canInviteStaffHint', 'Allow this manager to send invitations to new staff members')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Resources */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_manage_resources ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_manage_resources: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageResources', 'Can manage resources')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageResourcesHint', 'Create, edit, and delete bookable resources')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Services */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_manage_services ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_manage_services: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageServices', 'Can manage services')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageServicesHint', 'Create, edit, and delete service offerings')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can View Reports */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_view_reports ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_view_reports: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canViewReports', 'Can view reports')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canViewReportsHint', 'Access business analytics and financial reports')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Settings */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_access_settings ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_access_settings: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessSettings', 'Can access business settings')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessSettingsHint', 'Modify business profile, branding, and configuration')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Refund Payments */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_refund_payments ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_refund_payments: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canRefundPayments', 'Can refund payments')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canRefundPaymentsHint', 'Process refunds for customer payments')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Tickets */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_access_tickets ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_access_tickets: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessTickets', 'Can access support tickets')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessTicketsHint', 'View and manage customer support tickets')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<StaffPermissions
|
||||
role="manager"
|
||||
permissions={editPermissions}
|
||||
onChange={setEditPermissions}
|
||||
variant="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Staff Permissions Section (for non-managers) */}
|
||||
{editingStaff.role === 'staff' && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('staff.staffPermissions', 'Staff Permissions')}
|
||||
</h4>
|
||||
|
||||
{/* Can View Own Schedule Only */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_view_all_schedules ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_view_all_schedules: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canViewAllSchedules', 'Can view all schedules')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canViewAllSchedulesHint', 'View schedules of other staff members (otherwise only their own)')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Own Appointments */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_manage_own_appointments ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_manage_own_appointments: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageOwnAppointments', 'Can manage own appointments')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageOwnAppointmentsHint', 'Create, reschedule, and cancel their own appointments')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Tickets */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_access_tickets ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_access_tickets: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessTickets', 'Can access support tickets')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessTicketsHint', 'View and manage customer support tickets')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={editPermissions}
|
||||
onChange={setEditPermissions}
|
||||
variant="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No permissions for owners */}
|
||||
|
||||
490
frontend/src/pages/customer/CustomerSupport.tsx
Normal file
490
frontend/src/pages/customer/CustomerSupport.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { User, Business, Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory } from '../../types';
|
||||
import { useTickets, useCreateTicket, useTicketComments, useCreateTicketComment } from '../../hooks/useTickets';
|
||||
import { MessageSquare, Plus, Clock, CheckCircle, AlertCircle, HelpCircle, ChevronRight, Send, User as UserIcon } from 'lucide-react';
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge: React.FC<{ status: TicketStatus }> = ({ status }) => {
|
||||
const { t } = useTranslation();
|
||||
const statusConfig: Record<TicketStatus, { color: string; icon: React.ReactNode }> = {
|
||||
OPEN: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: <AlertCircle size={12} /> },
|
||||
IN_PROGRESS: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', icon: <Clock size={12} /> },
|
||||
AWAITING_RESPONSE: { color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', icon: <HelpCircle size={12} /> },
|
||||
RESOLVED: { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: <CheckCircle size={12} /> },
|
||||
CLOSED: { color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', icon: <CheckCircle size={12} /> },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.icon}
|
||||
{t(`tickets.status.${status.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Priority badge component
|
||||
const PriorityBadge: React.FC<{ priority: TicketPriority }> = ({ priority }) => {
|
||||
const { t } = useTranslation();
|
||||
const priorityConfig: Record<TicketPriority, string> = {
|
||||
LOW: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
MEDIUM: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
HIGH: 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
URGENT: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${priorityConfig[priority]}`}>
|
||||
{t(`tickets.priorities.${priority.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// New ticket form component
|
||||
const NewTicketForm: React.FC<{ onClose: () => void; onSuccess: () => void }> = ({ onClose, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState<TicketCategory>('GENERAL_INQUIRY');
|
||||
const [priority, setPriority] = useState<TicketPriority>('MEDIUM');
|
||||
|
||||
const createTicketMutation = useCreateTicket();
|
||||
|
||||
const categoryOptions: TicketCategory[] = ['APPOINTMENT', 'REFUND', 'COMPLAINT', 'GENERAL_INQUIRY', 'OTHER'];
|
||||
const priorityOptions: TicketPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'URGENT'];
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await createTicketMutation.mutateAsync({
|
||||
subject,
|
||||
description,
|
||||
category,
|
||||
priority,
|
||||
ticketType: 'CUSTOMER',
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('customerSupport.newRequest', 'Submit a Support Request')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('customerSupport.subjectPlaceholder', 'Brief summary of your issue')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.category')}
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as TicketCategory)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
{categoryOptions.map(cat => (
|
||||
<option key={cat} value={cat}>{t(`tickets.categories.${cat.toLowerCase()}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.priority')}
|
||||
</label>
|
||||
<select
|
||||
id="priority"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TicketPriority)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
{priorityOptions.map(opt => (
|
||||
<option key={opt} value={opt}>{t(`tickets.priorities.${opt.toLowerCase()}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.description')}
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={t('customerSupport.descriptionPlaceholder', 'Please describe your issue in detail...')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createTicketMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{createTicketMutation.isPending ? t('common.saving') : t('customerSupport.submitRequest', 'Submit Request')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Ticket detail view
|
||||
const TicketDetail: React.FC<{ ticket: Ticket; onBack: () => void }> = ({ ticket, onBack }) => {
|
||||
const { t } = useTranslation();
|
||||
const [replyText, setReplyText] = useState('');
|
||||
|
||||
// Fetch comments for this ticket
|
||||
const { data: comments = [], isLoading: isLoadingComments } = useTicketComments(ticket.id);
|
||||
const createCommentMutation = useCreateTicketComment();
|
||||
|
||||
// Filter out internal comments (customers shouldn't see them)
|
||||
const visibleComments = comments.filter((comment: TicketComment) => !comment.isInternal);
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyText.trim()) return;
|
||||
|
||||
await createCommentMutation.mutateAsync({
|
||||
ticketId: ticket.id,
|
||||
commentData: {
|
||||
commentText: replyText.trim(),
|
||||
isInternal: false, // Customer replies are never internal
|
||||
},
|
||||
});
|
||||
setReplyText('');
|
||||
};
|
||||
|
||||
const isTicketClosed = ticket.status === 'CLOSED';
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-2 flex items-center gap-1"
|
||||
>
|
||||
← {t('common.back', 'Back to tickets')}
|
||||
</button>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{ticket.subject}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('tickets.ticketNumber', 'Ticket #{{number}}', { number: ticket.ticketNumber })}
|
||||
{' • '}
|
||||
{t('tickets.createdAt', 'Created {{date}}', { date: new Date(ticket.createdAt).toLocaleDateString() })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ticket.status} />
|
||||
<PriorityBadge priority={ticket.priority} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t('tickets.description')}</h3>
|
||||
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">{ticket.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
{ticket.status === 'OPEN' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('customerSupport.statusOpen', 'Your request has been received. Our team will review it shortly.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'IN_PROGRESS' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('customerSupport.statusInProgress', 'Our team is currently working on your request.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'AWAITING_RESPONSE' && (
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t('customerSupport.statusAwaitingResponse', 'We need additional information from you. Please reply below.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'RESOLVED' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('customerSupport.statusResolved', 'Your request has been resolved. Thank you for contacting us!')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'CLOSED' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('customerSupport.statusClosed', 'This ticket has been closed.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments / Conversation */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
<MessageSquare size={16} />
|
||||
{t('customerSupport.conversation', 'Conversation')}
|
||||
</h3>
|
||||
|
||||
{isLoadingComments ? (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : visibleComments.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 py-4">
|
||||
{t('customerSupport.noRepliesYet', 'No replies yet. Our team will respond soon.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
{visibleComments.map((comment: TicketComment) => (
|
||||
<div key={comment.id} className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<UserIcon size={14} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{comment.authorFullName || comment.authorEmail}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap pl-10">
|
||||
{comment.commentText}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
{!isTicketClosed ? (
|
||||
<div className="px-6 py-4">
|
||||
<form onSubmit={handleSubmitReply} className="space-y-3">
|
||||
<label htmlFor="reply" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('customerSupport.yourReply', 'Your Reply')}
|
||||
</label>
|
||||
<textarea
|
||||
id="reply"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={t('customerSupport.replyPlaceholder', 'Type your message here...')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
disabled={createCommentMutation.isPending}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createCommentMutation.isPending || !replyText.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send size={16} />
|
||||
{createCommentMutation.isPending
|
||||
? t('common.sending', 'Sending...')
|
||||
: t('customerSupport.sendReply', 'Send Reply')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('customerSupport.ticketClosedNoReply', 'This ticket is closed. If you need further assistance, please open a new support request.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomerSupport: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, business } = useOutletContext<{ user: User; business: Business }>();
|
||||
const [showNewTicketForm, setShowNewTicketForm] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
|
||||
const { data: tickets = [], isLoading, refetch } = useTickets();
|
||||
|
||||
// Filter to only show customer's own tickets
|
||||
const myTickets = tickets.filter(ticket => ticket.ticketType === 'CUSTOMER');
|
||||
|
||||
if (selectedTicket) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('customerSupport.title', 'Support')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('customerSupport.subtitle', 'Get help with your appointments and account')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewTicketForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('customerSupport.newRequest', 'New Request')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Help Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('customerSupport.quickHelp', 'Quick Help')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); setShowNewTicketForm(true); }}
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<MessageSquare size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('customerSupport.contactUs', 'Contact Us')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('customerSupport.contactUsDesc', 'Submit a support request')}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:support@${business?.subdomain || 'business'}.smoothschedule.com`}
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<HelpCircle size={20} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('customerSupport.emailUs', 'Email Us')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('customerSupport.emailUsDesc', 'Get help via email')}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('customerSupport.myRequests', 'My Support Requests')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : myTickets.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<MessageSquare size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('customerSupport.noRequests', "You haven't submitted any support requests yet.")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewTicketForm(true)}
|
||||
className="mt-4 text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
||||
>
|
||||
{t('customerSupport.submitFirst', 'Submit your first request')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myTickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
onClick={() => setSelectedTicket(ticket)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{ticket.subject}
|
||||
</h3>
|
||||
<StatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('tickets.ticketNumber', 'Ticket #{{number}}', { number: ticket.ticketNumber })}
|
||||
{' • '}
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={20} className="text-gray-400 flex-shrink-0 ml-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Ticket Modal */}
|
||||
{showNewTicketForm && (
|
||||
<NewTicketForm
|
||||
onClose={() => setShowNewTicketForm(false)}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerSupport;
|
||||
Reference in New Issue
Block a user