Initial commit: SmoothSchedule multi-tenant scheduling platform

This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar for timeline */
.timeline-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.timeline-scroll::-webkit-scrollbar-track {
background: transparent;
}
.timeline-scroll::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 4px;
}
.timeline-scroll::-webkit-scrollbar-thumb:hover {
background: #a0aec0;
}
/* Dark mode scrollbar */
.dark .timeline-scroll::-webkit-scrollbar-thumb {
background: #4a5568;
}
.dark .timeline-scroll::-webkit-scrollbar-thumb:hover {
background: #68768a;
}
/* Preserve existing app styles */
.app {
min-height: 100vh;
}

View File

@@ -0,0 +1,555 @@
/**
* Main App Component - Integrated with Real API
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
import { useCurrentBusiness } from './hooks/useBusiness';
import { useUpdateBusiness } from './hooks/useBusiness';
import { setCookie } from './utils/cookies';
// Import Login Page
import LoginPage from './pages/LoginPage';
import OAuthCallback from './pages/OAuthCallback';
// Import layouts
import BusinessLayout from './layouts/BusinessLayout';
import PlatformLayout from './layouts/PlatformLayout';
import CustomerLayout from './layouts/CustomerLayout';
import MarketingLayout from './layouts/MarketingLayout';
// Import marketing pages
import HomePage from './pages/marketing/HomePage';
import FeaturesPage from './pages/marketing/FeaturesPage';
import PricingPage from './pages/marketing/PricingPage';
import AboutPage from './pages/marketing/AboutPage';
import ContactPage from './pages/marketing/ContactPage';
import SignupPage from './pages/marketing/SignupPage';
// Import pages
import Dashboard from './pages/Dashboard';
import Scheduler from './pages/Scheduler';
import Customers from './pages/Customers';
import Settings from './pages/Settings';
import Payments from './pages/Payments';
import Resources from './pages/Resources';
import Services from './pages/Services';
import Staff from './pages/Staff';
import CustomerDashboard from './pages/customer/CustomerDashboard';
import ResourceDashboard from './pages/resource/ResourceDashboard';
import BookingPage from './pages/customer/BookingPage';
import TrialExpired from './pages/TrialExpired';
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 PlatformUsers from './pages/platform/PlatformUsers';
import PlatformSettings from './pages/platform/PlatformSettings';
import ProfileSettings from './pages/ProfileSettings';
import VerifyEmail from './pages/VerifyEmail';
import EmailVerificationRequired from './pages/EmailVerificationRequired';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30000, // 30 seconds
},
},
});
/**
* Loading Component
*/
const LoadingScreen: React.FC = () => {
const { t } = useTranslation();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">{t('common.loading')}</p>
</div>
</div>
);
};
/**
* Error Component
*/
const ErrorScreen: React.FC<{ error: Error }> = ({ error }) => {
const { t } = useTranslation();
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">
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">{t('common.error')}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">{error.message}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{t('common.reload')}
</button>
</div>
</div>
);
};
/**
* App Content - Handles routing based on auth state
*/
const AppContent: React.FC = () => {
// Check for tokens in URL FIRST - before any queries execute
// This handles login/masquerade redirects that pass tokens in the URL
const [processingUrlTokens] = useState(() => {
const params = new URLSearchParams(window.location.search);
return !!(params.get('access_token') && params.get('refresh_token'));
});
const { data: user, isLoading: userLoading, error: userError } = useCurrentUser();
const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness();
const [darkMode, setDarkMode] = useState(false);
const updateBusinessMutation = useUpdateBusiness();
const masqueradeMutation = useMasquerade();
const logoutMutation = useLogout();
// Apply dark mode class
React.useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode);
}, [darkMode]);
// Handle tokens in URL (from login or masquerade redirect)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
if (accessToken && refreshToken) {
// Extract masquerade stack if present (for masquerade banner)
const masqueradeStackParam = params.get('masquerade_stack');
if (masqueradeStackParam) {
try {
const masqueradeStack = JSON.parse(decodeURIComponent(masqueradeStackParam));
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
} catch (e) {
console.error('Failed to parse masquerade stack', e);
}
}
// For backward compatibility, also check for original_user parameter
const originalUserParam = params.get('original_user');
if (originalUserParam && !masqueradeStackParam) {
try {
const originalUser = JSON.parse(decodeURIComponent(originalUserParam));
// Convert old format to new stack format (single entry)
const stack = [{
user_id: originalUser.id,
username: originalUser.username,
role: originalUser.role,
business_id: originalUser.business,
business_subdomain: originalUser.business_subdomain,
}];
localStorage.setItem('masquerade_stack', JSON.stringify(stack));
} catch (e) {
console.error('Failed to parse original user', e);
}
}
// Set cookies using helper (handles domain correctly)
setCookie('access_token', accessToken, 7);
setCookie('refresh_token', refreshToken, 7);
// Clear session cookie to prevent interference with JWT
// (Django session cookie might take precedence over JWT)
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Clean URL
const newUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', newUrl);
// Force reload to ensure auth state is picked up
window.location.reload();
}
}, []);
// Show loading while processing URL tokens (before reload happens)
if (processingUrlTokens) {
return <LoadingScreen />;
}
// Loading state
if (userLoading) {
return <LoadingScreen />;
}
// Helper to detect root domain (for marketing site)
const isRootDomain = (): boolean => {
const hostname = window.location.hostname;
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
};
// Not authenticated - show public routes
if (!user) {
// On root domain, show marketing site
if (isRootDomain()) {
return (
<Routes>
<Route element={<MarketingLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// On business subdomain, show login
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}
// Error state
if (userError) {
return <ErrorScreen error={userError as Error} />;
}
// Handlers
const toggleTheme = () => setDarkMode((prev) => !prev);
const handleSignOut = () => {
logoutMutation.mutate();
};
const handleUpdateBusiness = (updates: Partial<any>) => {
updateBusinessMutation.mutate(updates);
};
const handleMasquerade = (targetUser: any) => {
// Call the masquerade API with the target user's username
// Fallback to email prefix if username is not available
const username = targetUser.username || targetUser.email?.split('@')[0];
if (!username) {
console.error('Cannot masquerade: no username or email available', targetUser);
return;
}
masqueradeMutation.mutate(username);
};
// Helper to check access based on roles
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
// Platform users (superuser, platform_manager, platform_support)
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
if (isPlatformUser) {
return (
<Routes>
<Route
element={
<PlatformLayout
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
onSignOut={handleSignOut}
/>
}
>
{(user.role === 'superuser' || user.role === 'platform_manager') && (
<>
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
</>
)}
<Route path="/platform/support" element={<PlatformSupport />} />
{user.role === 'superuser' && (
<Route path="/platform/settings" element={<PlatformSettings />} />
)}
<Route path="/platform/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route
path="*"
element={
<Navigate
to={
user.role === 'superuser' || user.role === 'platform_manager'
? '/platform/dashboard'
: '/platform/support'
}
/>
}
/>
</Route>
</Routes>
);
}
// Customer users
if (user.role === 'customer') {
return (
<Routes>
<Route
element={
<CustomerLayout
business={business || ({} as any)}
user={user}
/>
}
>
<Route path="/" element={<CustomerDashboard />} />
<Route path="/book" element={<BookingPage />} />
<Route path="/payments" element={<Payments />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
);
}
// Business loading - show loading with user info
if (businessLoading) {
return <LoadingScreen />;
}
// Check if we're on root/platform domain without proper business context
const currentHostname = window.location.hostname;
const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me';
// Business error or no business found
if (businessError || !business) {
// If user is a business owner on root domain, redirect to their business
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
// If on root/platform and shouldn't be here, show appropriate message
if (isRootOrPlatform) {
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-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{user.business_subdomain
? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me`
: 'Your account is not associated with a business. Please contact support.'}
</p>
<div className="flex gap-4 justify-center">
{user.business_subdomain && (
<button
onClick={() => {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Go to Business
</button>
)}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Sign Out
</button>
</div>
</div>
</div>
);
}
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">
{businessError instanceof Error ? businessError.message : 'Unable to load business data. Please check your subdomain or try again.'}
</p>
<div className="flex gap-4 justify-center">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Reload
</button>
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Sign Out
</button>
</div>
</div>
</div>
);
}
// Business users (owner, manager, staff, resource)
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) {
// Check if email verification is required
if (!user.email_verified) {
return (
<Routes>
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
</Routes>
);
}
// Check if trial has expired
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
// Allowed routes when trial is expired
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
const currentPath = window.location.pathname;
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
// If trial expired and not on allowed route, redirect to trial-expired
if (isTrialExpired && !isOnAllowedRoute) {
return (
<Routes>
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
/>
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
</Routes>
);
}
return (
<Routes>
<Route
element={
<BusinessLayout
business={business}
user={user}
darkMode={darkMode}
toggleTheme={toggleTheme}
onSignOut={handleSignOut}
updateBusiness={handleUpdateBusiness}
/>
}
>
{/* Trial and Upgrade Routes */}
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
{/* Regular Routes */}
<Route
path="/"
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route
path="/customers"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/services"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Services />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/resources"
element={
hasAccess(['owner', 'manager', 'staff']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/staff"
element={
hasAccess(['owner', 'manager']) ? (
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/payments"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
}
/>
<Route
path="/messages"
element={
hasAccess(['owner', 'manager']) ? (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Messages</h1>
<p className="text-gray-600">Messages feature coming soon...</p>
</div>
) : (
<Navigate to="/" />
)
}
/>
<Route
path="/settings"
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
/>
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
</Route>
</Routes>
);
}
// Fallback
return <Navigate to="/" />;
};
/**
* Main App Component
*/
const App: React.FC = () => {
return (
<QueryClientProvider client={queryClient}>
<Router>
<AppContent />
</Router>
</QueryClientProvider>
);
};
export default App;

View File

@@ -0,0 +1,113 @@
/**
* Authentication API
*/
import apiClient from './client';
export interface LoginCredentials {
username: string;
password: string;
}
import { UserRole } from '../types';
export interface MasqueradeStackEntry {
user_id: number;
username: string;
role: UserRole;
business_id?: number;
business_subdomain?: string;
}
export interface LoginResponse {
access: string;
refresh: string;
user: {
id: number;
username: string;
email: string;
name: string;
role: UserRole;
avatar_url?: string;
email_verified?: boolean;
is_staff: boolean;
is_superuser: boolean;
business?: number;
business_name?: string;
business_subdomain?: string;
};
masquerade_stack?: MasqueradeStackEntry[];
}
export interface User {
id: number;
username: string;
email: string;
name: string;
role: UserRole;
avatar_url?: string;
email_verified?: boolean;
is_staff: boolean;
is_superuser: boolean;
business?: number;
business_name?: string;
business_subdomain?: string;
}
/**
* Login user
*/
export const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/api/auth/login/', credentials);
return response.data;
};
/**
* Logout user
*/
export const logout = async (): Promise<void> => {
await apiClient.post('/api/auth/logout/');
};
/**
* Get current user
*/
export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<User>('/api/auth/me/');
return response.data;
};
/**
* Refresh access token
*/
export const refreshToken = async (refresh: string): Promise<{ access: string }> => {
const response = await apiClient.post('/api/auth/refresh/', { refresh });
return response.data;
};
/**
* Masquerade as another user
*/
export const masquerade = async (
username: string,
masquerade_stack?: MasqueradeStackEntry[]
): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>(
`/api/users/${username}/masquerade/`,
{ masquerade_stack }
);
return response.data;
};
/**
* Stop masquerading and return to previous user
*/
export const stopMasquerade = async (
masquerade_stack: MasqueradeStackEntry[]
): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>(
'/api/users/stop_masquerade/',
{ masquerade_stack }
);
return response.data;
};

View File

@@ -0,0 +1,106 @@
/**
* Business API - Resources and Users
*/
import apiClient from './client';
import { User, Resource, BusinessOAuthSettings, BusinessOAuthSettingsResponse, BusinessOAuthCredentials } from '../types';
/**
* Get all resources for the current business
*/
export const getResources = async (): Promise<Resource[]> => {
const response = await apiClient.get<Resource[]>('/api/resources/');
return response.data;
};
/**
* Get all users for the current business
*/
export const getBusinessUsers = async (): Promise<User[]> => {
const response = await apiClient.get<User[]>('/api/business/users/');
return response.data;
};
/**
* Get business OAuth settings and available platform providers
*/
export const getBusinessOAuthSettings = async (): Promise<BusinessOAuthSettingsResponse> => {
const response = await apiClient.get<{
business_settings: {
oauth_enabled_providers: string[];
oauth_allow_registration: boolean;
oauth_auto_link_by_email: boolean;
};
available_providers: string[];
}>('/api/business/oauth-settings/');
// Transform snake_case to camelCase
return {
businessSettings: {
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
allowRegistration: response.data.business_settings.oauth_allow_registration,
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
},
availableProviders: response.data.available_providers || [],
};
};
/**
* Update business OAuth settings
*/
export const updateBusinessOAuthSettings = async (
settings: Partial<BusinessOAuthSettings>
): Promise<BusinessOAuthSettingsResponse> => {
// Transform camelCase to snake_case for backend
const backendData: Record<string, any> = {};
if (settings.enabledProviders !== undefined) {
backendData.oauth_enabled_providers = settings.enabledProviders;
}
if (settings.allowRegistration !== undefined) {
backendData.oauth_allow_registration = settings.allowRegistration;
}
if (settings.autoLinkByEmail !== undefined) {
backendData.oauth_auto_link_by_email = settings.autoLinkByEmail;
}
const response = await apiClient.patch<{
business_settings: {
oauth_enabled_providers: string[];
oauth_allow_registration: boolean;
oauth_auto_link_by_email: boolean;
};
available_providers: string[];
}>('/api/business/oauth-settings/update/', backendData);
// Transform snake_case to camelCase
return {
businessSettings: {
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
allowRegistration: response.data.business_settings.oauth_allow_registration,
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
},
availableProviders: response.data.available_providers || [],
};
};
/**
* Get business OAuth credentials (custom credentials for paid tiers)
*/
export const getBusinessOAuthCredentials = async (): Promise<BusinessOAuthCredentials> => {
const response = await apiClient.get<BusinessOAuthCredentials>('/api/business/oauth-credentials/');
return response.data;
};
/**
* Update business OAuth credentials (custom credentials for paid tiers)
*/
export const updateBusinessOAuthCredentials = async (
credentials: Partial<BusinessOAuthCredentials>
): Promise<BusinessOAuthCredentials> => {
const response = await apiClient.patch<BusinessOAuthCredentials>(
'/api/business/oauth-credentials/update/',
credentials
);
return response.data;
};

View File

@@ -0,0 +1,85 @@
/**
* API Client
* Axios instance configured for SmoothSchedule API
*/
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { API_BASE_URL, getSubdomain } from './config';
import { getCookie } from '../utils/cookies';
// Create axios instance
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // For CORS with credentials
});
// Request interceptor - add auth token and business subdomain
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Add business subdomain header if on business site
const subdomain = getSubdomain();
if (subdomain && subdomain !== 'platform') {
config.headers['X-Business-Subdomain'] = subdomain;
}
// Add auth token if available (from cookie)
const token = getCookie('access_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - handle errors and token refresh
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle 401 Unauthorized - token expired
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Try to refresh token (from cookie)
const refreshToken = getCookie('refresh_token');
if (refreshToken) {
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh/`, {
refresh: refreshToken,
});
const { access } = response.data;
// Import setCookie dynamically to avoid circular dependency
const { setCookie } = await import('../utils/cookies');
setCookie('access_token', access, 7);
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers['Authorization'] = `Bearer ${access}`;
}
return apiClient(originalRequest);
}
} catch (refreshError) {
// Refresh failed - clear tokens and redirect to login
const { deleteCookie } = await import('../utils/cookies');
deleteCookie('access_token');
deleteCookie('refresh_token');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,59 @@
/**
* API Configuration
* Centralized configuration for API endpoints and settings
*/
// Determine API base URL based on environment
const getApiBaseUrl = (): string => {
// In production, this would be set via environment variable
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
// Development: use api subdomain
return 'http://api.lvh.me:8000';
};
export const API_BASE_URL = getApiBaseUrl();
/**
* Extract subdomain from current hostname
* Returns null if on root domain or invalid subdomain
*/
export const getSubdomain = (): string | null => {
const hostname = window.location.hostname;
const parts = hostname.split('.');
// lvh.me without subdomain (root domain) - no business context
if (hostname === 'lvh.me') {
return null;
}
// Has subdomain
if (parts.length > 1) {
const subdomain = parts[0];
// Exclude special subdomains
if (['www', 'api', 'platform'].includes(subdomain)) {
return subdomain === 'platform' ? null : subdomain;
}
return subdomain;
}
return null;
};
/**
* Check if current page is platform site
*/
export const isPlatformSite = (): boolean => {
const hostname = window.location.hostname;
return hostname.startsWith('platform.');
};
/**
* Check if current page is business site
*/
export const isBusinessSite = (): boolean => {
const subdomain = getSubdomain();
return subdomain !== null && subdomain !== 'platform';
};

View File

@@ -0,0 +1,51 @@
/**
* Custom Domains API - Manage custom domains for businesses
*/
import apiClient from './client';
import { CustomDomain } from '../types';
/**
* Get all custom domains for the current business
*/
export const getCustomDomains = async (): Promise<CustomDomain[]> => {
const response = await apiClient.get<CustomDomain[]>('/api/business/domains/');
return response.data;
};
/**
* Add a new custom domain
*/
export const addCustomDomain = async (domain: string): Promise<CustomDomain> => {
const response = await apiClient.post<CustomDomain>('/api/business/domains/', {
domain: domain.toLowerCase().trim(),
});
return response.data;
};
/**
* Delete a custom domain
*/
export const deleteCustomDomain = async (domainId: number): Promise<void> => {
await apiClient.delete(`/api/business/domains/${domainId}/`);
};
/**
* Verify a custom domain by checking DNS
*/
export const verifyCustomDomain = async (domainId: number): Promise<{ verified: boolean; message: string }> => {
const response = await apiClient.post<{ verified: boolean; message: string }>(
`/api/business/domains/${domainId}/verify/`
);
return response.data;
};
/**
* Set a custom domain as the primary domain
*/
export const setPrimaryDomain = async (domainId: number): Promise<CustomDomain> => {
const response = await apiClient.post<CustomDomain>(
`/api/business/domains/${domainId}/set-primary/`
);
return response.data;
};

View File

@@ -0,0 +1,181 @@
/**
* Domains API - NameSilo Integration for Domain Registration
*/
import apiClient from './client';
// Types
export interface DomainAvailability {
domain: string;
available: boolean;
price: number | null;
premium: boolean;
premium_price: number | null;
}
export interface DomainPrice {
tld: string;
registration: number;
renewal: number;
transfer: number;
}
export interface RegistrantContact {
first_name: string;
last_name: string;
email: string;
phone: string;
address: string;
city: string;
state: string;
zip_code: string;
country: string;
}
export interface DomainRegisterRequest {
domain: string;
years: number;
whois_privacy: boolean;
auto_renew: boolean;
nameservers?: string[];
contact: RegistrantContact;
auto_configure: boolean;
}
export interface DomainRegistration {
id: number;
domain: string;
status: 'pending' | 'active' | 'expired' | 'transferred' | 'failed';
registered_at: string | null;
expires_at: string | null;
auto_renew: boolean;
whois_privacy: boolean;
purchase_price: number | null;
renewal_price: number | null;
nameservers: string[];
days_until_expiry: number | null;
is_expiring_soon: boolean;
created_at: string;
// Detail fields
registrant_first_name?: string;
registrant_last_name?: string;
registrant_email?: string;
}
export interface DomainSearchHistory {
id: number;
searched_domain: string;
was_available: boolean;
price: number | null;
searched_at: string;
}
// API Functions
/**
* Search for domain availability
*/
export const searchDomains = async (
query: string,
tlds: string[] = ['.com', '.net', '.org']
): Promise<DomainAvailability[]> => {
const response = await apiClient.post<DomainAvailability[]>('/api/domains/search/search/', {
query,
tlds,
});
return response.data;
};
/**
* Get TLD pricing
*/
export const getDomainPrices = async (): Promise<DomainPrice[]> => {
const response = await apiClient.get<DomainPrice[]>('/api/domains/search/prices/');
return response.data;
};
/**
* Register a new domain
*/
export const registerDomain = async (
data: DomainRegisterRequest
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>('/api/domains/search/register/', data);
return response.data;
};
/**
* Get all registered domains for current business
*/
export const getRegisteredDomains = async (): Promise<DomainRegistration[]> => {
const response = await apiClient.get<DomainRegistration[]>('/api/domains/registrations/');
return response.data;
};
/**
* Get a single domain registration
*/
export const getDomainRegistration = async (id: number): Promise<DomainRegistration> => {
const response = await apiClient.get<DomainRegistration>(`/api/domains/registrations/${id}/`);
return response.data;
};
/**
* Update nameservers for a domain
*/
export const updateNameservers = async (
id: number,
nameservers: string[]
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/update_nameservers/`,
{ nameservers }
);
return response.data;
};
/**
* Toggle auto-renewal for a domain
*/
export const toggleAutoRenew = async (
id: number,
autoRenew: boolean
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/toggle_auto_renew/`,
{ auto_renew: autoRenew }
);
return response.data;
};
/**
* Renew a domain
*/
export const renewDomain = async (
id: number,
years: number = 1
): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/renew/`,
{ years }
);
return response.data;
};
/**
* Sync domain info from NameSilo
*/
export const syncDomain = async (id: number): Promise<DomainRegistration> => {
const response = await apiClient.post<DomainRegistration>(
`/api/domains/registrations/${id}/sync/`
);
return response.data;
};
/**
* Get domain search history
*/
export const getSearchHistory = async (): Promise<DomainSearchHistory[]> => {
const response = await apiClient.get<DomainSearchHistory[]>('/api/domains/history/');
return response.data;
};

View File

@@ -0,0 +1,93 @@
/**
* OAuth API
* Handles OAuth authentication flows with various providers
*/
import apiClient from './client';
export interface OAuthProvider {
name: string;
display_name: string;
icon: string;
}
export interface OAuthAuthorizationResponse {
authorization_url: string;
}
export interface OAuthTokenResponse {
access: string;
refresh: string;
user: {
id: number;
username: string;
email: string;
name: string;
role: string;
avatar_url?: string;
is_staff: boolean;
is_superuser: boolean;
business?: number;
business_name?: string;
business_subdomain?: string;
};
}
export interface OAuthConnection {
id: string;
provider: string;
provider_user_id: string;
email?: string;
connected_at: string;
}
/**
* Get list of enabled OAuth providers
*/
export const getOAuthProviders = async (): Promise<OAuthProvider[]> => {
const response = await apiClient.get<{ providers: OAuthProvider[] }>('/api/auth/oauth/providers/');
return response.data.providers;
};
/**
* Initiate OAuth flow - get authorization URL
*/
export const initiateOAuth = async (provider: string): Promise<OAuthAuthorizationResponse> => {
const response = await apiClient.get<OAuthAuthorizationResponse>(
`/api/auth/oauth/${provider}/authorize/`
);
return response.data;
};
/**
* Handle OAuth callback - exchange code for tokens
*/
export const handleOAuthCallback = async (
provider: string,
code: string,
state: string
): Promise<OAuthTokenResponse> => {
const response = await apiClient.post<OAuthTokenResponse>(
`/api/auth/oauth/${provider}/callback/`,
{
code,
state,
}
);
return response.data;
};
/**
* Get user's connected OAuth accounts
*/
export const getOAuthConnections = async (): Promise<OAuthConnection[]> => {
const response = await apiClient.get<{ connections: OAuthConnection[] }>('/api/auth/oauth/connections/');
return response.data.connections;
};
/**
* Disconnect an OAuth account
*/
export const disconnectOAuth = async (provider: string): Promise<void> => {
await apiClient.delete(`/api/auth/oauth/connections/${provider}/`);
};

View File

@@ -0,0 +1,433 @@
/**
* Payments API
* Functions for managing payment configuration (API keys and Connect)
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
export type PaymentMode = 'direct_api' | 'connect' | 'none';
export type KeyStatus = 'active' | 'invalid' | 'deprecated';
export type AccountStatus = 'pending' | 'onboarding' | 'active' | 'restricted' | 'rejected';
export interface ApiKeysInfo {
id: number;
status: KeyStatus;
secret_key_masked: string;
publishable_key_masked: string;
last_validated_at: string | null;
stripe_account_id: string;
stripe_account_name: string;
validation_error: string;
created_at: string;
updated_at: string;
}
export interface ConnectAccountInfo {
id: number;
business: number;
business_name: string;
business_subdomain: string;
stripe_account_id: string;
account_type: 'standard' | 'express' | 'custom';
status: AccountStatus;
charges_enabled: boolean;
payouts_enabled: boolean;
details_submitted: boolean;
onboarding_complete: boolean;
onboarding_link: string | null;
onboarding_link_expires_at: string | null;
is_onboarding_link_valid: boolean;
created_at: string;
updated_at: string;
}
export interface PaymentConfig {
payment_mode: PaymentMode;
tier: string;
can_accept_payments: boolean;
api_keys: ApiKeysInfo | null;
connect_account: ConnectAccountInfo | null;
}
export interface ApiKeysValidationResult {
valid: boolean;
account_id?: string;
account_name?: string;
environment?: string;
error?: string;
}
export interface ApiKeysCurrentResponse {
configured: boolean;
id?: number;
status?: KeyStatus;
secret_key_masked?: string;
publishable_key_masked?: string;
last_validated_at?: string | null;
stripe_account_id?: string;
stripe_account_name?: string;
validation_error?: string;
message?: string;
}
export interface ConnectOnboardingResponse {
account_type: 'standard' | 'custom';
url: string;
stripe_account_id?: string;
}
export interface AccountSessionResponse {
client_secret: string;
stripe_account_id: string;
publishable_key: string;
}
// ============================================================================
// Unified Configuration
// ============================================================================
/**
* Get unified payment configuration status.
* Returns the complete payment setup for the business.
*/
export const getPaymentConfig = () =>
apiClient.get<PaymentConfig>('/api/payments/config/status/');
// ============================================================================
// API Keys (Free Tier)
// ============================================================================
/**
* Get current API key configuration (masked keys).
*/
export const getApiKeys = () =>
apiClient.get<ApiKeysCurrentResponse>('/api/payments/api-keys/');
/**
* Save API keys.
* Validates and stores the provided Stripe API keys.
*/
export const saveApiKeys = (secretKey: string, publishableKey: string) =>
apiClient.post<ApiKeysInfo>('/api/payments/api-keys/', {
secret_key: secretKey,
publishable_key: publishableKey,
});
/**
* Validate API keys without saving.
* Tests the keys against Stripe API.
*/
export const validateApiKeys = (secretKey: string, publishableKey: string) =>
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/validate/', {
secret_key: secretKey,
publishable_key: publishableKey,
});
/**
* Re-validate stored API keys.
* Tests stored keys and updates their status.
*/
export const revalidateApiKeys = () =>
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/revalidate/');
/**
* Delete stored API keys.
*/
export const deleteApiKeys = () =>
apiClient.delete<{ success: boolean; message: string }>('/api/payments/api-keys/delete/');
// ============================================================================
// Stripe Connect (Paid Tiers)
// ============================================================================
/**
* Get current Connect account status.
*/
export const getConnectStatus = () =>
apiClient.get<ConnectAccountInfo>('/api/payments/connect/status/');
/**
* Initiate Connect account onboarding.
* Returns a URL to redirect the user for Stripe onboarding.
*/
export const initiateConnectOnboarding = (refreshUrl: string, returnUrl: string) =>
apiClient.post<ConnectOnboardingResponse>('/api/payments/connect/onboard/', {
refresh_url: refreshUrl,
return_url: returnUrl,
});
/**
* Refresh Connect onboarding link.
* For custom Connect accounts that need a new onboarding link.
*/
export const refreshConnectOnboardingLink = (refreshUrl: string, returnUrl: string) =>
apiClient.post<{ url: string }>('/api/payments/connect/refresh-link/', {
refresh_url: refreshUrl,
return_url: returnUrl,
});
/**
* Create an Account Session for embedded Connect onboarding.
* Returns a client_secret for initializing Stripe's embedded Connect components.
*/
export const createAccountSession = () =>
apiClient.post<AccountSessionResponse>('/api/payments/connect/account-session/');
/**
* Refresh Connect account status from Stripe.
* Syncs the local account record with the current state in Stripe.
*/
export const refreshConnectStatus = () =>
apiClient.post<ConnectAccountInfo>('/api/payments/connect/refresh-status/');
// ============================================================================
// Transaction Analytics
// ============================================================================
export interface Transaction {
id: number;
business: number;
business_name: string;
stripe_payment_intent_id: string;
stripe_charge_id: string;
transaction_type: 'payment' | 'refund' | 'application_fee';
status: 'pending' | 'succeeded' | 'failed' | 'refunded' | 'partially_refunded';
amount: number;
amount_display: string;
application_fee_amount: number;
fee_display: string;
net_amount: number;
currency: string;
customer_email: string;
customer_name: string;
created_at: string;
updated_at: string;
stripe_data?: Record<string, unknown>;
}
export interface TransactionListResponse {
results: Transaction[];
count: number;
page: number;
page_size: number;
total_pages: number;
}
export interface TransactionSummary {
total_transactions: number;
total_volume: number;
total_volume_display: string;
total_fees: number;
total_fees_display: string;
net_revenue: number;
net_revenue_display: string;
successful_transactions: number;
failed_transactions: number;
refunded_transactions: number;
average_transaction: number;
average_transaction_display: string;
}
export interface TransactionFilters {
start_date?: string;
end_date?: string;
status?: 'all' | 'succeeded' | 'pending' | 'failed' | 'refunded';
transaction_type?: 'all' | 'payment' | 'refund' | 'application_fee';
page?: number;
page_size?: number;
}
export interface StripeCharge {
id: string;
amount: number;
amount_display: string;
amount_refunded: number;
currency: string;
status: string;
paid: boolean;
refunded: boolean;
description: string | null;
receipt_email: string | null;
receipt_url: string | null;
created: number;
payment_method_details: Record<string, unknown> | null;
billing_details: Record<string, unknown> | null;
}
export interface ChargesResponse {
charges: StripeCharge[];
has_more: boolean;
}
export interface StripePayout {
id: string;
amount: number;
amount_display: string;
currency: string;
status: string;
arrival_date: number | null;
created: number;
description: string | null;
destination: string | null;
failure_message: string | null;
method: string;
type: string;
}
export interface PayoutsResponse {
payouts: StripePayout[];
has_more: boolean;
}
export interface BalanceItem {
amount: number;
currency: string;
amount_display: string;
}
export interface BalanceResponse {
available: BalanceItem[];
pending: BalanceItem[];
available_total: number;
pending_total: number;
}
export interface ExportRequest {
format: 'csv' | 'xlsx' | 'pdf' | 'quickbooks';
start_date?: string;
end_date?: string;
include_details?: boolean;
}
/**
* Get list of transactions with optional filtering.
*/
export const getTransactions = (filters?: TransactionFilters) => {
const params = new URLSearchParams();
if (filters?.start_date) params.append('start_date', filters.start_date);
if (filters?.end_date) params.append('end_date', filters.end_date);
if (filters?.status && filters.status !== 'all') params.append('status', filters.status);
if (filters?.transaction_type && filters.transaction_type !== 'all') {
params.append('transaction_type', filters.transaction_type);
}
if (filters?.page) params.append('page', String(filters.page));
if (filters?.page_size) params.append('page_size', String(filters.page_size));
const queryString = params.toString();
return apiClient.get<TransactionListResponse>(
`/api/payments/transactions/${queryString ? `?${queryString}` : ''}`
);
};
/**
* Get a single transaction by ID.
*/
export const getTransaction = (id: number) =>
apiClient.get<Transaction>(`/api/payments/transactions/${id}/`);
/**
* Get transaction summary/analytics.
*/
export const getTransactionSummary = (filters?: Pick<TransactionFilters, 'start_date' | 'end_date'>) => {
const params = new URLSearchParams();
if (filters?.start_date) params.append('start_date', filters.start_date);
if (filters?.end_date) params.append('end_date', filters.end_date);
const queryString = params.toString();
return apiClient.get<TransactionSummary>(
`/api/payments/transactions/summary/${queryString ? `?${queryString}` : ''}`
);
};
/**
* Get charges from Stripe API.
*/
export const getStripeCharges = (limit: number = 20) =>
apiClient.get<ChargesResponse>(`/api/payments/transactions/charges/?limit=${limit}`);
/**
* Get payouts from Stripe API.
*/
export const getStripePayouts = (limit: number = 20) =>
apiClient.get<PayoutsResponse>(`/api/payments/transactions/payouts/?limit=${limit}`);
/**
* Get current balance from Stripe API.
*/
export const getStripeBalance = () =>
apiClient.get<BalanceResponse>('/api/payments/transactions/balance/');
/**
* Export transaction data.
* Returns the file data directly for download.
*/
export const exportTransactions = (request: ExportRequest) =>
apiClient.post('/api/payments/transactions/export/', request, {
responseType: 'blob',
});
// ============================================================================
// Transaction Details & Refunds
// ============================================================================
export interface RefundInfo {
id: string;
amount: number;
amount_display: string;
status: string;
reason: string | null;
created: number;
}
export interface PaymentMethodInfo {
type: string;
brand?: string;
last4?: string;
exp_month?: number;
exp_year?: number;
funding?: string;
bank_name?: string;
}
export interface TransactionDetail extends Transaction {
refunds: RefundInfo[];
refundable_amount: number;
total_refunded: number;
can_refund: boolean;
payment_method_info: PaymentMethodInfo | null;
description: string;
}
export interface RefundRequest {
amount?: number;
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
metadata?: Record<string, string>;
}
export interface RefundResponse {
success: boolean;
refund_id: string;
amount: number;
amount_display: string;
status: string;
reason: string | null;
transaction_status: string;
}
/**
* Get detailed transaction information including refund data.
*/
export const getTransactionDetail = (id: number) =>
apiClient.get<TransactionDetail>(`/api/payments/transactions/${id}/`);
/**
* Issue a refund for a transaction.
* @param transactionId - The ID of the transaction to refund
* @param request - Optional refund request with amount and reason
*/
export const refundTransaction = (transactionId: number, request?: RefundRequest) =>
apiClient.post<RefundResponse>(`/api/payments/transactions/${transactionId}/refund/`, request || {});

View File

@@ -0,0 +1,56 @@
/**
* Platform API
* API functions for platform-level operations (businesses, users, etc.)
*/
import apiClient from './client';
export interface PlatformBusiness {
id: number;
name: string;
subdomain: string;
tier: string;
is_active: boolean;
created_at: string;
user_count: number;
}
export interface PlatformUser {
id: number;
email: string;
username: string;
name?: string;
role?: string;
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
business: number | null;
business_name?: string;
business_subdomain?: string;
date_joined: string;
last_login?: string;
}
/**
* Get all businesses (platform admin only)
*/
export const getBusinesses = async (): Promise<PlatformBusiness[]> => {
const response = await apiClient.get<PlatformBusiness[]>('/api/platform/businesses/');
return response.data;
};
/**
* Get all users (platform admin only)
*/
export const getUsers = async (): Promise<PlatformUser[]> => {
const response = await apiClient.get<PlatformUser[]>('/api/platform/users/');
return response.data;
};
/**
* Get users for a specific business
*/
export const getBusinessUsers = async (businessId: number): Promise<PlatformUser[]> => {
const response = await apiClient.get<PlatformUser[]>(`/api/platform/users/?business=${businessId}`);
return response.data;
};

View File

@@ -0,0 +1,90 @@
/**
* Platform OAuth Settings API
*/
import apiClient from './client';
export interface OAuthProviderConfig {
enabled: boolean;
client_id: string;
client_secret: string;
// Apple-specific fields
team_id?: string;
key_id?: string;
// Microsoft-specific field
tenant_id?: string;
}
export interface PlatformOAuthSettings {
// Global setting
oauth_allow_registration: boolean;
// Provider configurations
google: OAuthProviderConfig;
apple: OAuthProviderConfig;
facebook: OAuthProviderConfig;
linkedin: OAuthProviderConfig;
microsoft: OAuthProviderConfig;
twitter: OAuthProviderConfig;
twitch: OAuthProviderConfig;
}
export interface PlatformOAuthSettingsUpdate {
oauth_allow_registration?: boolean;
// Google
oauth_google_enabled?: boolean;
oauth_google_client_id?: string;
oauth_google_client_secret?: string;
// Apple
oauth_apple_enabled?: boolean;
oauth_apple_client_id?: string;
oauth_apple_client_secret?: string;
oauth_apple_team_id?: string;
oauth_apple_key_id?: string;
// Facebook
oauth_facebook_enabled?: boolean;
oauth_facebook_client_id?: string;
oauth_facebook_client_secret?: string;
// LinkedIn
oauth_linkedin_enabled?: boolean;
oauth_linkedin_client_id?: string;
oauth_linkedin_client_secret?: string;
// Microsoft
oauth_microsoft_enabled?: boolean;
oauth_microsoft_client_id?: string;
oauth_microsoft_client_secret?: string;
oauth_microsoft_tenant_id?: string;
// Twitter (X)
oauth_twitter_enabled?: boolean;
oauth_twitter_client_id?: string;
oauth_twitter_client_secret?: string;
// Twitch
oauth_twitch_enabled?: boolean;
oauth_twitch_client_id?: string;
oauth_twitch_client_secret?: string;
}
/**
* Get platform OAuth settings
*/
export const getPlatformOAuthSettings = async (): Promise<PlatformOAuthSettings> => {
const { data } = await apiClient.get('/api/platform/settings/oauth/');
return data;
};
/**
* Update platform OAuth settings
*/
export const updatePlatformOAuthSettings = async (
settings: PlatformOAuthSettingsUpdate
): Promise<PlatformOAuthSettings> => {
const { data } = await apiClient.post('/api/platform/settings/oauth/', settings);
return data;
};

View File

@@ -0,0 +1,210 @@
import apiClient from './client';
// Types
export interface UserProfile {
id: number;
username: string;
email: string;
name: string;
phone?: string;
phone_verified: boolean;
avatar_url?: string;
email_verified: boolean;
two_factor_enabled: boolean;
totp_confirmed: boolean;
sms_2fa_enabled: boolean;
timezone: string;
locale: string;
notification_preferences: NotificationPreferences;
role: string;
business?: number;
business_name?: string;
business_subdomain?: string;
// Address fields
address_line1?: string;
address_line2?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
}
export interface NotificationPreferences {
email: boolean;
sms: boolean;
in_app: boolean;
appointment_reminders: boolean;
marketing: boolean;
}
export interface TOTPSetupResponse {
secret: string;
qr_code: string; // Base64 encoded PNG
provisioning_uri: string;
}
export interface TOTPVerifyResponse {
success: boolean;
recovery_codes: string[];
}
export interface Session {
id: string;
device_info: string;
ip_address: string;
location: string;
created_at: string;
last_activity: string;
is_current: boolean;
}
export interface LoginHistoryEntry {
id: string;
timestamp: string;
ip_address: string;
device_info: string;
location: string;
success: boolean;
failure_reason?: string;
two_factor_method?: string;
}
// Profile API
export const getProfile = async (): Promise<UserProfile> => {
const response = await apiClient.get('/api/auth/profile/');
return response.data;
};
export const updateProfile = async (data: Partial<UserProfile>): Promise<UserProfile> => {
const response = await apiClient.patch('/api/auth/profile/', data);
return response.data;
};
export const uploadAvatar = async (file: File): Promise<{ avatar_url: string }> => {
const formData = new FormData();
formData.append('avatar', file);
const response = await apiClient.post('/api/auth/profile/avatar/', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};
export const deleteAvatar = async (): Promise<void> => {
await apiClient.delete('/api/auth/profile/avatar/');
};
// Email API
export const sendVerificationEmail = async (): Promise<void> => {
await apiClient.post('/api/auth/email/verify/send/');
};
export const verifyEmail = async (token: string): Promise<void> => {
await apiClient.post('/api/auth/email/verify/confirm/', { token });
};
export const requestEmailChange = async (newEmail: string): Promise<void> => {
await apiClient.post('/api/auth/email/change/', { new_email: newEmail });
};
export const confirmEmailChange = async (token: string): Promise<void> => {
await apiClient.post('/api/auth/email/change/confirm/', { token });
};
// Password API
export const changePassword = async (
currentPassword: string,
newPassword: string
): Promise<void> => {
await apiClient.post('/api/auth/password/change/', {
current_password: currentPassword,
new_password: newPassword,
});
};
// 2FA API
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
const response = await apiClient.post('/api/auth/2fa/totp/setup/');
return response.data;
};
export const verifyTOTP = async (code: string): Promise<TOTPVerifyResponse> => {
const response = await apiClient.post('/api/auth/2fa/totp/verify/', { code });
return response.data;
};
export const disableTOTP = async (code: string): Promise<void> => {
await apiClient.post('/api/auth/2fa/totp/disable/', { code });
};
export const getRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.get('/api/auth/2fa/recovery-codes/');
return response.data.codes;
};
export const regenerateRecoveryCodes = async (): Promise<string[]> => {
const response = await apiClient.post('/api/auth/2fa/recovery-codes/regenerate/');
return response.data.codes;
};
// Sessions API
export const getSessions = async (): Promise<Session[]> => {
const response = await apiClient.get('/api/auth/sessions/');
return response.data;
};
export const revokeSession = async (sessionId: string): Promise<void> => {
await apiClient.delete(`/api/auth/sessions/${sessionId}/`);
};
export const revokeOtherSessions = async (): Promise<void> => {
await apiClient.post('/api/auth/sessions/revoke-others/');
};
export const getLoginHistory = async (): Promise<LoginHistoryEntry[]> => {
const response = await apiClient.get('/api/auth/login-history/');
return response.data;
};
// Phone Verification API
export const sendPhoneVerification = async (phone: string): Promise<void> => {
await apiClient.post('/api/auth/phone/verify/send/', { phone });
};
export const verifyPhoneCode = async (code: string): Promise<void> => {
await apiClient.post('/api/auth/phone/verify/confirm/', { code });
};
// Multiple Email Management API
export interface UserEmail {
id: number;
email: string;
is_primary: boolean;
verified: boolean;
created_at: string;
}
export const getUserEmails = async (): Promise<UserEmail[]> => {
const response = await apiClient.get('/api/auth/emails/');
return response.data;
};
export const addUserEmail = async (email: string): Promise<UserEmail> => {
const response = await apiClient.post('/api/auth/emails/', { email });
return response.data;
};
export const deleteUserEmail = async (emailId: number): Promise<void> => {
await apiClient.delete(`/api/auth/emails/${emailId}/`);
};
export const sendUserEmailVerification = async (emailId: number): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/send-verification/`);
};
export const verifyUserEmail = async (emailId: number, token: string): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/verify/`, { token });
};
export const setPrimaryEmail = async (emailId: number): Promise<void> => {
await apiClient.post(`/api/auth/emails/${emailId}/set-primary/`);
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,247 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100%" viewBox="0 0 1730 1100" enable-background="new 0 0 1730 1100" xml:space="preserve">
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M969.645508,771.667175
C983.734009,760.932678 998.024170,750.981995 1011.135925,739.665955
C1020.239868,731.809021 1027.811401,722.153137 1035.890625,713.142212
C1039.635864,708.964966 1042.988037,704.431946 1046.455933,700.011230
C1047.427979,698.771973 1048.177979,697.358459 1048.318481,695.266968
C1043.233154,698.355286 1038.068848,701.321533 1033.076172,704.552979
C1011.285095,718.656555 987.633118,729.002747 963.865662,739.154541
C926.816467,754.979309 888.330383,766.524841 849.335266,776.096252
C816.661194,784.116150 783.571899,790.540527 750.510559,796.854858
C725.879822,801.559082 701.040466,805.235657 676.219849,808.862976
C650.730042,812.588013 625.020874,814.936829 599.640015,819.253784
C561.088013,825.810913 522.543823,832.670288 484.360413,841.058594
C453.594025,847.817566 423.045654,856.010376 393.000458,865.471924
C368.607147,873.153687 344.985138,883.370483 321.251038,893.028137
C306.543671,899.012756 291.840790,905.266846 277.895966,912.805786
C257.393433,923.890198 237.239243,935.709290 217.557373,948.193909
C200.561569,958.974670 183.671112,970.190491 168.118774,982.907349
C150.190521,997.567078 133.454575,1013.749817 116.860298,1029.946777
C109.343819,1037.283325 103.407921,1046.241699 96.785301,1054.488647
C95.963615,1055.511963 95.329086,1056.685547 94.607811,1057.789551
C94.087418,1057.428833 93.567024,1057.068237 93.046631,1056.707520
C95.143036,1051.902710 97.038155,1046.997681 99.369362,1042.309692
C112.070229,1016.768616 126.605263,992.265686 143.560577,969.362183
C154.371017,954.759155 166.268524,940.905212 178.350021,927.312134
C195.337433,908.199402 214.588501,891.460449 234.166809,874.999146
C257.180664,855.649292 281.649719,838.427429 307.573792,823.472717
C336.247131,806.932129 365.478760,791.285645 396.623840,779.614136
C412.184509,773.782776 427.375671,766.898743 443.113495,761.622986
C464.384369,754.492371 485.846008,747.786011 507.540192,742.096313
C540.973694,733.327393 574.554077,725.033386 608.300049,717.568359
C634.070862,711.867554 660.204224,707.813232 686.158875,702.934082
C711.461548,698.177490 736.731262,693.245178 762.037231,688.506470
C773.996765,686.266907 786.117065,684.782654 797.967041,682.087830
C813.228760,678.617249 828.417358,674.693970 843.413147,670.214722
C868.431335,662.742126 893.816467,656.055481 918.046326,646.500244
C947.249329,634.983948 975.782898,621.687195 1001.597900,603.148926
C1019.638672,590.193542 1038.112427,577.639526 1057.397705,566.673218
C1078.458008,554.697449 1100.290771,544.266785 1123.296509,535.835693
C1153.968750,524.595032 1185.606567,517.348511 1216.895020,508.623566
C1228.170898,505.479218 1239.672241,503.140717 1251.080444,500.476807
C1252.144653,500.228271 1253.280396,500.285614 1255.739990,500.096741
C1253.853149,502.287323 1252.808350,503.648651 1251.613892,504.862793
C1244.387573,512.208191 1237.151611,519.453979 1230.673462,527.585815
C1218.089600,543.381836 1207.873535,560.547852 1199.297607,578.655396
C1191.381104,595.370850 1184.464722,612.563843 1177.247925,629.605774
C1168.326660,650.672302 1158.144165,671.075928 1146.017334,690.496033
C1135.214478,707.795898 1123.201904,724.184570 1109.329102,739.153809
C1098.717407,750.604187 1088.405151,762.391296 1077.100098,773.122986
C1066.321655,783.354675 1054.340088,792.309082 1043.048340,802.012085
C1022.812439,819.400757 999.674561,832.426270 976.316162,844.731873
C956.019775,855.424316 934.888245,864.927551 913.308838,872.683411
C889.113220,881.379639 864.222961,888.452698 839.208069,894.452332
C822.112122,898.552673 804.305725,899.769409 786.777283,901.957336
C776.820679,903.200073 766.784119,903.802368 755.687805,904.790649
C757.714539,906.218933 759.003662,907.369080 760.489807,908.138672
C783.668091,920.140076 807.284790,931.020691 832.462219,938.359375
C855.906860,945.193054 879.583191,951.030334 903.823364,953.736511
C919.614380,955.499451 935.650452,956.242859 951.530090,955.794312
C985.213318,954.842834 1018.249756,949.116272 1050.425049,938.980957
C1090.859131,926.244141 1128.350220,907.577209 1162.281494,882.076538
C1172.054565,874.731628 1181.528320,866.922607 1190.604370,858.733459
C1201.177246,849.194031 1211.503418,839.328491 1221.327759,829.023743
C1238.017578,811.517944 1253.516968,792.980530 1265.936401,772.111816
C1274.501709,757.719238 1283.092041,743.320740 1291.001709,728.565918
C1296.228638,718.815796 1300.504639,708.528442 1304.793457,698.308411
C1315.707275,672.301758 1322.893799,645.154175 1327.839600,617.478088
C1330.420410,603.036621 1331.911011,588.336731 1332.869995,573.686584
C1333.878906,558.275757 1334.407471,542.754089 1333.765503,527.338318
C1333.190186,513.526611 1330.652344,499.801727 1329.257446,486.010529
C1329.129883,484.748444 1330.735107,482.487030 1332.013306,482.032196
C1347.430786,476.546417 1363.083862,471.698395 1378.384033,465.913452
C1395.856812,459.307068 1413.124390,452.132050 1430.313843,444.811279
C1442.720703,439.527374 1455.204834,434.247284 1467.033081,427.821472
C1488.682861,416.060059 1510.133179,403.880432 1531.183105,391.080017
C1553.192505,377.696136 1573.413086,361.740723 1592.717285,344.700775
C1602.850830,335.755951 1612.603027,326.373413 1622.384766,317.039001
C1625.733643,313.843140 1628.616577,310.162659 1631.782593,306.769073
C1632.601929,305.891022 1633.686157,305.260193 1634.648682,304.515747
C1634.988770,304.771484 1635.328979,305.027191 1635.669067,305.282928
C1633.291504,309.465271 1631.207642,313.850372 1628.485229,317.794739
C1616.850464,334.652039 1605.817017,352.011719 1593.041870,367.969421
C1581.144165,382.831451 1568.030884,396.904633 1554.180420,409.977081
C1532.040161,430.873718 1508.570923,450.362701 1482.932861,466.917206
C1461.684692,480.637024 1440.099609,493.861084 1418.288452,506.665344
C1412.599854,510.004883 1412.874390,514.199585 1413.025269,519.129028
C1413.335327,529.252197 1413.837646,539.372375 1413.970093,549.497498
C1414.420044,583.932129 1409.491089,617.730225 1402.481934,651.371643
C1396.489746,680.130859 1388.232544,708.150024 1376.739746,735.113281
C1365.900146,760.543701 1354.243652,785.647095 1338.789673,808.736450
C1329.595947,822.472168 1320.811768,836.521423 1310.929565,849.746643
C1299.263916,865.358582 1287.147461,880.676086 1272.680908,893.951477
C1267.312744,898.877502 1262.994141,904.960815 1257.552490,909.790405
C1244.686401,921.209106 1231.741821,932.582581 1218.245483,943.234863
C1206.325317,952.643250 1194.096924,961.842163 1181.146973,969.724365
C1158.948486,983.235718 1135.947021,995.433838 1111.749023,1005.062805
C1097.940796,1010.557495 1084.021851,1016.069458 1069.709839,1019.932617
C1049.345581,1025.429443 1028.718628,1030.276367 1007.929993,1033.773071
C991.841858,1036.479248 975.354248,1037.157715 959.006348,1037.869873
C944.148682,1038.517090 929.177429,1038.851318 914.369873,1037.769165
C895.950500,1036.422974 877.505005,1034.281860 859.326843,1031.045898
C829.348206,1025.709351 800.144714,1017.064575 771.925598,1005.670044
C756.608765,999.485352 741.639099,992.105408 727.324646,983.855835
C708.068115,972.758301 689.407349,960.583984 670.880676,948.284729
C663.679993,943.504333 657.602966,937.049927 650.901428,931.493958
C644.098328,925.853760 636.800903,920.762085 630.356140,914.748413
C619.933044,905.022461 609.957886,894.810120 599.945679,884.653320
C596.250183,880.904480 592.985229,876.731201 588.732971,871.842102
C593.234985,871.136841 596.812500,870.434021 600.423706,870.036133
C613.563843,868.588135 626.724976,867.327454 639.859131,865.829712
C649.863892,864.688843 659.866699,863.473877 669.822815,861.976013
C687.452637,859.323730 705.044434,856.420166 722.655457,853.642822
C738.960144,851.071533 755.332520,848.871826 771.558411,845.876282
C788.103882,842.821716 804.617798,839.442139 820.942810,835.388611
C838.621033,830.999084 856.189697,826.061890 873.562744,820.590759
C883.364563,817.504089 892.799072,813.130615 902.178589,808.853394
C914.899170,803.052734 927.670898,797.269348 939.927246,790.575073
C950.102966,785.017090 959.565613,778.153442 969.645508,771.667175
M1050.581421,694.050720
C1050.730957,693.806946 1050.880493,693.563171 1051.029907,693.319275
C1050.812500,693.437988 1050.595093,693.556763 1050.581421,694.050720
z"/>
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M556.660950,457.603760
C541.109375,531.266846 547.394165,603.414612 568.399292,675.217285
C432.503021,704.469421 306.741730,754.212341 199.911194,846.845520
C200.479172,845.300049 200.602173,844.107422 201.242157,843.353882
C209.385620,833.765381 217.337875,823.994263 225.877579,814.768311
C234.207504,805.768921 242.989990,797.166687 251.896179,788.730408
C257.379120,783.536743 263.590637,779.120728 269.241333,774.092896
C273.459808,770.339478 276.960907,765.728882 281.376740,762.257324
C297.837646,749.316223 314.230652,736.249023 331.255981,724.078125
C345.231110,714.087769 359.912170,705.048035 374.584686,696.085144
C382.450134,691.280396 391.044617,687.685791 399.150726,683.253601
C407.072968,678.921997 414.597321,673.833801 422.642975,669.762817
C438.151398,661.916077 453.798492,654.321594 469.600006,647.085205
C477.539642,643.449280 478.113831,642.479065 476.519012,633.766968
C474.589203,623.224731 473.630249,612.508850 471.947601,601.916382
C467.749847,575.490784 468.654633,548.856323 469.237122,522.316833
C469.602295,505.676849 471.616699,488.988892 474.083252,472.500793
C477.059357,452.606354 480.060059,432.564514 485.320496,413.203339
C491.148651,391.752808 499.099060,370.831879 506.971741,350.000183
C512.325867,335.832855 518.620361,321.957916 525.397888,308.404053
C541.421509,276.359467 560.144958,245.828873 582.862244,218.156967
C598.004089,199.712769 614.822388,182.523621 631.949951,165.861969
C652.972046,145.411667 676.340942,127.695229 701.137573,111.953148
C726.902954,95.596024 753.783325,81.411240 782.541138,71.040688
C797.603638,65.608902 812.617126,59.820137 828.057861,55.716591
C845.892639,50.976776 864.121277,47.634624 882.284790,44.254494
C890.218506,42.778072 898.403564,42.346916 906.495300,42.086170
C924.443237,41.507816 942.445129,40.435017 960.349243,41.242741
C979.963135,42.127602 999.561890,44.377670 1019.039673,46.986061
C1043.176270,50.218334 1066.758545,56.365486 1089.506470,64.964005
C1106.661865,71.448593 1123.305542,79.342972 1139.969482,87.057976
C1162.813843,97.634354 1183.941406,111.123840 1204.113037,126.138229
C1207.003540,128.289703 1209.946899,130.370087 1213.763916,133.133530
C1216.783447,129.931229 1220.327026,126.716408 1223.223755,122.997650
C1231.400269,112.500671 1239.273560,101.768028 1247.343994,91.187546
C1251.051270,86.327263 1254.881470,81.556633 1258.788696,76.855797
C1259.760620,75.686508 1261.248413,74.945900 1262.499023,74.008209
C1263.292480,75.345688 1264.457031,76.590347 1264.818726,78.035873
C1267.046143,86.937248 1268.891724,95.937119 1271.242432,104.803978
C1275.496948,120.851143 1280.156372,136.791153 1284.390381,152.843521
C1289.730957,173.090820 1294.707275,193.434189 1300.038086,213.684174
C1305.998291,236.325089 1312.179443,258.907806 1318.265015,281.515717
C1318.472290,282.285461 1318.685059,283.053680 1319.249390,285.117157
C1249.010864,270.419495 1179.575439,255.889877 1109.182129,241.159790
C1125.300659,224.247345 1141.057739,207.714233 1157.271729,190.701782
C1151.530518,186.784927 1146.192871,182.681778 1140.429565,179.305191
C1127.437134,171.693329 1114.523315,163.859375 1101.056763,157.163300
C1072.803589,143.114868 1043.187866,132.633057 1012.025146,127.306679
C996.903809,124.722130 981.545776,123.292236 966.235352,122.115311
C953.661621,121.148743 940.985535,120.787796 928.380005,121.118324
C905.687134,121.713341 883.266846,125.033134 861.164490,130.156235
C827.750183,137.901321 796.099426,150.481354 765.943542,166.659683
C744.045410,178.407761 723.100586,191.717087 704.741150,208.715820
C692.812561,219.760330 680.168945,230.111618 668.980225,241.854492
C657.360962,254.049179 646.193909,266.898956 636.478516,280.629303
C622.844910,299.897369 609.775757,319.708069 598.278931,340.299286
C589.203308,356.553925 582.410522,374.153534 575.426636,391.487335
C567.199646,411.906586 561.340576,433.110779 557.061401,454.725311
C556.900940,455.536224 556.898621,456.378479 556.660950,457.603760
z"/>
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M1706.087402,107.067314
C1703.089111,115.619484 1700.499512,124.342247 1697.015747,132.691925
C1686.536865,157.806900 1674.552490,182.225861 1658.662109,204.387024
C1646.541138,221.290833 1633.860840,237.859802 1620.447754,253.749130
C1610.171387,265.922516 1598.887085,277.376678 1587.116699,288.127747
C1567.458008,306.083740 1546.417847,322.320587 1524.483398,337.552246
C1495.366455,357.771515 1464.521729,374.787689 1432.470215,389.522156
C1408.761597,400.421356 1384.338989,409.873322 1359.856445,418.950348
C1338.651123,426.812286 1317.005859,433.538574 1295.377563,440.189545
C1282.541626,444.136749 1269.303589,446.756866 1256.353271,450.357635
C1243.725464,453.868683 1231.256226,457.945312 1218.677490,461.637756
C1192.216675,469.405334 1165.581299,476.616241 1139.306396,484.964813
C1122.046509,490.448944 1105.143555,497.158905 1088.355957,503.995453
C1073.956177,509.859589 1059.653931,516.113403 1045.836670,523.221436
C1027.095337,532.862488 1009.846802,544.765564 994.656799,559.637390
C986.521912,567.601807 977.590271,574.817322 968.586731,581.815613
C950.906799,595.557678 919.261353,591.257507 902.949524,575.751221
C890.393311,563.815002 883.972961,548.799927 878.270020,533.265137
C872.118042,516.506958 862.364990,502.109009 851.068176,488.567474
C837.824646,472.692474 824.675781,456.737396 811.315308,440.961517
C803.714661,431.986664 795.703918,423.360413 788.002930,414.468903
C778.470581,403.462769 769.106140,392.311340 759.596680,381.285126
C752.240295,372.755371 744.606201,364.460114 737.400879,355.806427
C730.120544,347.062592 727.078613,337.212921 730.571777,325.824554
C736.598145,306.177765 760.405457,299.432281 775.159790,313.874237
C789.284302,327.699738 802.566406,342.385803 816.219543,356.692902
C816.564697,357.054535 816.916931,357.409424 817.268250,357.765015
C845.125427,385.956726 872.707642,414.429291 901.026123,442.149719
C908.467834,449.434296 918.068054,454.575775 926.906189,460.350739
C933.051758,464.366333 939.634460,467.707123 945.928894,471.503540
C948.467102,473.034454 950.404358,472.612885 952.644043,470.884766
C972.771118,455.355255 994.347229,442.156677 1017.344299,431.336121
C1045.954834,417.874298 1075.032959,405.539398 1105.177612,395.868073
C1127.357422,388.752136 1149.351074,380.949371 1171.792480,374.778931
C1209.532104,364.402008 1247.646118,355.393494 1285.443359,345.217163
C1317.973999,336.458740 1350.391968,327.239716 1382.656372,317.547119
C1412.278198,308.648407 1441.114014,297.585785 1469.434570,285.016663
C1511.778687,266.223450 1552.020386,243.841415 1590.082031,217.493744
C1608.183228,204.963440 1625.881104,191.914856 1641.874268,176.685043
C1649.680786,169.250977 1658.483398,162.733627 1665.537964,154.666931
C1679.129517,139.125244 1691.799438,122.777374 1705.282837,106.722069
C1705.838623,106.809341 1705.963135,106.938339 1706.087402,107.067314
z"/>
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M1705.527710,106.486694
C1705.630859,105.936501 1705.907837,105.568420 1706.218628,105.231407
C1706.297485,105.145935 1706.483765,105.159477 1706.930664,105.055130
C1706.771118,105.730949 1706.643921,106.269272 1706.302246,106.937462
C1705.963135,106.938339 1705.838623,106.809341 1705.527710,106.486694
z"/>
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
d="
M196.850372,849.515076
C196.898865,849.852539 196.767776,850.076172 196.636688,850.299805
C196.648056,850.000305 196.659409,849.700745 196.850372,849.515076
z"/>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,89 @@
.confirmation-container {
background: white;
border-radius: 8px;
padding: 3rem 2rem;
max-width: 500px;
margin: 2rem auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
text-align: center;
}
.confirmation-icon {
width: 80px;
height: 80px;
background: #48bb78;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin: 0 auto 1.5rem;
}
.confirmation-container h2 {
font-size: 2rem;
color: #1a202c;
margin-bottom: 2rem;
}
.confirmation-details {
background: #f7fafc;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.5rem;
text-align: left;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #e2e8f0;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-weight: 600;
color: #4a5568;
}
.detail-value {
color: #2d3748;
}
.status-badge {
background: #48bb78;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
}
.confirmation-message {
color: #718096;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-done {
width: 100%;
padding: 0.75rem;
background: #3182ce;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-done:hover {
background: #2c5282;
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { format } from 'date-fns';
import './AppointmentConfirmation.css';
const AppointmentConfirmation = ({ appointment, onClose }) => {
const startTime = new Date(appointment.start_time);
return (
<div className="confirmation-container">
<div className="confirmation-icon"></div>
<h2>Booking Confirmed!</h2>
<div className="confirmation-details">
<div className="detail-row">
<span className="detail-label">Date:</span>
<span className="detail-value">{format(startTime, 'MMMM d, yyyy')}</span>
</div>
<div className="detail-row">
<span className="detail-label">Time:</span>
<span className="detail-value">{format(startTime, 'h:mm a')}</span>
</div>
<div className="detail-row">
<span className="detail-label">Status:</span>
<span className="detail-value status-badge">{appointment.status}</span>
</div>
</div>
<p className="confirmation-message">
You will receive a confirmation email shortly with all the details.
</p>
<button onClick={onClose} className="btn-done">
Done
</button>
</div>
);
};
export default AppointmentConfirmation;

View File

@@ -0,0 +1,137 @@
.booking-form-container {
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 500px;
margin: 2rem auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.booking-form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.booking-form-header h2 {
font-size: 1.75rem;
color: #1a202c;
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
color: #718096;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
}
.close-btn:hover {
background: #edf2f7;
}
.service-summary {
background: #f7fafc;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
}
.service-summary p {
margin: 0.5rem 0;
color: #4a5568;
}
.booking-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-weight: 600;
margin-bottom: 0.5rem;
color: #2d3748;
}
.form-group input,
.form-group select {
padding: 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3182ce;
}
.form-group input.error,
.form-group select.error {
border-color: #e53e3e;
}
.error-message {
color: #e53e3e;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.btn-cancel,
.btn-submit {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-cancel {
background: #edf2f7;
color: #4a5568;
}
.btn-cancel:hover {
background: #e2e8f0;
}
.btn-submit {
background: #3182ce;
color: white;
}
.btn-submit:hover:not(:disabled) {
background: #2c5282;
}
.btn-submit:disabled {
background: #a0aec0;
cursor: not-allowed;
}

View File

@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { format } from 'date-fns';
import './BookingForm.css';
const BookingForm = ({ service, resources, onSubmit, onCancel, loading }) => {
const [formData, setFormData] = useState({
resource: resources?.[0]?.id || '',
date: '',
time: '',
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error for this field
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.resource) {
newErrors.resource = 'Please select a resource';
}
if (!formData.date) {
newErrors.date = 'Please select a date';
}
if (!formData.time) {
newErrors.time = 'Please select a time';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) {
return;
}
// Combine date and time into ISO format
const startDateTime = new Date(`${formData.date}T${formData.time}`);
const endDateTime = new Date(startDateTime.getTime() + service.duration * 60000);
const appointmentData = {
service: service.id,
resource: parseInt(formData.resource),
start_time: startDateTime.toISOString(),
end_time: endDateTime.toISOString(),
};
onSubmit(appointmentData);
};
return (
<div className="booking-form-container">
<div className="booking-form-header">
<h2>Book: {service.name}</h2>
<button onClick={onCancel} className="close-btn">×</button>
</div>
<div className="service-summary">
<p><strong>Duration:</strong> {service.duration} minutes</p>
<p><strong>Price:</strong> ${service.price}</p>
</div>
<form onSubmit={handleSubmit} className="booking-form">
<div className="form-group">
<label htmlFor="resource">Select Provider</label>
<select
id="resource"
name="resource"
value={formData.resource}
onChange={handleChange}
className={errors.resource ? 'error' : ''}
>
<option value="">Choose a provider...</option>
{resources?.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
{errors.resource && <span className="error-message">{errors.resource}</span>}
</div>
<div className="form-group">
<label htmlFor="date">Date</label>
<input
type="date"
id="date"
name="date"
value={formData.date}
onChange={handleChange}
min={format(new Date(), 'yyyy-MM-dd')}
className={errors.date ? 'error' : ''}
/>
{errors.date && <span className="error-message">{errors.date}</span>}
</div>
<div className="form-group">
<label htmlFor="time">Time</label>
<input
type="time"
id="time"
name="time"
value={formData.time}
onChange={handleChange}
className={errors.time ? 'error' : ''}
/>
{errors.time && <span className="error-message">{errors.time}</span>}
</div>
<div className="form-actions">
<button type="button" onClick={onCancel} className="btn-cancel">
Cancel
</button>
<button type="submit" className="btn-submit" disabled={loading}>
{loading ? 'Booking...' : 'Confirm Booking'}
</button>
</div>
</form>
</div>
);
};
export default BookingForm;

View File

@@ -0,0 +1,269 @@
/**
* Stripe Connect Onboarding Component
* For paid-tier businesses to connect their Stripe account via Connect
*/
import React, { useState } from 'react';
import {
ExternalLink,
CheckCircle,
AlertCircle,
Loader2,
RefreshCw,
CreditCard,
Wallet,
} from 'lucide-react';
import { ConnectAccountInfo } from '../api/payments';
import { useConnectOnboarding, useRefreshConnectLink } from '../hooks/usePayments';
interface ConnectOnboardingProps {
connectAccount: ConnectAccountInfo | null;
tier: string;
onSuccess?: () => void;
}
const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
connectAccount,
tier,
onSuccess,
}) => {
const [error, setError] = useState<string | null>(null);
const onboardingMutation = useConnectOnboarding();
const refreshLinkMutation = useRefreshConnectLink();
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
const isOnboarding = connectAccount?.status === 'onboarding' ||
(connectAccount && !connectAccount.onboarding_complete);
const needsOnboarding = !connectAccount;
const getReturnUrls = () => {
const baseUrl = window.location.origin;
return {
refreshUrl: `${baseUrl}/payments?connect=refresh`,
returnUrl: `${baseUrl}/payments?connect=complete`,
};
};
const handleStartOnboarding = async () => {
setError(null);
try {
const { refreshUrl, returnUrl } = getReturnUrls();
const result = await onboardingMutation.mutateAsync({ refreshUrl, returnUrl });
// Redirect to Stripe onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to start onboarding');
}
};
const handleRefreshLink = async () => {
setError(null);
try {
const { refreshUrl, returnUrl } = getReturnUrls();
const result = await refreshLinkMutation.mutateAsync({ refreshUrl, returnUrl });
// Redirect to continue onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to refresh onboarding link');
}
};
// Account type display
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
case 'express':
return 'Express Connect';
case 'custom':
return 'Custom Connect';
default:
return 'Connect';
}
};
return (
<div className="space-y-6">
{/* Active Account Status */}
{isActive && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<p className="text-sm text-green-700 mt-1">
Your Stripe account is connected and ready to accept payments.
</p>
</div>
</div>
</div>
)}
{/* Account Details */}
{connectAccount && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
connectAccount.status === 'active'
? 'bg-green-100 text-green-800'
: connectAccount.status === 'onboarding'
? 'bg-yellow-100 text-yellow-800'
: connectAccount.status === 'restricted'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="flex items-center gap-1">
{connectAccount.charges_enabled ? (
<>
<CreditCard size={14} className="text-green-600" />
<span className="text-green-600">Enabled</span>
</>
) : (
<>
<CreditCard size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
</>
)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="flex items-center gap-1">
{connectAccount.payouts_enabled ? (
<>
<Wallet size={14} className="text-green-600" />
<span className="text-green-600">Enabled</span>
</>
) : (
<>
<Wallet size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
</>
)}
</span>
</div>
{connectAccount.stripe_account_id && (
<div className="flex justify-between">
<span className="text-gray-600">Account ID:</span>
<code className="font-mono text-gray-900 text-xs">
{connectAccount.stripe_account_id}
</code>
</div>
)}
</div>
</div>
)}
{/* Onboarding in Progress */}
{isOnboarding && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-yellow-800">Complete Onboarding</h4>
<p className="text-sm text-yellow-700 mt-1">
Your Stripe Connect account setup is incomplete.
Click below to continue the onboarding process.
</p>
<button
onClick={handleRefreshLink}
disabled={refreshLinkMutation.isPending}
className="mt-3 flex items-center gap-2 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-lg hover:bg-yellow-200 disabled:opacity-50"
>
{refreshLinkMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<RefreshCw size={16} />
)}
Continue Onboarding
</button>
</div>
</div>
</div>
)}
{/* Start Onboarding */}
{needsOnboarding && (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-800 mb-2">Connect with Stripe</h4>
<p className="text-sm text-blue-700">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
This provides a seamless payment experience for your customers while
the platform handles payment processing.
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
</li>
</ul>
</div>
<button
onClick={handleStartOnboarding}
disabled={onboardingMutation.isPending}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] disabled:opacity-50"
>
{onboardingMutation.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<ExternalLink size={18} />
Connect with Stripe
</>
)}
</button>
</div>
)}
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-2 text-red-800">
<AlertCircle size={18} className="shrink-0 mt-0.5" />
<span className="text-sm">{error}</span>
</div>
</div>
)}
{/* External Stripe Dashboard Link */}
{isActive && (
<a
href="https://dashboard.stripe.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
>
<ExternalLink size={14} />
Open Stripe Dashboard
</a>
)}
</div>
);
};
export default ConnectOnboarding;

View File

@@ -0,0 +1,290 @@
/**
* Embedded Stripe Connect Onboarding Component
*
* Uses Stripe's Connect embedded components to provide a seamless
* onboarding experience without redirecting users away from the app.
*/
import React, { useState, useCallback } from 'react';
import {
ConnectComponentsProvider,
ConnectAccountOnboarding,
} from '@stripe/react-connect-js';
import { loadConnectAndInitialize } from '@stripe/connect-js';
import type { StripeConnectInstance } from '@stripe/connect-js';
import {
CheckCircle,
AlertCircle,
Loader2,
CreditCard,
Wallet,
Building2,
} from 'lucide-react';
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
interface ConnectOnboardingEmbedProps {
connectAccount: ConnectAccountInfo | null;
tier: string;
onComplete?: () => void;
onError?: (error: string) => void;
}
type LoadingState = 'idle' | 'loading' | 'ready' | 'error' | 'complete';
const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
connectAccount,
tier,
onComplete,
onError,
}) => {
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
// Initialize Stripe Connect
const initializeStripeConnect = useCallback(async () => {
if (loadingState === 'loading' || loadingState === 'ready') return;
setLoadingState('loading');
setErrorMessage(null);
try {
// Fetch account session from our backend
const response = await createAccountSession();
const { client_secret, publishable_key } = response.data;
// Initialize the Connect instance
const instance = await loadConnectAndInitialize({
publishableKey: publishable_key,
fetchClientSecret: async () => client_secret,
appearance: {
overlays: 'drawer',
variables: {
colorPrimary: '#635BFF',
colorBackground: '#ffffff',
colorText: '#1a1a1a',
colorDanger: '#df1b41',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSizeBase: '14px',
spacingUnit: '4px',
borderRadius: '8px',
},
},
});
setStripeConnectInstance(instance);
setLoadingState('ready');
} catch (err: any) {
console.error('Failed to initialize Stripe Connect:', err);
const message = err.response?.data?.error || err.message || 'Failed to initialize payment setup';
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}
}, [loadingState, onError]);
// Handle onboarding completion
const handleOnboardingExit = useCallback(async () => {
// Refresh status from Stripe to sync the local database
try {
await refreshConnectStatus();
} catch (err) {
console.error('Failed to refresh Connect status:', err);
}
setLoadingState('complete');
onComplete?.();
}, [onComplete]);
// Handle errors from the Connect component
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
console.error('Connect component load error:', loadError);
const message = loadError.error.message || 'Failed to load payment component';
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}, [onError]);
// Account type display
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
case 'express':
return 'Express Connect';
case 'custom':
return 'Custom Connect';
default:
return 'Connect';
}
};
// If account is already active, show status
if (isActive) {
return (
<div className="space-y-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<p className="text-sm text-green-700 mt-1">
Your Stripe account is connected and ready to accept payments.
</p>
</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="flex items-center gap-1 text-green-600">
<CreditCard size={14} />
Enabled
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="flex items-center gap-1 text-green-600">
<Wallet size={14} />
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
</span>
</div>
</div>
</div>
</div>
);
}
// Completion state
if (loadingState === 'complete') {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 mb-3" size={48} />
<h4 className="font-medium text-green-800 text-lg">Onboarding Complete!</h4>
<p className="text-sm text-green-700 mt-2">
Your Stripe account has been set up. You can now accept payments.
</p>
</div>
);
}
// Error state
if (loadingState === 'error') {
return (
<div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-red-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-red-800">Setup Failed</h4>
<p className="text-sm text-red-700 mt-1">{errorMessage}</p>
</div>
</div>
</div>
<button
onClick={() => {
setLoadingState('idle');
setErrorMessage(null);
}}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Try Again
</button>
</div>
);
}
// Idle state - show start button
if (loadingState === 'idle') {
return (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<Building2 className="text-blue-600 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-blue-800">Set Up Payments</h4>
<p className="text-sm text-blue-700 mt-1">
As a {tier} tier business, you'll use Stripe Connect to accept payments.
Complete the onboarding process to start accepting payments from your customers.
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
</li>
</ul>
</div>
</div>
</div>
<button
onClick={initializeStripeConnect}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
>
<CreditCard size={18} />
Start Payment Setup
</button>
</div>
);
}
// Loading state
if (loadingState === 'loading') {
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
<p className="text-gray-600">Initializing payment setup...</p>
</div>
);
}
// Ready state - show embedded onboarding
if (loadingState === 'ready' && stripeConnectInstance) {
return (
<div className="space-y-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Complete Your Account Setup</h4>
<p className="text-sm text-gray-600">
Fill out the information below to finish setting up your payment account.
Your information is securely handled by Stripe.
</p>
</div>
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white p-4">
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
<ConnectAccountOnboarding
onExit={handleOnboardingExit}
onLoadError={handleLoadError}
/>
</ConnectComponentsProvider>
</div>
</div>
);
}
return null;
};
export default ConnectOnboardingEmbed;

View File

@@ -0,0 +1,636 @@
import React, { useState } from 'react';
import {
Search,
Globe,
Check,
X,
ShoppingCart,
Loader2,
ChevronRight,
Shield,
RefreshCw,
AlertCircle,
} from 'lucide-react';
import {
useDomainSearch,
useRegisterDomain,
useRegisteredDomains,
type DomainAvailability,
type RegistrantContact,
} from '../hooks/useDomains';
interface DomainPurchaseProps {
onSuccess?: () => void;
}
type Step = 'search' | 'details' | 'confirm';
const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
const [step, setStep] = useState<Step>('search');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DomainAvailability[]>([]);
const [selectedDomain, setSelectedDomain] = useState<DomainAvailability | null>(null);
const [years, setYears] = useState(1);
const [whoisPrivacy, setWhoisPrivacy] = useState(true);
const [autoRenew, setAutoRenew] = useState(true);
const [autoConfigureDomain, setAutoConfigureDomain] = useState(true);
// Contact info form state
const [contact, setContact] = useState<RegistrantContact>({
first_name: '',
last_name: '',
email: '',
phone: '',
address: '',
city: '',
state: '',
zip_code: '',
country: 'US',
});
const searchMutation = useDomainSearch();
const registerMutation = useRegisterDomain();
const { data: registeredDomains } = useRegisteredDomains();
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
try {
const results = await searchMutation.mutateAsync({
query: searchQuery,
tlds: ['.com', '.net', '.org', '.io', '.co'],
});
setSearchResults(results);
} catch {
// Error is handled by React Query
}
};
const handleSelectDomain = (domain: DomainAvailability) => {
setSelectedDomain(domain);
setStep('details');
};
const handlePurchase = async () => {
if (!selectedDomain) return;
try {
await registerMutation.mutateAsync({
domain: selectedDomain.domain,
years,
whois_privacy: whoisPrivacy,
auto_renew: autoRenew,
contact,
auto_configure: autoConfigureDomain,
});
// Reset and go back to search
setStep('search');
setSearchQuery('');
setSearchResults([]);
setSelectedDomain(null);
onSuccess?.();
} catch {
// Error is handled by React Query
}
};
const updateContact = (field: keyof RegistrantContact, value: string) => {
setContact((prev) => ({ ...prev, [field]: value }));
};
const isContactValid = () => {
return (
contact.first_name &&
contact.last_name &&
contact.email &&
contact.phone &&
contact.address &&
contact.city &&
contact.state &&
contact.zip_code &&
contact.country
);
};
const getPrice = () => {
if (!selectedDomain) return 0;
const basePrice = selectedDomain.premium_price || selectedDomain.price || 0;
return basePrice * years;
};
return (
<div className="space-y-6">
{/* Steps indicator */}
<div className="flex items-center gap-4">
<div
className={`flex items-center gap-2 ${
step === 'search' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step === 'search'
? 'bg-brand-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
1
</div>
<span className="text-sm font-medium">Search</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div
className={`flex items-center gap-2 ${
step === 'details' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step === 'details'
? 'bg-brand-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
2
</div>
<span className="text-sm font-medium">Details</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div
className={`flex items-center gap-2 ${
step === 'confirm' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
}`}
>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
step === 'confirm'
? 'bg-brand-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
3
</div>
<span className="text-sm font-medium">Confirm</span>
</div>
</div>
{/* Step 1: Search */}
{step === 'search' && (
<div className="space-y-6">
<form onSubmit={handleSearch} className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Enter domain name or keyword..."
className="w-full pl-10 pr-4 py-3 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-brand-500"
/>
</div>
<button
type="submit"
disabled={searchMutation.isPending || !searchQuery.trim()}
className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{searchMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Search className="h-5 w-5" />
)}
Search
</button>
</form>
{/* Search Results */}
{searchResults.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium text-gray-900 dark:text-white">Search Results</h4>
<div className="space-y-2">
{searchResults.map((result) => (
<div
key={result.domain}
className={`flex items-center justify-between p-4 rounded-lg border ${
result.available
? 'border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50'
}`}
>
<div className="flex items-center gap-3">
{result.available ? (
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<X className="h-5 w-5 text-gray-400" />
)}
<div>
<span className="font-medium text-gray-900 dark:text-white">
{result.domain}
</span>
{result.premium && (
<span className="ml-2 px-2 py-0.5 text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded">
Premium
</span>
)}
</div>
</div>
<div className="flex items-center gap-4">
{result.available && (
<>
<span className="font-semibold text-gray-900 dark:text-white">
${(result.premium_price || result.price || 0).toFixed(2)}/yr
</span>
<button
onClick={() => handleSelectDomain(result)}
className="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700 flex items-center gap-2"
>
<ShoppingCart className="h-4 w-4" />
Select
</button>
</>
)}
{!result.available && (
<span className="text-sm text-gray-500 dark:text-gray-400">Unavailable</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Registered Domains */}
{registeredDomains && registeredDomains.length > 0 && (
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
Your Registered Domains
</h4>
<div className="space-y-2">
{registeredDomains.map((domain) => (
<div
key={domain.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"
>
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-gray-400" />
<span className="font-medium text-gray-900 dark:text-white">
{domain.domain}
</span>
<span
className={`px-2 py-0.5 text-xs rounded ${
domain.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{domain.status}
</span>
</div>
{domain.expires_at && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Expires: {new Date(domain.expires_at).toLocaleDateString()}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Step 2: Details */}
{step === 'details' && selectedDomain && (
<div className="space-y-6">
{/* Selected Domain */}
<div className="p-4 bg-brand-50 dark:bg-brand-900/20 rounded-lg border border-brand-200 dark:border-brand-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Globe className="h-6 w-6 text-brand-600 dark:text-brand-400" />
<span className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedDomain.domain}
</span>
</div>
<button
onClick={() => setStep('search')}
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
Change
</button>
</div>
</div>
{/* Registration Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Registration Period
</label>
<select
value={years}
onChange={(e) => setYears(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
{[1, 2, 3, 5, 10].map((y) => (
<option key={y} value={y}>
{y} {y === 1 ? 'year' : 'years'} - $
{((selectedDomain.premium_price || selectedDomain.price || 0) * y).toFixed(2)}
</option>
))}
</select>
</div>
</div>
{/* Privacy & Auto-renew */}
<div className="space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={whoisPrivacy}
onChange={(e) => setWhoisPrivacy(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">
WHOIS Privacy Protection
</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Hide your personal information from public WHOIS lookups
</p>
</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoRenew}
onChange={(e) => setAutoRenew(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div className="flex items-center gap-2">
<RefreshCw className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">Auto-Renewal</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically renew this domain before it expires
</p>
</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoConfigureDomain}
onChange={(e) => setAutoConfigureDomain(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">
Auto-configure as Custom Domain
</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically set up this domain for your business
</p>
</div>
</div>
</label>
</div>
{/* Contact Information */}
<div className="pt-6 border-t border-gray-100 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
Registrant Information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name *
</label>
<input
type="text"
value={contact.first_name}
onChange={(e) => updateContact('first_name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name *
</label>
<input
type="text"
value={contact.last_name}
onChange={(e) => updateContact('last_name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
</label>
<input
type="email"
value={contact.email}
onChange={(e) => updateContact('email', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone *
</label>
<input
type="tel"
value={contact.phone}
onChange={(e) => updateContact('phone', e.target.value)}
placeholder="+1.5551234567"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Address *
</label>
<input
type="text"
value={contact.address}
onChange={(e) => updateContact('address', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City *
</label>
<input
type="text"
value={contact.city}
onChange={(e) => updateContact('city', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
State/Province *
</label>
<input
type="text"
value={contact.state}
onChange={(e) => updateContact('state', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
ZIP/Postal Code *
</label>
<input
type="text"
value={contact.zip_code}
onChange={(e) => updateContact('zip_code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country *
</label>
<select
value={contact.country}
onChange={(e) => updateContact('country', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
</select>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-between pt-4">
<button
onClick={() => setStep('search')}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Back
</button>
<button
onClick={() => setStep('confirm')}
disabled={!isContactValid()}
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue
</button>
</div>
</div>
)}
{/* Step 3: Confirm */}
{step === 'confirm' && selectedDomain && (
<div className="space-y-6">
<h4 className="font-medium text-gray-900 dark:text-white">Order Summary</h4>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Domain</span>
<span className="font-medium text-gray-900 dark:text-white">
{selectedDomain.domain}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Registration Period</span>
<span className="font-medium text-gray-900 dark:text-white">
{years} {years === 1 ? 'year' : 'years'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">WHOIS Privacy</span>
<span className="font-medium text-gray-900 dark:text-white">
{whoisPrivacy ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Auto-Renewal</span>
<span className="font-medium text-gray-900 dark:text-white">
{autoRenew ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between">
<span className="font-semibold text-gray-900 dark:text-white">Total</span>
<span className="font-bold text-xl text-brand-600 dark:text-brand-400">
${getPrice().toFixed(2)}
</span>
</div>
</div>
</div>
{/* Registrant Summary */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Registrant</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{contact.first_name} {contact.last_name}
<br />
{contact.email}
<br />
{contact.address}
<br />
{contact.city}, {contact.state} {contact.zip_code}
</p>
</div>
{registerMutation.isError && (
<div className="flex items-center gap-2 p-4 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
<AlertCircle className="h-5 w-5" />
<span>Registration failed. Please try again.</span>
</div>
)}
{/* Actions */}
<div className="flex justify-between pt-4">
<button
onClick={() => setStep('details')}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Back
</button>
<button
onClick={handlePurchase}
disabled={registerMutation.isPending}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{registerMutation.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ShoppingCart className="h-5 w-5" />
)}
Complete Purchase
</button>
</div>
</div>
)}
</div>
);
};
export default DomainPurchase;

View File

@@ -0,0 +1,111 @@
/**
* Language Selector Component
* Dropdown for selecting the application language
*/
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe, Check, ChevronDown } from 'lucide-react';
import { supportedLanguages, SupportedLanguage } from '../i18n';
interface LanguageSelectorProps {
variant?: 'dropdown' | 'inline';
showFlag?: boolean;
className?: string;
}
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
variant = 'dropdown',
showFlag = true,
className = '',
}) => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const currentLanguage = supportedLanguages.find(
(lang) => lang.code === i18n.language
) || supportedLanguages[0];
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 handleLanguageChange = (code: SupportedLanguage) => {
i18n.changeLanguage(code);
setIsOpen(false);
};
if (variant === 'inline') {
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{supportedLanguages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
i18n.language === lang.code
? 'bg-brand-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
>
{showFlag && <span className="mr-1.5">{lang.flag}</span>}
{lang.name}
</button>
))}
</div>
);
}
return (
<div ref={dropdownRef} className={`relative ${className}`}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<Globe className="w-4 h-4" />
{showFlag && <span>{currentLanguage.flag}</span>}
<span className="hidden sm:inline">{currentLanguage.name}</span>
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</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">
<ul role="listbox" aria-label="Select language">
{supportedLanguages.map((lang) => (
<li key={lang.code}>
<button
onClick={() => handleLanguageChange(lang.code)}
className={`w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors ${
i18n.language === lang.code
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
role="option"
aria-selected={i18n.language === lang.code}
>
<span className="text-lg">{lang.flag}</span>
<span className="flex-1">{lang.name}</span>
{i18n.language === lang.code && (
<Check className="w-4 h-4 text-brand-600 dark:text-brand-400" />
)}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default LanguageSelector;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Eye, XCircle } from 'lucide-react';
import { User } from '../types';
interface MasqueradeBannerProps {
effectiveUser: User;
originalUser: User;
previousUser: User | null;
onStop: () => void;
}
const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, originalUser, previousUser, onStop }) => {
const buttonText = previousUser ? `Return to ${previousUser.name}` : 'Stop Masquerading';
return (
<div className="bg-orange-600 text-white px-4 py-2 shadow-md flex items-center justify-between z-50 relative">
<div className="flex items-center gap-3">
<div className="p-1.5 bg-white/20 rounded-full animate-pulse">
<Eye size={18} />
</div>
<span className="text-sm font-medium">
Masquerading as <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
<span className="opacity-75 mx-2 text-xs">|</span>
Logged in as {originalUser.name}
</span>
</div>
<button
onClick={onStop}
className="flex items-center gap-2 px-3 py-1 text-xs font-bold uppercase bg-white text-orange-600 rounded hover:bg-orange-50 transition-colors"
>
<XCircle size={14} />
{buttonText}
</button>
</div>
);
};
export default MasqueradeBanner;

View File

@@ -0,0 +1,156 @@
/**
* OAuth Buttons Component
* Displays OAuth provider buttons with icons and brand colors
*/
import React from 'react';
import { Loader2 } from 'lucide-react';
import { useInitiateOAuth, useOAuthProviders } from '../hooks/useOAuth';
interface OAuthButtonsProps {
onSuccess?: () => void;
disabled?: boolean;
}
// Provider configurations with colors and icons
const providerConfig: Record<
string,
{
name: string;
bgColor: string;
hoverColor: string;
textColor: string;
icon: string;
}
> = {
google: {
name: 'Google',
bgColor: 'bg-white',
hoverColor: 'hover:bg-gray-50',
textColor: 'text-gray-900',
icon: 'G',
},
apple: {
name: 'Apple',
bgColor: 'bg-black',
hoverColor: 'hover:bg-gray-900',
textColor: 'text-white',
icon: '',
},
facebook: {
name: 'Facebook',
bgColor: 'bg-[#1877F2]',
hoverColor: 'hover:bg-[#166FE5]',
textColor: 'text-white',
icon: 'f',
},
linkedin: {
name: 'LinkedIn',
bgColor: 'bg-[#0A66C2]',
hoverColor: 'hover:bg-[#095196]',
textColor: 'text-white',
icon: 'in',
},
microsoft: {
name: 'Microsoft',
bgColor: 'bg-[#00A4EF]',
hoverColor: 'hover:bg-[#0078D4]',
textColor: 'text-white',
icon: 'M',
},
x: {
name: 'X',
bgColor: 'bg-black',
hoverColor: 'hover:bg-gray-900',
textColor: 'text-white',
icon: 'X',
},
twitch: {
name: 'Twitch',
bgColor: 'bg-[#9146FF]',
hoverColor: 'hover:bg-[#7D3ACE]',
textColor: 'text-white',
icon: 'T',
},
};
const OAuthButtons: React.FC<OAuthButtonsProps> = ({ onSuccess, disabled = false }) => {
const { data: providers, isLoading } = useOAuthProviders();
const initiateMutation = useInitiateOAuth();
const handleOAuthClick = (providerId: string) => {
if (disabled || initiateMutation.isPending) return;
initiateMutation.mutate(providerId, {
onSuccess: () => {
onSuccess?.();
},
onError: (error) => {
console.error('OAuth initiation error:', error);
},
});
};
if (isLoading) {
return (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
);
}
if (!providers || providers.length === 0) {
return null;
}
return (
<div className="space-y-3">
{providers.map((provider) => {
const config = providerConfig[provider.name] || {
name: provider.display_name,
bgColor: 'bg-gray-600',
hoverColor: 'hover:bg-gray-700',
textColor: 'text-white',
icon: provider.display_name.charAt(0).toUpperCase(),
};
const isCurrentlyLoading =
initiateMutation.isPending && initiateMutation.variables === provider.name;
return (
<button
key={provider.name}
type="button"
onClick={() => handleOAuthClick(provider.name)}
disabled={disabled || initiateMutation.isPending}
className={`
w-full flex items-center justify-center gap-3 py-3 px-4
border rounded-lg shadow-sm text-sm font-medium
transition-all duration-200 ease-in-out transform active:scale-[0.98]
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400
disabled:opacity-50 disabled:cursor-not-allowed
${config.bgColor} ${config.hoverColor} ${config.textColor}
${provider.name === 'google' ? 'border-gray-300 dark:border-gray-700' : 'border-transparent'}
`}
>
{isCurrentlyLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Connecting...</span>
</>
) : (
<>
<span className="flex items-center justify-center w-5 h-5 font-bold text-sm">
{config.icon}
</span>
<span>Continue with {config.name}</span>
</>
)}
</button>
);
})}
</div>
);
};
export default OAuthButtons;

View File

@@ -0,0 +1,329 @@
/**
* Onboarding Wizard Component
* Multi-step wizard for paid-tier businesses to complete post-signup setup
* Step 1: Welcome/Overview
* Step 2: Stripe Connect setup (embedded)
* Step 3: Completion
*/
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
CheckCircle,
CreditCard,
Rocket,
ArrowRight,
Sparkles,
Loader2,
X,
AlertCircle,
} from 'lucide-react';
import { Business } from '../types';
import { usePaymentConfig } from '../hooks/usePayments';
import { useUpdateBusiness } from '../hooks/useBusiness';
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
interface OnboardingWizardProps {
business: Business;
onComplete: () => void;
onSkip?: () => void;
}
type OnboardingStep = 'welcome' | 'stripe' | 'complete';
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
business,
onComplete,
onSkip,
}) => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [currentStep, setCurrentStep] = useState<OnboardingStep>('welcome');
const { data: paymentConfig, isLoading: configLoading, refetch: refetchConfig } = usePaymentConfig();
const updateBusinessMutation = useUpdateBusiness();
// Check if Stripe Connect is complete
const isStripeConnected = paymentConfig?.connect_account?.status === 'active' &&
paymentConfig?.connect_account?.charges_enabled;
// Handle return from Stripe Connect (for fallback redirect flow)
useEffect(() => {
const connectStatus = searchParams.get('connect');
if (connectStatus === 'complete' || connectStatus === 'refresh') {
// User returned from Stripe, refresh the config
refetchConfig();
// Clear the search params
setSearchParams({});
// Show stripe step to verify completion
setCurrentStep('stripe');
}
}, [searchParams, refetchConfig, setSearchParams]);
// Auto-advance to complete step when Stripe is connected
useEffect(() => {
if (isStripeConnected && currentStep === 'stripe') {
setCurrentStep('complete');
}
}, [isStripeConnected, currentStep]);
// Handle embedded onboarding completion
const handleEmbeddedOnboardingComplete = () => {
refetchConfig();
setCurrentStep('complete');
};
// Handle embedded onboarding error
const handleEmbeddedOnboardingError = (error: string) => {
console.error('Embedded onboarding error:', error);
};
const handleCompleteOnboarding = async () => {
try {
await updateBusinessMutation.mutateAsync({ initialSetupComplete: true });
onComplete();
} catch (err) {
console.error('Failed to complete onboarding:', err);
onComplete(); // Still call onComplete even if the update fails
}
};
const handleSkip = async () => {
try {
await updateBusinessMutation.mutateAsync({ initialSetupComplete: true });
} catch (err) {
console.error('Failed to skip onboarding:', err);
}
if (onSkip) {
onSkip();
} else {
onComplete();
}
};
const steps = [
{ key: 'welcome', label: t('onboarding.steps.welcome') },
{ key: 'stripe', label: t('onboarding.steps.payments') },
{ key: 'complete', label: t('onboarding.steps.complete') },
];
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
// Step indicator component
const StepIndicator = () => (
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step, index) => (
<React.Fragment key={step.key}>
<div
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors ${
index < currentStepIndex
? 'bg-green-500 text-white'
: index === currentStepIndex
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{index < currentStepIndex ? (
<CheckCircle size={16} />
) : (
index + 1
)}
</div>
{index < steps.length - 1 && (
<div
className={`w-12 h-0.5 ${
index < currentStepIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
);
// Welcome step
const WelcomeStep = () => (
<div className="text-center">
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mb-6">
<Sparkles className="text-white" size={32} />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{t('onboarding.welcome.title', { businessName: business.name })}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 max-w-md mx-auto">
{t('onboarding.welcome.subtitle')}
</p>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 mb-6 max-w-md mx-auto">
<h3 className="font-medium text-gray-900 dark:text-white mb-3 text-left">
{t('onboarding.welcome.whatsIncluded')}
</h3>
<ul className="space-y-2 text-left">
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<CreditCard size={18} className="text-blue-500 shrink-0" />
<span>{t('onboarding.welcome.connectStripe')}</span>
</li>
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<CheckCircle size={18} className="text-green-500 shrink-0" />
<span>{t('onboarding.welcome.automaticPayouts')}</span>
</li>
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
<CheckCircle size={18} className="text-green-500 shrink-0" />
<span>{t('onboarding.welcome.pciCompliance')}</span>
</li>
</ul>
</div>
<div className="flex flex-col gap-3 max-w-xs mx-auto">
<button
onClick={() => setCurrentStep('stripe')}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
{t('onboarding.welcome.getStarted')}
<ArrowRight size={18} />
</button>
<button
onClick={handleSkip}
className="w-full px-6 py-2 text-gray-500 dark:text-gray-400 text-sm hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
>
{t('onboarding.welcome.skip')}
</button>
</div>
</div>
);
// Stripe Connect step - uses embedded onboarding
const StripeStep = () => (
<div>
<div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-[#635BFF] rounded-full flex items-center justify-center mb-6">
<CreditCard className="text-white" size={32} />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{t('onboarding.stripe.title')}
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
{t('onboarding.stripe.subtitle', { plan: business.plan })}
</p>
</div>
{configLoading ? (
<div className="flex items-center justify-center gap-2 py-8">
<Loader2 className="animate-spin text-gray-400" size={24} />
<span className="text-gray-500">{t('onboarding.stripe.checkingStatus')}</span>
</div>
) : isStripeConnected ? (
<div className="space-y-4 max-w-md mx-auto">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center gap-3">
<CheckCircle className="text-green-600 dark:text-green-400" size={24} />
<div className="text-left">
<h4 className="font-medium text-green-800 dark:text-green-300">
{t('onboarding.stripe.connected.title')}
</h4>
<p className="text-sm text-green-700 dark:text-green-400">
{t('onboarding.stripe.connected.subtitle')}
</p>
</div>
</div>
</div>
<button
onClick={() => setCurrentStep('complete')}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
{t('onboarding.stripe.continue')}
<ArrowRight size={18} />
</button>
</div>
) : (
<div className="max-w-md mx-auto">
<ConnectOnboardingEmbed
connectAccount={paymentConfig?.connect_account || null}
tier={business.plan}
onComplete={handleEmbeddedOnboardingComplete}
onError={handleEmbeddedOnboardingError}
/>
<button
onClick={handleSkip}
className="w-full mt-4 px-6 py-2 text-gray-500 dark:text-gray-400 text-sm hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
>
{t('onboarding.stripe.doLater')}
</button>
</div>
)}
</div>
);
// Complete step
const CompleteStep = () => (
<div className="text-center">
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-green-400 to-green-600 rounded-full flex items-center justify-center mb-6">
<Rocket className="text-white" size={32} />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{t('onboarding.complete.title')}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 max-w-md mx-auto">
{t('onboarding.complete.subtitle')}
</p>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6 max-w-md mx-auto">
<ul className="space-y-2 text-left">
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
<CheckCircle size={16} className="shrink-0" />
<span>{t('onboarding.complete.checklist.accountCreated')}</span>
</li>
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
<CheckCircle size={16} className="shrink-0" />
<span>{t('onboarding.complete.checklist.stripeConfigured')}</span>
</li>
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
<CheckCircle size={16} className="shrink-0" />
<span>{t('onboarding.complete.checklist.readyForPayments')}</span>
</li>
</ul>
</div>
<button
onClick={handleCompleteOnboarding}
disabled={updateBusinessMutation.isPending}
className="px-8 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{updateBusinessMutation.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
t('onboarding.complete.goToDashboard')
)}
</button>
</div>
);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-auto">
{/* Header with close button */}
<div className="flex justify-end p-4 pb-0">
<button
onClick={handleSkip}
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-800 transition-colors"
title={t('onboarding.skipForNow')}
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="px-8 pb-8">
<StepIndicator />
{currentStep === 'welcome' && <WelcomeStep />}
{currentStep === 'stripe' && <StripeStep />}
{currentStep === 'complete' && <CompleteStep />}
</div>
</div>
</div>
);
};
export default OnboardingWizard;

View File

@@ -0,0 +1,220 @@
/**
* Payment Settings Section Component
* Unified payment configuration UI that shows the appropriate setup
* based on the business tier (API keys for Free, Connect for Paid)
*/
import React from 'react';
import {
CreditCard,
CheckCircle,
AlertCircle,
Loader2,
FlaskConical,
Zap,
} from 'lucide-react';
import { Business } from '../types';
import { usePaymentConfig } from '../hooks/usePayments';
import StripeApiKeysForm from './StripeApiKeysForm';
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
interface PaymentSettingsSectionProps {
business: Business;
}
type PaymentModeType = 'direct_api' | 'connect' | 'none';
const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ business }) => {
const { data: config, isLoading, error, refetch } = usePaymentConfig();
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3">
<Loader2 className="animate-spin text-gray-400" size={24} />
<span className="text-gray-600">Loading payment configuration...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 text-red-600">
<AlertCircle size={24} />
<span>Failed to load payment configuration</span>
</div>
<button
onClick={() => refetch()}
className="mt-3 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Retry
</button>
</div>
);
}
const paymentMode = (config?.payment_mode || 'none') as PaymentModeType;
const canAcceptPayments = config?.can_accept_payments || false;
const tier = config?.tier || business.plan || 'Free';
const isFreeTier = tier === 'Free';
// Determine Stripe environment (test vs live) from API keys
const getStripeEnvironment = (): 'test' | 'live' | null => {
const maskedKey = config?.api_keys?.publishable_key_masked;
if (!maskedKey) return null;
if (maskedKey.startsWith('pk_test_')) return 'test';
if (maskedKey.startsWith('pk_live_')) return 'live';
return null;
};
const stripeEnvironment = getStripeEnvironment();
// Status badge component
const StatusBadge = () => {
if (canAcceptPayments) {
return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
<CheckCircle size={12} />
Ready
</span>
);
}
return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">
<AlertCircle size={12} />
Setup Required
</span>
);
};
// Mode description
const getModeDescription = () => {
if (isFreeTier) {
return 'Free tier businesses use their own Stripe API keys for payment processing. No platform fees apply.';
}
return `${tier} tier businesses use Stripe Connect for payment processing with platform-managed payments.`;
};
return (
<div className="bg-white rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<CreditCard className="text-purple-600" size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Payment Configuration</h2>
<p className="text-sm text-gray-500">{getModeDescription()}</p>
</div>
</div>
<StatusBadge />
</div>
</div>
{/* Test/Live Mode Banner */}
{stripeEnvironment && config?.api_keys?.status === 'active' && (
<div
className={`px-6 py-3 flex items-center gap-3 ${
stripeEnvironment === 'test'
? 'bg-amber-50 border-b border-amber-200'
: 'bg-green-50 border-b border-green-200'
}`}
>
{stripeEnvironment === 'test' ? (
<>
<div className="p-2 bg-amber-100 rounded-full">
<FlaskConical className="text-amber-600" size={20} />
</div>
<div className="flex-1">
<p className="font-semibold text-amber-800">Test Mode</p>
<p className="text-sm text-amber-700">
Payments are simulated. No real money will be charged.
</p>
</div>
<a
href="https://dashboard.stripe.com/test/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-amber-700 hover:text-amber-800 underline"
>
Get Live Keys
</a>
</>
) : (
<>
<div className="p-2 bg-green-100 rounded-full">
<Zap className="text-green-600" size={20} />
</div>
<div className="flex-1">
<p className="font-semibold text-green-800">Live Mode</p>
<p className="text-sm text-green-700">
Payments are real. Customers will be charged.
</p>
</div>
</>
)}
</div>
)}
{/* Content */}
<div className="p-6">
{/* Tier info banner */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-600">Current Plan:</span>
<span className={`ml-2 px-2 py-0.5 text-xs font-semibold rounded-full ${
tier === 'Enterprise' ? 'bg-purple-100 text-purple-800' :
tier === 'Business' ? 'bg-blue-100 text-blue-800' :
tier === 'Professional' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`}>
{tier}
</span>
</div>
<div className="text-sm text-gray-600">
Payment Mode:{' '}
<span className="font-medium text-gray-900">
{paymentMode === 'direct_api' ? 'Direct API Keys' :
paymentMode === 'connect' ? 'Stripe Connect' :
'Not Configured'}
</span>
</div>
</div>
</div>
{/* Tier-specific content */}
{isFreeTier ? (
<StripeApiKeysForm
apiKeys={config?.api_keys || null}
onSuccess={() => refetch()}
/>
) : (
<ConnectOnboardingEmbed
connectAccount={config?.connect_account || null}
tier={tier}
onComplete={() => refetch()}
/>
)}
{/* Upgrade notice for free tier with deprecated keys */}
{isFreeTier && config?.api_keys?.status === 'deprecated' && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-800 mb-1">
Upgraded to a Paid Plan?
</h4>
<p className="text-sm text-blue-700">
If you've recently upgraded, your API keys have been deprecated.
Please contact support to complete your Stripe Connect setup.
</p>
</div>
)}
</div>
</div>
);
};
export default PaymentSettingsSection;

View File

@@ -0,0 +1,85 @@
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 { User } from '../types';
import SmoothScheduleLogo from './SmoothScheduleLogo';
interface PlatformSidebarProps {
user: User;
isCollapsed: boolean;
toggleCollapse: () => void;
}
const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, toggleCollapse }) => {
const { t } = useTranslation();
const location = useLocation();
const getNavClass = (path: string) => {
const isActive = location.pathname === path || (path !== '/' && location.pathname.startsWith(path));
const baseClasses = `flex items-center gap-3 py-2 text-sm font-medium rounded-md transition-colors`;
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-3';
const activeClasses = 'bg-gray-700 text-white';
const inactiveClasses = 'text-gray-400 hover:text-white hover:bg-gray-800';
return `${baseClasses} ${collapsedClasses} ${isActive ? activeClasses : inactiveClasses}`;
};
const isSuperuser = user.role === 'superuser';
const isManager = user.role === 'platform_manager';
return (
<div className={`flex flex-col h-full bg-gray-900 text-white shrink-0 border-r border-gray-800 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}>
<button
onClick={toggleCollapse}
className={`flex items-center gap-3 w-full text-left px-6 py-6 border-b border-gray-800 ${isCollapsed ? 'justify-center' : ''} hover:bg-gray-800 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<SmoothScheduleLogo className="w-10 h-10 shrink-0" />
{!isCollapsed && (
<div className="overflow-hidden">
<h1 className="font-bold text-sm tracking-wide uppercase text-gray-100 truncate">Smooth Schedule</h1>
<p className="text-xs text-gray-500 capitalize truncate">{user.role.replace('_', ' ')}</p>
</div>
)}
</button>
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
<p className={`text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 ${isCollapsed ? 'text-center' : 'px-3'}`}>{isCollapsed ? 'Ops' : 'Operations'}</p>
{(isSuperuser || isManager) && (
<Link to="/platform/dashboard" className={getNavClass('/platform/dashboard')} title={t('nav.platformDashboard')}>
<LayoutDashboard size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.dashboard')}</span>}
</Link>
)}
<Link to="/platform/businesses" className={getNavClass("/platform/businesses")} title={t('nav.businesses')}>
<Building2 size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.businesses')}</span>}
</Link>
<Link to="/platform/users" className={getNavClass('/platform/users')} title={t('nav.users')}>
<Users size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.users')}</span>}
</Link>
<Link to="/platform/support" className={getNavClass('/platform/support')} title={t('nav.support')}>
<MessageSquare size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.support')}</span>}
</Link>
{isSuperuser && (
<>
<p className={`text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 mt-8 ${isCollapsed ? 'text-center' : 'px-3'}`}>{isCollapsed ? 'Sys' : 'System'}</p>
<Link to="/platform/staff" className={getNavClass('/platform/staff')} title={t('nav.staff')}>
<Shield size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
<Link to="/platform/settings" className={getNavClass('/platform/settings')} title={t('nav.platformSettings')}>
<Settings size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.platformSettings')}</span>}
</Link>
</>
)}
</nav>
</div>
);
};
export default PlatformSidebar;

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface PortalProps {
children: React.ReactNode;
}
/**
* Portal component that renders children directly into document.body.
* This bypasses any parent stacking contexts created by CSS transforms,
* ensuring modals with fixed positioning cover the entire viewport.
*/
const Portal: React.FC<PortalProps> = ({ children }) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) return null;
return createPortal(children, document.body);
};
export default Portal;

View File

@@ -0,0 +1,252 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CalendarPlus, Clock, User, Briefcase, MapPin, FileText, Loader2, Check } from 'lucide-react';
import { useServices } from '../hooks/useServices';
import { useResources } from '../hooks/useResources';
import { useCustomers } from '../hooks/useCustomers';
import { useCreateAppointment } from '../hooks/useAppointments';
import { format } from 'date-fns';
interface QuickAddAppointmentProps {
onSuccess?: () => void;
}
const QuickAddAppointment: React.FC<QuickAddAppointmentProps> = ({ onSuccess }) => {
const { t } = useTranslation();
const { data: services } = useServices();
const { data: resources } = useResources();
const { data: customers } = useCustomers();
const createAppointment = useCreateAppointment();
const [customerId, setCustomerId] = useState('');
const [serviceId, setServiceId] = useState('');
const [resourceId, setResourceId] = useState('');
const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [time, setTime] = useState('09:00');
const [notes, setNotes] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
// Get selected service to auto-fill duration
const selectedService = useMemo(() => {
return services?.find(s => s.id === serviceId);
}, [services, serviceId]);
// Generate time slots (every 15 minutes from 6am to 10pm)
const timeSlots = useMemo(() => {
const slots = [];
for (let hour = 6; hour <= 22; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const h = hour.toString().padStart(2, '0');
const m = minute.toString().padStart(2, '0');
slots.push(`${h}:${m}`);
}
}
return slots;
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!serviceId || !date || !time) {
return;
}
const [hours, minutes] = time.split(':').map(Number);
const startTime = new Date(date);
startTime.setHours(hours, minutes, 0, 0);
try {
await createAppointment.mutateAsync({
customerId: customerId || undefined,
customerName: customerId ? (customers?.find(c => c.id === customerId)?.name || '') : 'Walk-in',
serviceId,
resourceId: resourceId || null,
startTime,
durationMinutes: selectedService?.durationMinutes || 60,
status: 'Scheduled',
notes,
});
// Show success state
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 2000);
// Reset form
setCustomerId('');
setServiceId('');
setResourceId('');
setNotes('');
setTime('09:00');
onSuccess?.();
} catch (error) {
console.error('Failed to create appointment:', error);
}
};
const activeCustomers = customers?.filter(c => c.status === 'Active') || [];
return (
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
<CalendarPlus className="h-5 w-5 text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('dashboard.quickAddAppointment', 'Quick Add Appointment')}
</h3>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Customer Select */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<User className="inline h-4 w-4 mr-1" />
{t('appointments.customer', 'Customer')}
</label>
<select
value={customerId}
onChange={(e) => setCustomerId(e.target.value)}
className="w-full px-3 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-brand-500"
>
<option value="">{t('appointments.walkIn', 'Walk-in / No customer')}</option>
{activeCustomers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name} {customer.email && `(${customer.email})`}
</option>
))}
</select>
</div>
{/* Service Select */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<Briefcase className="inline h-4 w-4 mr-1" />
{t('appointments.service', 'Service')} *
</label>
<select
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
required
className="w-full px-3 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-brand-500"
>
<option value="">{t('appointments.selectService', 'Select service...')}</option>
{services?.map((service) => (
<option key={service.id} value={service.id}>
{service.name} ({service.durationMinutes} min - ${service.price})
</option>
))}
</select>
</div>
{/* Resource Select (Optional) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<MapPin className="inline h-4 w-4 mr-1" />
{t('appointments.resource', 'Resource')}
</label>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className="w-full px-3 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-brand-500"
>
<option value="">{t('appointments.unassigned', 'Unassigned')}</option>
{resources?.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
</div>
{/* Date and Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('appointments.date', 'Date')} *
</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
min={format(new Date(), 'yyyy-MM-dd')}
className="w-full px-3 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-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<Clock className="inline h-4 w-4 mr-1" />
{t('appointments.time', 'Time')} *
</label>
<select
value={time}
onChange={(e) => setTime(e.target.value)}
required
className="w-full px-3 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-brand-500"
>
{timeSlots.map((slot) => (
<option key={slot} value={slot}>
{slot}
</option>
))}
</select>
</div>
</div>
{/* Duration Display */}
{selectedService && (
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<Clock className="h-4 w-4" />
{t('appointments.duration', 'Duration')}: {selectedService.durationMinutes} {t('common.minutes', 'minutes')}
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<FileText className="inline h-4 w-4 mr-1" />
{t('appointments.notes', 'Notes')}
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder={t('appointments.notesPlaceholder', 'Optional notes...')}
className="w-full px-3 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-brand-500 resize-none"
/>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={createAppointment.isPending || !serviceId}
className={`w-full py-2.5 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${
showSuccess
? 'bg-green-600 text-white'
: 'bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
}`}
>
{createAppointment.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('common.creating', 'Creating...')}
</>
) : showSuccess ? (
<>
<Check className="h-4 w-4" />
{t('common.created', 'Created!')}
</>
) : (
<>
<CalendarPlus className="h-4 w-4" />
{t('appointments.addAppointment', 'Add Appointment')}
</>
)}
</button>
</form>
</div>
);
};
export default QuickAddAppointment;

View File

@@ -0,0 +1,729 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns';
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
import { Appointment } from '../types';
import Portal from './Portal';
type ViewMode = 'day' | 'week' | 'month';
// Format duration as hours and minutes when >= 60 min
const formatDuration = (minutes: number): string => {
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
return `${minutes} min`;
};
// Constants for timeline rendering
const PIXELS_PER_HOUR = 64;
const PIXELS_PER_MINUTE = PIXELS_PER_HOUR / 60;
interface ResourceCalendarProps {
resourceId: string;
resourceName: string;
onClose: () => void;
}
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [currentDate, setCurrentDate] = useState(new Date());
const timelineRef = useRef<HTMLDivElement>(null);
const timeLabelsRef = useRef<HTMLDivElement>(null);
// Drag state
const [dragState, setDragState] = useState<{
appointmentId: string;
startY: number;
originalStartTime: Date;
originalDuration: number;
} | null>(null);
const [dragPreview, setDragPreview] = useState<Date | null>(null);
// Resize state
const [resizeState, setResizeState] = useState<{
appointmentId: string;
direction: 'top' | 'bottom';
startY: number;
originalStartTime: Date;
originalDuration: number;
} | null>(null);
const [resizePreview, setResizePreview] = useState<{ startTime: Date; duration: number } | null>(null);
const updateMutation = useUpdateAppointment();
// Auto-scroll to current time or 8 AM when switching to day/week view
useEffect(() => {
if ((viewMode === 'day' || viewMode === 'week') && timelineRef.current) {
const now = new Date();
const scrollToHour = isToday(currentDate)
? Math.max(now.getHours() - 1, 0) // Scroll to an hour before current time
: 8; // Default to 8 AM for other days
timelineRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR;
// Sync time labels scroll
if (timeLabelsRef.current) {
timeLabelsRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR;
}
}
}, [viewMode, currentDate]);
// Sync scroll between timeline and time labels (for week view)
useEffect(() => {
const timeline = timelineRef.current;
const timeLabels = timeLabelsRef.current;
if (!timeline || !timeLabels) return;
const handleTimelineScroll = () => {
if (timeLabels) {
timeLabels.scrollTop = timeline.scrollTop;
}
};
timeline.addEventListener('scroll', handleTimelineScroll);
return () => timeline.removeEventListener('scroll', handleTimelineScroll);
}, [viewMode]);
// Helper to get Monday of the week containing the given date
const getMonday = (date: Date) => {
return startOfWeek(date, { weekStartsOn: 1 }); // 1 = Monday
};
// Helper to get Friday of the week (4 days after Monday)
const getFriday = (date: Date) => {
return addDays(getMonday(date), 4);
};
// Calculate date range based on view mode
const dateRange = useMemo(() => {
switch (viewMode) {
case 'day':
return { startDate: startOfDay(currentDate), endDate: addDays(startOfDay(currentDate), 1) };
case 'week':
// Full week (Monday to Sunday)
return { startDate: getMonday(currentDate), endDate: addDays(getMonday(currentDate), 7) };
case 'month':
return { startDate: startOfMonth(currentDate), endDate: addDays(endOfMonth(currentDate), 1) };
}
}, [viewMode, currentDate]);
// Fetch appointments for this resource within the date range
const { data: allAppointments = [], isLoading } = useAppointments({
resource: resourceId,
...dateRange
});
// Filter appointments for this specific resource
const appointments = useMemo(() => {
const resourceIdStr = String(resourceId);
return allAppointments.filter(apt => apt.resourceId === resourceIdStr);
}, [allAppointments, resourceId]);
const navigatePrevious = () => {
switch (viewMode) {
case 'day':
setCurrentDate(addDays(currentDate, -1));
break;
case 'week':
setCurrentDate(addWeeks(currentDate, -1));
break;
case 'month':
setCurrentDate(addMonths(currentDate, -1));
break;
}
};
const navigateNext = () => {
switch (viewMode) {
case 'day':
setCurrentDate(addDays(currentDate, 1));
break;
case 'week':
setCurrentDate(addWeeks(currentDate, 1));
break;
case 'month':
setCurrentDate(addMonths(currentDate, 1));
break;
}
};
const goToToday = () => {
setCurrentDate(new Date());
};
const getTitle = () => {
switch (viewMode) {
case 'day':
return format(currentDate, 'EEEE, MMMM d, yyyy');
case 'week':
const weekStart = getMonday(currentDate);
const weekEnd = addDays(weekStart, 6); // Sunday
return `${format(weekStart, 'MMM d')} - ${format(weekEnd, 'MMM d, yyyy')}`;
case 'month':
return format(currentDate, 'MMMM yyyy');
}
};
// Get appointments for a specific day
const getAppointmentsForDay = (day: Date) => {
return appointments.filter(apt => isSameDay(new Date(apt.startTime), day));
};
// Convert Y position to time
const yToTime = (y: number, baseDate: Date): Date => {
const minutes = Math.round((y / PIXELS_PER_MINUTE) / 15) * 15; // Snap to 15 min
const result = new Date(baseDate);
result.setHours(0, 0, 0, 0);
result.setMinutes(minutes);
return result;
};
// Handle drag start
const handleDragStart = (e: React.MouseEvent, apt: Appointment) => {
e.preventDefault();
const rect = timelineRef.current?.getBoundingClientRect();
if (!rect) return;
setDragState({
appointmentId: apt.id,
startY: e.clientY,
originalStartTime: new Date(apt.startTime),
originalDuration: apt.durationMinutes,
});
};
// Handle resize start
const handleResizeStart = (e: React.MouseEvent, apt: Appointment, direction: 'top' | 'bottom') => {
e.preventDefault();
e.stopPropagation();
setResizeState({
appointmentId: apt.id,
direction,
startY: e.clientY,
originalStartTime: new Date(apt.startTime),
originalDuration: apt.durationMinutes,
});
};
// Mouse move handler for drag and resize
useEffect(() => {
if (!dragState && !resizeState) return;
const handleMouseMove = (e: MouseEvent) => {
if (dragState) {
const deltaY = e.clientY - dragState.startY;
const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15;
const newStartTime = new Date(dragState.originalStartTime.getTime() + deltaMinutes * 60000);
// Keep within same day
const dayStart = startOfDay(dragState.originalStartTime);
const dayEnd = endOfDay(dragState.originalStartTime);
if (newStartTime >= dayStart && newStartTime <= dayEnd) {
setDragPreview(newStartTime);
}
}
if (resizeState) {
const deltaY = e.clientY - resizeState.startY;
const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15;
if (resizeState.direction === 'bottom') {
// Resize from bottom - change duration
const newDuration = Math.max(15, resizeState.originalDuration + deltaMinutes);
setResizePreview({
startTime: resizeState.originalStartTime,
duration: newDuration,
});
} else {
// Resize from top - change start time and duration
const newStartTime = new Date(resizeState.originalStartTime.getTime() + deltaMinutes * 60000);
const newDuration = Math.max(15, resizeState.originalDuration - deltaMinutes);
// Keep within same day
const dayStart = startOfDay(resizeState.originalStartTime);
if (newStartTime >= dayStart) {
setResizePreview({
startTime: newStartTime,
duration: newDuration,
});
}
}
}
};
const handleMouseUp = () => {
if (dragState && dragPreview) {
updateMutation.mutate({
id: dragState.appointmentId,
updates: {
startTime: dragPreview,
durationMinutes: dragState.originalDuration, // Preserve duration when dragging
}
});
}
if (resizeState && resizePreview) {
updateMutation.mutate({
id: resizeState.appointmentId,
updates: {
startTime: resizePreview.startTime,
durationMinutes: resizePreview.duration,
}
});
}
setDragState(null);
setDragPreview(null);
setResizeState(null);
setResizePreview(null);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragState, dragPreview, resizeState, resizePreview, updateMutation]);
// Calculate lanes for overlapping appointments
const calculateLanes = (appts: Appointment[]): Map<string, { lane: number; totalLanes: number }> => {
const laneMap = new Map<string, { lane: number; totalLanes: number }>();
if (appts.length === 0) return laneMap;
// Sort by start time
const sorted = [...appts].sort((a, b) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
// Get end time for an appointment
const getEndTime = (apt: Appointment) => {
return new Date(apt.startTime).getTime() + apt.durationMinutes * 60000;
};
// Find overlapping groups
const groups: Appointment[][] = [];
let currentGroup: Appointment[] = [];
let groupEndTime = 0;
for (const apt of sorted) {
const aptStart = new Date(apt.startTime).getTime();
const aptEnd = getEndTime(apt);
if (currentGroup.length === 0 || aptStart < groupEndTime) {
// Overlaps with current group
currentGroup.push(apt);
groupEndTime = Math.max(groupEndTime, aptEnd);
} else {
// Start new group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [apt];
groupEndTime = aptEnd;
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
// Assign lanes within each group
for (const group of groups) {
const totalLanes = group.length;
// Sort by start time within group
group.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
group.forEach((apt, index) => {
laneMap.set(apt.id, { lane: index, totalLanes });
});
}
return laneMap;
};
const renderDayView = () => {
const dayStart = startOfDay(currentDate);
const hours = eachHourOfInterval({
start: dayStart,
end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000)
});
const dayAppointments = getAppointmentsForDay(currentDate);
const laneAssignments = calculateLanes(dayAppointments);
return (
<div className="flex-1 overflow-y-auto min-h-0" ref={timelineRef}>
<div className="relative ml-16" style={{ height: hours.length * PIXELS_PER_HOUR }}>
{/* Hour grid lines */}
{hours.map((hour) => (
<div key={hour.toISOString()} className="border-b border-gray-200 dark:border-gray-700 relative" style={{ height: PIXELS_PER_HOUR }}>
<div className="absolute -left-16 top-0 w-14 text-xs text-gray-500 dark:text-gray-400 pr-2 text-right">
{format(hour, 'h a')}
</div>
{/* Half-hour line */}
<div className="absolute left-0 right-0 top-1/2 border-t border-dashed border-gray-100 dark:border-gray-800" />
</div>
))}
{/* Render appointments */}
{dayAppointments.map((apt) => {
const isDragging = dragState?.appointmentId === apt.id;
const isResizing = resizeState?.appointmentId === apt.id;
// Use preview values if dragging/resizing this appointment
let displayStartTime = new Date(apt.startTime);
let displayDuration = apt.durationMinutes;
if (isDragging && dragPreview) {
displayStartTime = dragPreview;
}
if (isResizing && resizePreview) {
displayStartTime = resizePreview.startTime;
displayDuration = resizePreview.duration;
}
const startHour = displayStartTime.getHours() + displayStartTime.getMinutes() / 60;
const durationHours = displayDuration / 60;
const top = startHour * PIXELS_PER_HOUR;
const height = Math.max(durationHours * PIXELS_PER_HOUR, 30);
// Get lane info for overlapping appointments
const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 };
const widthPercent = 100 / laneInfo.totalLanes;
const leftPercent = laneInfo.lane * widthPercent;
return (
<div
key={apt.id}
className={`absolute bg-brand-100 dark:bg-brand-900/50 border-t-4 border-brand-500 rounded-b px-2 py-1 overflow-hidden cursor-move select-none group transition-shadow ${
isDragging || isResizing ? 'shadow-lg ring-2 ring-brand-500 z-20' : 'hover:shadow-md z-10'
}`}
style={{
top: `${top}px`,
height: `${height}px`,
left: `${leftPercent}%`,
width: `calc(${widthPercent}% - 8px)`,
}}
onMouseDown={(e) => handleDragStart(e, apt)}
>
{/* Top resize handle */}
<div
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
onMouseDown={(e) => handleResizeStart(e, apt, 'top')}
/>
<div className="text-sm font-medium text-gray-900 dark:text-white truncate pointer-events-none mt-2">
{apt.customerName}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 pointer-events-none">
<Clock size={10} />
{format(displayStartTime, 'h:mm a')} {formatDuration(displayDuration)}
</div>
{/* Bottom resize handle */}
<div
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
onMouseDown={(e) => handleResizeStart(e, apt, 'bottom')}
/>
</div>
);
})}
{/* Current time indicator */}
{isToday(currentDate) && (
<div
className="absolute left-0 right-0 border-t-2 border-red-500 z-30 pointer-events-none"
style={{
top: `${(new Date().getHours() + new Date().getMinutes() / 60) * PIXELS_PER_HOUR}px`
}}
>
<div className="absolute -left-1.5 -top-1.5 w-3 h-3 bg-red-500 rounded-full" />
</div>
)}
</div>
</div>
);
};
const renderWeekView = () => {
// Full week Monday to Sunday
const days = eachDayOfInterval({
start: getMonday(currentDate),
end: addDays(getMonday(currentDate), 6)
});
const dayStart = startOfDay(days[0]);
const hours = eachHourOfInterval({
start: dayStart,
end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000)
});
const DAY_COLUMN_WIDTH = 200; // pixels per day column
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Day headers - fixed at top */}
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
<div className="w-16 flex-shrink-0" /> {/* Spacer for time column */}
<div className="flex overflow-hidden">
{days.map((day) => (
<div
key={day.toISOString()}
className={`flex-shrink-0 text-center py-2 font-medium text-sm border-l border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 ${
isToday(day) ? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20' : 'text-gray-900 dark:text-white'
}`}
style={{ width: DAY_COLUMN_WIDTH }}
onClick={() => {
setCurrentDate(day);
setViewMode('day');
}}
>
{format(day, 'EEE, MMM d')}
</div>
))}
</div>
</div>
{/* Scrollable timeline grid */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Time labels - fixed left column */}
<div ref={timeLabelsRef} className="w-16 flex-shrink-0 overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
<div style={{ height: hours.length * PIXELS_PER_HOUR }}>
{hours.map((hour) => (
<div key={hour.toISOString()} className="relative" style={{ height: PIXELS_PER_HOUR }}>
<div className="absolute top-0 right-2 text-xs text-gray-500 dark:text-gray-400">
{format(hour, 'h a')}
</div>
</div>
))}
</div>
</div>
{/* Day columns with appointments - scrollable both ways */}
<div className="flex-1 overflow-auto" ref={timelineRef}>
<div className="flex" style={{ height: hours.length * PIXELS_PER_HOUR, width: days.length * DAY_COLUMN_WIDTH }}>
{days.map((day) => {
const dayAppointments = getAppointmentsForDay(day);
const laneAssignments = calculateLanes(dayAppointments);
return (
<div
key={day.toISOString()}
className="relative flex-shrink-0 border-l border-gray-200 dark:border-gray-700"
style={{ width: DAY_COLUMN_WIDTH }}
onClick={() => {
setCurrentDate(day);
setViewMode('day');
}}
>
{/* Hour grid lines */}
{hours.map((hour) => (
<div
key={hour.toISOString()}
className="border-b border-gray-100 dark:border-gray-800"
style={{ height: PIXELS_PER_HOUR }}
>
<div className="absolute left-0 right-0 border-t border-dashed border-gray-100 dark:border-gray-800" style={{ top: PIXELS_PER_HOUR / 2 }} />
</div>
))}
{/* Appointments for this day */}
{dayAppointments.map((apt) => {
const aptStartTime = new Date(apt.startTime);
const startHour = aptStartTime.getHours() + aptStartTime.getMinutes() / 60;
const durationHours = apt.durationMinutes / 60;
const top = startHour * PIXELS_PER_HOUR;
const height = Math.max(durationHours * PIXELS_PER_HOUR, 24);
const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 };
const widthPercent = 100 / laneInfo.totalLanes;
const leftPercent = laneInfo.lane * widthPercent;
return (
<div
key={apt.id}
className="absolute bg-brand-100 dark:bg-brand-900/50 border-t-2 border-brand-500 rounded-b px-1 py-0.5 overflow-hidden cursor-pointer hover:shadow-md hover:z-10 text-xs"
style={{
top: `${top}px`,
height: `${height}px`,
left: `${leftPercent}%`,
width: `calc(${widthPercent}% - 4px)`,
}}
onClick={(e) => {
e.stopPropagation();
setCurrentDate(day);
setViewMode('day');
}}
>
<div className="font-medium text-gray-900 dark:text-white truncate">
{apt.customerName}
</div>
<div className="text-gray-500 dark:text-gray-400 truncate">
{format(aptStartTime, 'h:mm a')}
</div>
</div>
);
})}
{/* Current time indicator for today */}
{isToday(day) && (
<div
className="absolute left-0 right-0 border-t-2 border-red-500 z-20 pointer-events-none"
style={{
top: `${(new Date().getHours() + new Date().getMinutes() / 60) * PIXELS_PER_HOUR}px`
}}
/>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};
const renderMonthView = () => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
// Start padding from Monday (weekStartsOn: 1)
const startDayOfWeek = getDay(monthStart);
// Adjust for Monday start: if Sunday (0), it's 6 days from Monday; otherwise subtract 1
const paddingDays = Array(startDayOfWeek === 0 ? 6 : startDayOfWeek - 1).fill(null);
return (
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => (
<div key={day} className="text-center text-xs font-medium text-gray-500 dark:text-gray-400 py-2">
{day}
</div>
))}
{paddingDays.map((_, index) => (
<div key={`padding-${index}`} className="min-h-20" />
))}
{days.map((day) => {
const dayAppointments = getAppointmentsForDay(day);
const dayOfWeek = getDay(day);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
return (
<div
key={day.toISOString()}
className={`min-h-20 p-2 border border-gray-200 dark:border-gray-700 rounded cursor-pointer hover:border-brand-300 dark:hover:border-brand-700 transition-colors ${
isToday(day) ? 'bg-brand-50 dark:bg-brand-900/20' : isWeekend ? 'bg-gray-50 dark:bg-gray-900/30' : 'bg-white dark:bg-gray-800'
}`}
onClick={() => {
// Drill down to week view showing the week containing this day
setCurrentDate(day);
setViewMode('week');
}}
>
<div className={`text-sm font-medium mb-1 ${isToday(day) ? 'text-brand-600 dark:text-brand-400' : isWeekend ? 'text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white'}`}>
{format(day, 'd')}
</div>
{dayAppointments.length > 0 && (
<div className="text-xs">
<div className="text-brand-600 dark:text-brand-400 font-medium">
{dayAppointments.length} appt{dayAppointments.length > 1 ? 's' : ''}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
return (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-6xl h-[80vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{resourceName} Calendar</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{viewMode === 'day' ? 'Drag to move, drag edges to resize' : 'Click a day to view details'}
</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<X size={24} />
</button>
</div>
{/* Toolbar */}
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={navigatePrevious}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
<ChevronLeft size={20} />
</button>
<button
onClick={goToToday}
className="px-3 py-1 text-sm font-medium bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Today
</button>
<button
onClick={navigateNext}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
<ChevronRight size={20} />
</button>
<div className="ml-4 text-lg font-semibold text-gray-900 dark:text-white">
{getTitle()}
</div>
</div>
{/* View Mode Selector */}
<div className="flex gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
{(['day', 'week', 'month'] as ViewMode[]).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`px-4 py-1.5 text-sm font-medium rounded transition-colors capitalize ${viewMode === mode
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{mode}
</button>
))}
</div>
</div>
{/* Calendar Content */}
<div className="flex-1 min-h-0 flex flex-col relative">
{viewMode === 'day' && renderDayView()}
{viewMode === 'week' && renderWeekView()}
{viewMode === 'month' && renderMonthView()}
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 dark:text-gray-500">Loading appointments...</p>
</div>
)}
{!isLoading && appointments.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 dark:text-gray-500">No appointments scheduled for this period</p>
</div>
)}
</div>
</div>
</Portal>
);
};
export default ResourceCalendar;

View File

@@ -0,0 +1,89 @@
.service-list {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.service-list h2 {
font-size: 2rem;
margin-bottom: 2rem;
color: #1a202c;
}
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.service-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.service-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.service-card h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: #2d3748;
}
.service-details {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #4a5568;
}
.service-duration {
background: #edf2f7;
padding: 0.25rem 0.75rem;
border-radius: 4px;
}
.service-price {
font-weight: bold;
color: #2b6cb0;
font-size: 1.1rem;
}
.service-description {
color: #718096;
font-size: 0.9rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.service-book-btn {
width: 100%;
padding: 0.75rem;
background: #3182ce;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.service-book-btn:hover {
background: #2c5282;
}
.service-list-loading,
.service-list-empty {
text-align: center;
padding: 3rem;
color: #718096;
font-size: 1.1rem;
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import './ServiceList.css';
const ServiceList = ({ services, onSelectService, loading }) => {
if (loading) {
return <div className="service-list-loading">Loading services...</div>;
}
if (!services || services.length === 0) {
return <div className="service-list-empty">No services available</div>;
}
return (
<div className="service-list">
<h2>Available Services</h2>
<div className="service-grid">
{services.map((service) => (
<div
key={service.id}
className="service-card"
onClick={() => onSelectService(service)}
>
<h3>{service.name}</h3>
<div className="service-details">
<span className="service-duration">{service.duration} min</span>
<span className="service-price">${service.price}</span>
</div>
{service.description && (
<p className="service-description">{service.description}</p>
)}
<button className="service-book-btn">Book Now</button>
</div>
))}
</div>
</div>
);
};
export default ServiceList;

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
CalendarDays,
Settings,
Users,
CreditCard,
MessageSquare,
LogOut,
ClipboardList,
Briefcase
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import SmoothScheduleLogo from './SmoothScheduleLogo';
interface SidebarProps {
business: Business;
user: User;
isCollapsed: boolean;
toggleCollapse: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCollapse }) => {
const { t } = useTranslation();
const location = useLocation();
const { role } = user;
const logoutMutation = useLogout();
const getNavClass = (path: string, exact: boolean = false, disabled: boolean = false) => {
const isActive = exact
? location.pathname === path
: location.pathname.startsWith(path);
const baseClasses = `flex items-center gap-3 py-3 text-sm font-medium rounded-lg transition-colors`;
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
const activeClasses = 'bg-opacity-10 text-white bg-white';
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
const disabledClasses = 'text-white/30 cursor-not-allowed';
if (disabled) {
return `${baseClasses} ${collapsedClasses} ${disabledClasses}`;
}
return `${baseClasses} ${collapsedClasses} ${isActive ? activeClasses : inactiveClasses}`;
};
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
const canViewSettings = role === 'owner';
const getDashboardLink = () => {
if (role === 'resource') return '/';
return '/';
};
const handleSignOut = () => {
logoutMutation.mutate();
};
return (
<div
className={`flex flex-col h-full text-white shrink-0 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}
style={{ backgroundColor: business.primaryColor }}
>
<button
onClick={toggleCollapse}
className={`flex items-center gap-3 w-full text-left px-6 py-8 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
{business.name.substring(0, 2).toUpperCase()}
</div>
{!isCollapsed && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
</div>
)}
</button>
<nav className="flex-1 px-4 space-y-1 overflow-y-auto">
<Link to={getDashboardLink()} className={getNavClass('/', true)} title={t('nav.dashboard')}>
<LayoutDashboard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.dashboard')}</span>}
</Link>
<Link to="/scheduler" className={getNavClass('/scheduler')} title={t('nav.scheduler')}>
<CalendarDays size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.scheduler')}</span>}
</Link>
{canViewManagementPages && (
<>
<Link to="/customers" className={getNavClass('/customers')} title={t('nav.customers')}>
<Users size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.customers')}</span>}
</Link>
<Link to="/services" className={getNavClass('/services')} title={t('nav.services', 'Services')}>
<Briefcase size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.services', 'Services')}</span>}
</Link>
<Link to="/resources" className={getNavClass('/resources')} title={t('nav.resources')}>
<ClipboardList size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.resources')}</span>}
</Link>
</>
)}
{canViewAdminPages && (
<>
{business.paymentsEnabled ? (
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</Link>
) : (
<div
className={getNavClass('/payments', false, true)}
title={t('nav.paymentsDisabledTooltip')}
>
<CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</div>
)}
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
<MessageSquare size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.messages')}</span>}
</Link>
<Link to="/staff" className={getNavClass('/staff')} title={t('nav.staff')}>
<Users size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
</>
)}
{canViewSettings && (
<div className="pt-8 mt-8 border-t border-white/10">
{canViewSettings && (
<Link to="/settings" className={getNavClass('/settings', true)} title={t('nav.businessSettings')}>
<Settings size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.businessSettings')}</span>}
</Link>
)}
</div>
)}
</nav>
<div className="p-4 border-t border-white/10">
<div className={`flex items-center gap-2 text-xs text-white/60 mb-4 ${isCollapsed ? 'justify-center' : ''}`}>
<SmoothScheduleLogo className="w-6 h-6 text-white" />
{!isCollapsed && (
<div>
<span className="block">{t('common.poweredBy')}</span>
<span className="font-semibold text-white/80">Smooth Schedule</span>
</div>
)}
</div>
<button
onClick={handleSignOut}
disabled={logoutMutation.isPending}
className={`flex items-center gap-3 px-4 py-2 text-sm font-medium text-white/70 hover:text-white w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
>
<LogOut size={20} className="shrink-0" />
{!isCollapsed && <span>{t('auth.signOut')}</span>}
</button>
</div>
</div>
);
};
export default Sidebar;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,441 @@
/**
* Stripe API Keys Form Component
* For free-tier businesses to enter and manage their Stripe API keys
*/
import React, { useState } from 'react';
import {
Key,
Eye,
EyeOff,
CheckCircle,
AlertCircle,
Loader2,
Trash2,
RefreshCw,
FlaskConical,
Zap,
} from 'lucide-react';
import { ApiKeysInfo } from '../api/payments';
import {
useValidateApiKeys,
useSaveApiKeys,
useDeleteApiKeys,
useRevalidateApiKeys,
} from '../hooks/usePayments';
interface StripeApiKeysFormProps {
apiKeys: ApiKeysInfo | null;
onSuccess?: () => void;
}
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
const [secretKey, setSecretKey] = useState('');
const [publishableKey, setPublishableKey] = useState('');
const [showSecretKey, setShowSecretKey] = useState(false);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
accountName?: string;
environment?: string;
error?: string;
} | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const validateMutation = useValidateApiKeys();
const saveMutation = useSaveApiKeys();
const deleteMutation = useDeleteApiKeys();
const revalidateMutation = useRevalidateApiKeys();
const isConfigured = apiKeys && apiKeys.status !== 'deprecated';
const isDeprecated = apiKeys?.status === 'deprecated';
const isInvalid = apiKeys?.status === 'invalid';
// Determine if using test or live keys from the masked key prefix
const getKeyEnvironment = (maskedKey: string | undefined): 'test' | 'live' | null => {
if (!maskedKey) return null;
if (maskedKey.startsWith('pk_test_') || maskedKey.startsWith('sk_test_')) return 'test';
if (maskedKey.startsWith('pk_live_') || maskedKey.startsWith('sk_live_')) return 'live';
return null;
};
const keyEnvironment = getKeyEnvironment(apiKeys?.publishable_key_masked);
const handleValidate = async () => {
setValidationResult(null);
try {
const result = await validateMutation.mutateAsync({ secretKey, publishableKey });
setValidationResult({
valid: result.valid,
accountName: result.account_name,
environment: result.environment,
error: result.error,
});
} catch (error: any) {
setValidationResult({
valid: false,
error: error.response?.data?.error || 'Validation failed',
});
}
};
const handleSave = async () => {
try {
await saveMutation.mutateAsync({ secretKey, publishableKey });
setSecretKey('');
setPublishableKey('');
setValidationResult(null);
onSuccess?.();
} catch (error: any) {
setValidationResult({
valid: false,
error: error.response?.data?.error || 'Failed to save keys',
});
}
};
const handleDelete = async () => {
try {
await deleteMutation.mutateAsync();
setShowDeleteConfirm(false);
onSuccess?.();
} catch (error) {
console.error('Failed to delete keys:', error);
}
};
const handleRevalidate = async () => {
try {
await revalidateMutation.mutateAsync();
onSuccess?.();
} catch (error) {
console.error('Failed to revalidate keys:', error);
}
};
const canSave = validationResult?.valid && secretKey && publishableKey;
return (
<div className="space-y-6">
{/* Current Configuration */}
{isConfigured && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
<CheckCircle size={18} className="text-green-500" />
Stripe Keys Configured
</h4>
<div className="flex items-center gap-2">
{/* Environment Badge */}
{keyEnvironment && (
<span
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full ${
keyEnvironment === 'test'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{keyEnvironment === 'test' ? (
<>
<FlaskConical size={12} />
Test Mode
</>
) : (
<>
<Zap size={12} />
Live Mode
</>
)}
</span>
)}
{/* Status Badge */}
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
apiKeys.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: apiKeys.status === 'invalid'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
}`}
>
{apiKeys.status}
</span>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Publishable Key:</span>
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.publishable_key_masked}</code>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Secret Key:</span>
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.secret_key_masked}</code>
</div>
{apiKeys.stripe_account_name && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Account:</span>
<span className="text-gray-900 dark:text-white">{apiKeys.stripe_account_name}</span>
</div>
)}
{apiKeys.last_validated_at && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Last Validated:</span>
<span className="text-gray-900 dark:text-white">
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
</span>
</div>
)}
</div>
{/* Test Mode Warning */}
{keyEnvironment === 'test' && apiKeys.status === 'active' && (
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<FlaskConical size={16} className="shrink-0 mt-0.5" />
<span>
You are using <strong>test keys</strong>. Payments will not be processed for real.
Switch to live keys when ready to accept real payments.
</span>
</div>
)}
{isInvalid && apiKeys.validation_error && (
<div className="mt-3 p-2 bg-red-50 rounded text-sm text-red-700">
{apiKeys.validation_error}
</div>
)}
<div className="flex gap-2 mt-4">
<button
onClick={handleRevalidate}
disabled={revalidateMutation.isPending}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
{revalidateMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<RefreshCw size={16} />
)}
Re-validate
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-lg hover:bg-red-50"
>
<Trash2 size={16} />
Remove
</button>
</div>
</div>
)}
{/* Deprecated Notice */}
{isDeprecated && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
<div>
<h4 className="font-medium text-yellow-800">API Keys Deprecated</h4>
<p className="text-sm text-yellow-700 mt-1">
Your API keys have been deprecated because you upgraded to a paid tier.
Please complete Stripe Connect onboarding to accept payments.
</p>
</div>
</div>
</div>
)}
{/* Add/Update Keys Form */}
{(!isConfigured || isDeprecated) && (
<div className="space-y-4">
<h4 className="font-medium text-gray-900">
{isConfigured ? 'Update API Keys' : 'Add Stripe API Keys'}
</h4>
<p className="text-sm text-gray-600">
Enter your Stripe API keys to enable payment collection.
You can find these in your{' '}
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Stripe Dashboard
</a>
.
</p>
{/* Publishable Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Publishable Key
</label>
<div className="relative">
<Key
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
value={publishableKey}
onChange={(e) => {
setPublishableKey(e.target.value);
setValidationResult(null);
}}
placeholder="pk_test_..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
{/* Secret Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Secret Key
</label>
<div className="relative">
<Key
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type={showSecretKey ? 'text' : 'password'}
value={secretKey}
onChange={(e) => {
setSecretKey(e.target.value);
setValidationResult(null);
}}
placeholder="sk_test_..."
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
<button
type="button"
onClick={() => setShowSecretKey(!showSecretKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showSecretKey ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{/* Validation Result */}
{validationResult && (
<div
className={`flex items-start gap-2 p-3 rounded-lg ${
validationResult.valid
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{validationResult.valid ? (
<CheckCircle size={18} className="shrink-0 mt-0.5" />
) : (
<AlertCircle size={18} className="shrink-0 mt-0.5" />
)}
<div className="text-sm flex-1">
{validationResult.valid ? (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium">Keys are valid!</span>
{validationResult.environment && (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
validationResult.environment === 'test'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{validationResult.environment === 'test' ? (
<>
<FlaskConical size={10} />
Test Mode
</>
) : (
<>
<Zap size={10} />
Live Mode
</>
)}
</span>
)}
</div>
{validationResult.accountName && (
<div>Connected to: {validationResult.accountName}</div>
)}
{validationResult.environment === 'test' && (
<div className="text-amber-700 dark:text-amber-400 text-xs mt-1">
These are test keys. No real payments will be processed.
</div>
)}
</div>
) : (
<span>{validationResult.error}</span>
)}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleValidate}
disabled={!secretKey || !publishableKey || validateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{validateMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<CheckCircle size={16} />
)}
Validate
</button>
<button
onClick={handleSave}
disabled={!canSave || saveMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saveMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Key size={16} />
)}
Save Keys
</button>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Remove API Keys?
</h3>
<p className="text-gray-600 mb-4">
Are you sure you want to remove your Stripe API keys?
You will not be able to accept payments until you add them again.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{deleteMutation.isPending && <Loader2 size={16} className="animate-spin" />}
Remove
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default StripeApiKeysForm;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Bell, Search, Moon, Sun, Menu } from 'lucide-react';
import { User } from '../types';
import UserProfileDropdown from './UserProfileDropdown';
import LanguageSelector from './LanguageSelector';
interface TopBarProps {
user: User;
isDarkMode: boolean;
toggleTheme: () => void;
onMenuClick: () => void;
}
const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuClick }) => {
const { t } = useTranslation();
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">
<div className="flex items-center gap-4">
<button
onClick={onMenuClick}
className="p-2 -ml-2 text-gray-500 rounded-md md:hidden hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-brand-500"
aria-label="Open sidebar"
>
<Menu size={24} />
</button>
<div className="relative hidden md:block w-96">
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
<Search size={18} />
</span>
<input
type="text"
placeholder={t('common.search')}
className="w-full py-2 pl-10 pr-4 text-sm text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 placeholder-gray-400 dark:placeholder-gray-500 transition-colors duration-200"
/>
</div>
</div>
<div className="flex items-center gap-4">
<LanguageSelector />
<button
onClick={toggleTheme}
className="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"
>
{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>
<UserProfileDropdown user={user} />
</div>
</header>
);
};
export default TopBar;

View File

@@ -0,0 +1,549 @@
/**
* Transaction Detail Modal
*
* Displays comprehensive transaction information and provides refund functionality.
* Supports both partial and full refunds with reason selection.
*/
import React, { useState } from 'react';
import {
X,
CreditCard,
User,
Mail,
Calendar,
DollarSign,
RefreshCcw,
CheckCircle,
Clock,
XCircle,
AlertCircle,
Receipt,
ExternalLink,
Loader2,
ArrowLeftRight,
Percent,
} from 'lucide-react';
import { TransactionDetail, RefundInfo, RefundRequest } from '../api/payments';
import { useTransactionDetail, useRefundTransaction } from '../hooks/useTransactionAnalytics';
import Portal from './Portal';
interface TransactionDetailModalProps {
transactionId: number | null;
onClose: () => void;
}
const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
transactionId,
onClose,
}) => {
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
const refundMutation = useRefundTransaction();
// Refund form state
const [showRefundForm, setShowRefundForm] = useState(false);
const [refundType, setRefundType] = useState<'full' | 'partial'>('full');
const [refundAmount, setRefundAmount] = useState('');
const [refundReason, setRefundReason] = useState<RefundRequest['reason']>('requested_by_customer');
const [refundError, setRefundError] = useState<string | null>(null);
if (!transactionId) return null;
const handleRefund = async () => {
if (!transaction) return;
setRefundError(null);
const request: RefundRequest = {
reason: refundReason,
};
// For partial refunds, include the amount
if (refundType === 'partial') {
const amountCents = Math.round(parseFloat(refundAmount) * 100);
if (isNaN(amountCents) || amountCents <= 0) {
setRefundError('Please enter a valid refund amount');
return;
}
if (amountCents > transaction.refundable_amount) {
setRefundError(`Amount exceeds refundable amount ($${(transaction.refundable_amount / 100).toFixed(2)})`);
return;
}
request.amount = amountCents;
}
try {
await refundMutation.mutateAsync({
transactionId: transaction.id,
request,
});
setShowRefundForm(false);
setRefundAmount('');
} catch (err: any) {
setRefundError(err.response?.data?.error || 'Failed to process refund');
}
};
// Status badge helper
const getStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
succeeded: { bg: 'bg-green-100', text: 'text-green-800', icon: <CheckCircle size={14} /> },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: <Clock size={14} /> },
failed: { bg: 'bg-red-100', text: 'text-red-800', icon: <XCircle size={14} /> },
refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: <RefreshCcw size={14} /> },
partially_refunded: { bg: 'bg-orange-100', text: 'text-orange-800', icon: <RefreshCcw size={14} /> },
};
const style = styles[status] || styles.pending;
return (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full ${style.bg} ${style.text}`}>
{style.icon}
{status.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
);
};
// Format date helper
const formatDate = (dateStr: string | number) => {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Format timestamp for refunds
const formatRefundDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Get payment method display
const getPaymentMethodDisplay = () => {
if (!transaction?.payment_method_info) return null;
const pm = transaction.payment_method_info;
if (pm.type === 'card') {
return (
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<CreditCard className="text-gray-600" size={20} />
</div>
<div>
<p className="font-medium text-gray-900">
{pm.brand} **** {pm.last4}
</p>
{pm.exp_month && pm.exp_year && (
<p className="text-sm text-gray-500">
Expires {pm.exp_month}/{pm.exp_year}
{pm.funding && ` (${pm.funding})`}
</p>
)}
</div>
</div>
);
}
return (
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<DollarSign className="text-gray-600" size={20} />
</div>
<div>
<p className="font-medium text-gray-900 capitalize">{pm.type.replace('_', ' ')}</p>
{pm.bank_name && <p className="text-sm text-gray-500">{pm.bank_name}</p>}
</div>
</div>
);
};
return (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div
className="w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Transaction Details
</h3>
{transaction && (
<p className="text-sm text-gray-500 font-mono">
{transaction.stripe_payment_intent_id}
</p>
)}
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-gray-400" size={32} />
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-700">
<AlertCircle size={18} />
<p className="font-medium">Failed to load transaction details</p>
</div>
</div>
)}
{transaction && (
<>
{/* Status & Amount */}
<div className="flex items-start justify-between">
<div>
{getStatusBadge(transaction.status)}
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">
{transaction.amount_display}
</p>
<p className="text-sm text-gray-500">
{transaction.transaction_type.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
</p>
</div>
{transaction.can_refund && !showRefundForm && (
<button
onClick={() => setShowRefundForm(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
<RefreshCcw size={16} />
Issue Refund
</button>
)}
</div>
{/* Refund Form */}
{showRefundForm && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-4">
<div className="flex items-center gap-2 text-red-800">
<RefreshCcw size={18} />
<h4 className="font-semibold">Issue Refund</h4>
</div>
{/* Refund Type */}
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="refundType"
checked={refundType === 'full'}
onChange={() => setRefundType('full')}
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">
Full refund (${(transaction.refundable_amount / 100).toFixed(2)})
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="refundType"
checked={refundType === 'partial'}
onChange={() => setRefundType('partial')}
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Partial refund</span>
</label>
</div>
{/* Partial Amount */}
{refundType === 'partial' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refund Amount (max ${(transaction.refundable_amount / 100).toFixed(2)})
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
min="0.01"
max={(transaction.refundable_amount / 100).toFixed(2)}
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
placeholder="0.00"
className="w-full pl-7 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
</div>
)}
{/* Reason */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refund Reason
</label>
<select
value={refundReason}
onChange={(e) => setRefundReason(e.target.value as RefundRequest['reason'])}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
<option value="requested_by_customer">Requested by customer</option>
<option value="duplicate">Duplicate charge</option>
<option value="fraudulent">Fraudulent</option>
</select>
</div>
{refundError && (
<div className="flex items-center gap-2 text-red-600 text-sm">
<AlertCircle size={16} />
{refundError}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3">
<button
onClick={handleRefund}
disabled={refundMutation.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{refundMutation.isPending ? (
<>
<Loader2 className="animate-spin" size={16} />
Processing...
</>
) : (
<>
<RefreshCcw size={16} />
Confirm Refund
</>
)}
</button>
<button
onClick={() => {
setShowRefundForm(false);
setRefundError(null);
setRefundAmount('');
}}
disabled={refundMutation.isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
>
Cancel
</button>
</div>
</div>
)}
{/* Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Customer Info */}
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<User size={16} />
Customer
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
{transaction.customer_name && (
<div className="flex items-center gap-2 text-sm">
<User size={14} className="text-gray-400" />
<span className="text-gray-900 dark:text-white font-medium">
{transaction.customer_name}
</span>
</div>
)}
{transaction.customer_email && (
<div className="flex items-center gap-2 text-sm">
<Mail size={14} className="text-gray-400" />
<span className="text-gray-600 dark:text-gray-300">
{transaction.customer_email}
</span>
</div>
)}
</div>
</div>
{/* Amount Breakdown */}
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<DollarSign size={16} />
Amount Breakdown
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Gross Amount</span>
<span className="font-medium">{transaction.amount_display}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Platform Fee</span>
<span className="text-red-600">-{transaction.fee_display}</span>
</div>
{transaction.total_refunded > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Refunded</span>
<span className="text-orange-600">
-${(transaction.total_refunded / 100).toFixed(2)}
</span>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
<span className="font-medium text-gray-900 dark:text-white">Net Amount</span>
<span className="font-bold text-green-600">
${(transaction.net_amount / 100).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
{/* Payment Method */}
{transaction.payment_method_info && (
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<CreditCard size={16} />
Payment Method
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
{getPaymentMethodDisplay()}
</div>
</div>
)}
{/* Description */}
{transaction.description && (
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Receipt size={16} />
Description
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-gray-700 dark:text-gray-300">{transaction.description}</p>
</div>
</div>
)}
{/* Refund History */}
{transaction.refunds && transaction.refunds.length > 0 && (
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<RefreshCcw size={16} />
Refund History
</h4>
<div className="space-y-3">
{transaction.refunds.map((refund: RefundInfo) => (
<div
key={refund.id}
className="bg-orange-50 border border-orange-200 rounded-lg p-4 flex items-center justify-between"
>
<div>
<p className="font-medium text-orange-800">{refund.amount_display}</p>
<p className="text-sm text-orange-600">
{refund.reason
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
: 'No reason provided'}
</p>
<p className="text-xs text-orange-500 mt-1">
{formatRefundDate(refund.created)}
</p>
</div>
<div className="text-right">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
refund.status === 'succeeded'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{refund.status === 'succeeded' ? (
<CheckCircle size={12} />
) : (
<Clock size={12} />
)}
{refund.status}
</span>
<p className="text-xs text-gray-500 mt-1 font-mono">{refund.id}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Timeline */}
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Calendar size={16} />
Timeline
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 text-sm">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-gray-600">Created</span>
<span className="ml-auto text-gray-900 dark:text-white">
{formatDate(transaction.created_at)}
</span>
</div>
{transaction.updated_at !== transaction.created_at && (
<div className="flex items-center gap-3 text-sm">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="text-gray-600">Last Updated</span>
<span className="ml-auto text-gray-900 dark:text-white">
{formatDate(transaction.updated_at)}
</span>
</div>
)}
</div>
</div>
{/* Technical Details */}
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<ArrowLeftRight size={16} />
Technical Details
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2 font-mono text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Payment Intent</span>
<span className="text-gray-700 dark:text-gray-300">
{transaction.stripe_payment_intent_id}
</span>
</div>
{transaction.stripe_charge_id && (
<div className="flex justify-between">
<span className="text-gray-500">Charge ID</span>
<span className="text-gray-700 dark:text-gray-300">
{transaction.stripe_charge_id}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500">Transaction ID</span>
<span className="text-gray-700 dark:text-gray-300">{transaction.id}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Currency</span>
<span className="text-gray-700 dark:text-gray-300 uppercase">
{transaction.currency}
</span>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
</Portal>
);
};
export default TransactionDetailModal;

View File

@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Clock, X, ArrowRight, Sparkles } from 'lucide-react';
import { Business } from '../types';
interface TrialBannerProps {
business: Business;
}
/**
* TrialBanner Component
* Shows at the top of the business layout when trial is active
* Displays days remaining and upgrade CTA
* Dismissible but reappears on page reload
*/
const TrialBanner: React.FC<TrialBannerProps> = ({ business }) => {
const { t } = useTranslation();
const [isDismissed, setIsDismissed] = useState(false);
const navigate = useNavigate();
if (isDismissed || !business.isTrialActive || !business.daysLeftInTrial) {
return null;
}
const daysLeft = business.daysLeftInTrial;
const isUrgent = daysLeft <= 3;
const trialEndDate = business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : '';
const handleUpgrade = () => {
navigate('/upgrade');
};
const handleDismiss = () => {
setIsDismissed(true);
};
return (
<div
className={`relative ${
isUrgent
? 'bg-gradient-to-r from-red-500 to-orange-500'
: 'bg-gradient-to-r from-blue-600 to-blue-500'
} text-white shadow-md`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between gap-4">
{/* Left: Trial Info */}
<div className="flex items-center gap-3 flex-1">
<div className={`p-2 rounded-full ${isUrgent ? 'bg-white/20' : 'bg-white/20'} backdrop-blur-sm`}>
{isUrgent ? (
<Clock size={20} className="animate-pulse" />
) : (
<Sparkles size={20} />
)}
</div>
<div className="flex-1">
<p className="font-semibold text-sm sm:text-base">
{t('trial.banner.title')} - {t('trial.banner.daysLeft', { days: daysLeft })}
</p>
<p className="text-xs sm:text-sm text-white/90 hidden sm:block">
{t('trial.banner.expiresOn', { date: trialEndDate })}
</p>
</div>
</div>
{/* Right: CTA Button */}
<div className="flex items-center gap-2">
<button
onClick={handleUpgrade}
className="group px-4 py-2 bg-white text-blue-600 hover:bg-blue-50 rounded-lg font-semibold text-sm transition-all shadow-lg hover:shadow-xl flex items-center gap-2"
>
{t('trial.banner.upgradeNow')}
<ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" />
</button>
{/* Dismiss Button */}
<button
onClick={handleDismiss}
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
aria-label={t('trial.banner.dismiss')}
>
<X size={20} />
</button>
</div>
</div>
</div>
</div>
);
};
export default TrialBanner;

View File

@@ -0,0 +1,150 @@
import React, { useState, useRef, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { User, Settings, LogOut, ChevronDown } from 'lucide-react';
import { User as UserType } from '../types';
import { useLogout } from '../hooks/useAuth';
interface UserProfileDropdownProps {
user: UserType;
variant?: 'default' | 'light'; // 'light' for colored headers
}
const UserProfileDropdown: React.FC<UserProfileDropdownProps> = ({ user, variant = 'default' }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const location = useLocation();
// Determine the profile route based on current path
const isPlatform = location.pathname.startsWith('/platform');
const profilePath = isPlatform ? '/platform/profile' : '/profile';
const isLight = variant === 'light';
// 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);
}, []);
// Close dropdown on escape key
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, []);
const handleSignOut = () => {
logout();
};
// Get user initials for fallback avatar
const getInitials = (name: string) => {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
// Format role for display
const formatRole = (role: string) => {
return role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-3 pl-6 border-l hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg ${
isLight
? 'border-white/20 focus:ring-white/50'
: 'border-gray-200 dark:border-gray-700 focus:ring-brand-500'
}`}
aria-expanded={isOpen}
aria-haspopup="true"
>
<div className="text-right hidden sm:block">
<p className={`text-sm font-medium ${isLight ? 'text-white' : 'text-gray-900 dark:text-white'}`}>
{user.name}
</p>
<p className={`text-xs ${isLight ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'}`}>
{formatRole(user.role)}
</p>
</div>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name}
className={`w-10 h-10 rounded-full object-cover ${
isLight ? 'border-2 border-white/30' : 'border border-gray-200 dark:border-gray-600'
}`}
/>
) : (
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium ${
isLight
? 'border-2 border-white/30 bg-white/20 text-white'
: 'border border-gray-200 dark:border-gray-600 bg-brand-500 text-white'
}`}>
{getInitials(user.name)}
</div>
)}
<ChevronDown
size={16}
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''} ${
isLight ? 'text-white/70' : 'text-gray-400'
}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
{/* User Info Header */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{user.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
</div>
{/* Menu Items */}
<div className="py-1">
<Link
to={profilePath}
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<Settings size={16} className="text-gray-400" />
Profile Settings
</Link>
</div>
{/* Sign Out */}
<div className="border-t border-gray-200 dark:border-gray-700 py-1">
<button
onClick={handleSignOut}
disabled={isLoggingOut}
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<LogOut size={16} />
{isLoggingOut ? 'Signing out...' : 'Sign Out'}
</button>
</div>
</div>
)}
</div>
);
};
export default UserProfileDropdown;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight } from 'lucide-react';
interface CTASectionProps {
variant?: 'default' | 'minimal';
}
const CTASection: React.FC<CTASectionProps> = ({ variant = 'default' }) => {
const { t } = useTranslation();
if (variant === 'minimal') {
return (
<section className="py-16 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.cta.ready')}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
{t('marketing.cta.readySubtitle')}
</p>
<Link
to="/signup"
className="inline-flex items-center gap-2 px-6 py-3 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-colors"
>
{t('marketing.cta.startFree')}
<ArrowRight className="h-5 w-5" />
</Link>
</div>
</section>
);
}
return (
<section className="py-20 lg:py-28 bg-gradient-to-br from-brand-600 to-brand-700 relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
</div>
<div className="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-6">
{t('marketing.cta.ready')}
</h2>
<p className="text-lg sm:text-xl text-brand-100 mb-10 max-w-2xl mx-auto">
{t('marketing.cta.readySubtitle')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
to="/signup"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4 text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 shadow-lg shadow-black/10 transition-colors"
>
{t('marketing.cta.startFree')}
<ArrowRight className="h-5 w-5" />
</Link>
<Link
to="/contact"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4 text-base font-semibold text-white bg-white/10 rounded-xl hover:bg-white/20 border border-white/20 transition-colors"
>
{t('marketing.cta.talkToSales')}
</Link>
</div>
<p className="mt-6 text-sm text-brand-200">
{t('marketing.cta.noCredit')}
</p>
</div>
</section>
);
};
export default CTASection;

View File

@@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { ChevronDown } from 'lucide-react';
interface FAQItem {
question: string;
answer: string;
}
interface FAQAccordionProps {
items: FAQItem[];
}
const FAQAccordion: React.FC<FAQAccordionProps> = ({ items }) => {
const [openIndex, setOpenIndex] = useState<number | null>(0);
const toggleItem = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="space-y-4">
{items.map((item, index) => (
<div
key={index}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<button
onClick={() => toggleItem(index)}
className="w-full flex items-center justify-between p-6 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
aria-expanded={openIndex === index}
>
<span className="text-base font-medium text-gray-900 dark:text-white dark:hover:text-white pr-4">
{item.question}
</span>
<ChevronDown
className={`h-5 w-5 text-gray-500 dark:text-gray-400 flex-shrink-0 transition-transform duration-200 ${
openIndex === index ? 'rotate-180' : ''
}`}
/>
</button>
<div
className={`overflow-hidden transition-all duration-200 ${
openIndex === index ? 'max-h-96' : 'max-h-0'
}`}
>
<div className="px-6 pt-2 pb-6 text-gray-600 dark:text-gray-400 leading-relaxed">
{item.answer}
</div>
</div>
</div>
))}
</div>
);
};
export default FAQAccordion;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface FeatureCardProps {
icon: LucideIcon;
title: string;
description: string;
iconColor?: string;
}
const FeatureCard: React.FC<FeatureCardProps> = ({
icon: Icon,
title,
description,
iconColor = 'brand',
}) => {
const colorClasses: Record<string, string> = {
brand: 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400',
green: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
purple: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
orange: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400',
pink: 'bg-pink-100 dark:bg-pink-900/30 text-pink-600 dark:text-pink-400',
cyan: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400',
};
return (
<div className="group p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-lg hover:shadow-brand-600/5 transition-all duration-300">
<div className={`inline-flex p-3 rounded-xl ${colorClasses[iconColor]} mb-4`}>
<Icon className="h-6 w-6" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">
{title}
</h3>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
{description}
</p>
</div>
);
};
export default FeatureCard;

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Twitter, Linkedin, Github, Youtube } from 'lucide-react';
import SmoothScheduleLogo from '../SmoothScheduleLogo';
const Footer: React.FC = () => {
const { t } = useTranslation();
const currentYear = new Date().getFullYear();
const footerLinks = {
product: [
{ to: '/features', label: t('marketing.nav.features') },
{ to: '/pricing', label: t('marketing.nav.pricing') },
{ to: '/signup', label: t('marketing.nav.getStarted') },
],
company: [
{ to: '/about', label: t('marketing.nav.about') },
{ to: '/contact', label: t('marketing.nav.contact') },
],
legal: [
{ to: '/privacy', label: t('marketing.footer.legal.privacy') },
{ to: '/terms', label: t('marketing.footer.legal.terms') },
],
};
const socialLinks = [
{ href: 'https://twitter.com/smoothschedule', icon: Twitter, label: 'Twitter' },
{ href: 'https://linkedin.com/company/smoothschedule', icon: Linkedin, label: 'LinkedIn' },
{ href: 'https://github.com/smoothschedule', icon: Github, label: 'GitHub' },
{ href: 'https://youtube.com/@smoothschedule', icon: Youtube, label: 'YouTube' },
];
return (
<footer className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
{/* Main Footer Content */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 lg:gap-12">
{/* Brand Column */}
<div className="col-span-2 md:col-span-1">
<Link to="/" className="flex items-center gap-2 mb-4 group">
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
<span className="text-lg font-bold text-gray-900 dark:text-white">
Smooth Schedule
</span>
</Link>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t('marketing.description')}
</p>
{/* Social Links */}
<div className="flex items-center gap-4">
{socialLinks.map((social) => (
<a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-gray-500 hover:text-brand-600 dark:text-gray-400 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={social.label}
>
<social.icon className="h-5 w-5" />
</a>
))}
</div>
</div>
{/* Product Links */}
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
{t('marketing.footer.product.title')}
</h3>
<ul className="space-y-3">
{footerLinks.product.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Company Links */}
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
{t('marketing.footer.company.title')}
</h3>
<ul className="space-y-3">
{footerLinks.company.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Legal Links */}
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
{t('marketing.footer.legal.title')}
</h3>
<ul className="space-y-3">
{footerLinks.legal.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-800">
<p className="text-sm text-center text-gray-500 dark:text-gray-400">
&copy; {currentYear} {t('marketing.footer.copyright')}
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,166 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Play, ArrowRight, CheckCircle } from 'lucide-react';
const Hero: React.FC = () => {
const { t } = useTranslation();
return (
<section className="relative overflow-hidden bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
{/* Background Pattern */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-brand-100 dark:bg-brand-500/10 rounded-full blur-3xl opacity-50 dark:opacity-30" />
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-brand-100 dark:bg-brand-500/10 rounded-full blur-3xl opacity-50 dark:opacity-30" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 lg:py-32">
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
{/* Left Content */}
<div className="text-center lg:text-left">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand-50 dark:bg-brand-900/30 border border-brand-200 dark:border-brand-800 mb-6">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-brand-700 dark:text-brand-300">
{t('marketing.pricing.startToday')}
</span>
</div>
{/* Headline */}
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-tight mb-6">
{t('marketing.hero.headline')}
</h1>
{/* Subheadline */}
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 mb-8 max-w-xl mx-auto lg:mx-0">
{t('marketing.hero.subheadline')}
</p>
{/* CTAs */}
<div className="flex flex-col sm:flex-row items-center gap-4 justify-center lg:justify-start mb-8">
<Link
to="/signup"
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 shadow-lg shadow-brand-600/25 hover:shadow-brand-600/40 transition-all duration-200"
>
{t('marketing.hero.cta')}
<ArrowRight className="h-5 w-5" />
</Link>
<button
onClick={() => {/* TODO: Open demo modal/video */}}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 text-base font-semibold text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<Play className="h-5 w-5" />
{t('marketing.hero.secondaryCta')}
</button>
</div>
{/* Trust Indicators */}
<div className="flex flex-col sm:flex-row items-center gap-4 text-sm text-gray-500 dark:text-gray-400 justify-center lg:justify-start">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span>{t('marketing.pricing.noCredit')}</span>
</div>
<div className="hidden sm:block w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span>{t('marketing.pricing.startToday')}</span>
</div>
</div>
</div>
{/* Right Content - Dashboard Preview */}
<div className="relative">
<div className="relative rounded-2xl overflow-hidden shadow-2xl shadow-brand-600/10 border border-gray-200 dark:border-gray-700">
{/* Mock Dashboard */}
<div className="bg-white dark:bg-gray-800 aspect-[4/3]">
{/* Mock Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400" />
<div className="w-3 h-3 rounded-full bg-yellow-400" />
<div className="w-3 h-3 rounded-full bg-green-400" />
</div>
<div className="flex-1 text-center">
<div className="inline-block px-4 py-1 rounded-lg bg-gray-100 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400">
dashboard.smoothschedule.com
</div>
</div>
</div>
{/* Mock Content */}
<div className="p-4 space-y-4">
{/* Stats Row */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'Today', value: '12', color: 'brand' },
{ label: 'This Week', value: '48', color: 'green' },
{ label: 'Revenue', value: '$2.4k', color: 'purple' },
].map((stat) => (
<div key={stat.label} className="p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{stat.label}</div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{stat.value}</div>
</div>
))}
</div>
{/* Calendar Mock */}
<div className="rounded-lg bg-gray-50 dark:bg-gray-700/50 p-3">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-3">Today's Schedule</div>
<div className="space-y-2">
{[
{ time: '9:00 AM', title: 'Sarah J. - Haircut', color: 'brand' },
{ time: '10:30 AM', title: 'Mike T. - Consultation', color: 'green' },
{ time: '2:00 PM', title: 'Emma W. - Color', color: 'purple' },
].map((apt, i) => (
<div key={i} className="flex items-center gap-3 p-2 rounded-lg bg-white dark:bg-gray-800">
<div className={`w-1 h-8 rounded-full ${
apt.color === 'brand' ? 'bg-brand-500' :
apt.color === 'green' ? 'bg-green-500' : 'bg-purple-500'
}`} />
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">{apt.time}</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{apt.title}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Floating Elements */}
<div className="absolute -bottom-4 -left-4 px-4 py-3 rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">New Booking!</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Just now</div>
</div>
</div>
</div>
</div>
</div>
{/* Trust Badge */}
<div className="mt-16 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{t('marketing.hero.trustedBy')}
</p>
<div className="flex flex-wrap items-center justify-center gap-8 opacity-50">
{/* Mock company logos - replace with actual logos */}
{['TechCorp', 'Innovate', 'StartupX', 'GrowthCo', 'ScaleUp'].map((name) => (
<div key={name} className="text-lg font-bold text-gray-400 dark:text-gray-500">
{name}
</div>
))}
</div>
</div>
</div>
</section>
);
};
export default Hero;

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { UserPlus, Settings, Rocket } from 'lucide-react';
const HowItWorks: React.FC = () => {
const { t } = useTranslation();
const steps = [
{
number: '01',
icon: UserPlus,
title: t('marketing.howItWorks.step1.title'),
description: t('marketing.howItWorks.step1.description'),
color: 'brand',
},
{
number: '02',
icon: Settings,
title: t('marketing.howItWorks.step2.title'),
description: t('marketing.howItWorks.step2.description'),
color: 'purple',
},
{
number: '03',
icon: Rocket,
title: t('marketing.howItWorks.step3.title'),
description: t('marketing.howItWorks.step3.description'),
color: 'green',
},
];
const colorClasses: Record<string, { bg: string; text: string; border: string }> = {
brand: {
bg: 'bg-brand-100 dark:bg-brand-900/30',
text: 'text-brand-600 dark:text-brand-400',
border: 'border-brand-200 dark:border-brand-800',
},
purple: {
bg: 'bg-purple-100 dark:bg-purple-900/30',
text: 'text-purple-600 dark:text-purple-400',
border: 'border-purple-200 dark:border-purple-800',
},
green: {
bg: 'bg-green-100 dark:bg-green-900/30',
text: 'text-green-600 dark:text-green-400',
border: 'border-green-200 dark:border-green-800',
},
};
return (
<section className="py-20 lg:py-28 bg-gray-50 dark:bg-gray-800/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{t('marketing.howItWorks.title')}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{t('marketing.howItWorks.subtitle')}
</p>
</div>
{/* Steps */}
<div className="grid md:grid-cols-3 gap-8 lg:gap-12">
{steps.map((step, index) => {
const colors = colorClasses[step.color];
return (
<div key={step.number} className="relative">
{/* Connector Line (hidden on mobile) */}
{index < steps.length - 1 && (
<div className="hidden md:block absolute top-16 left-1/2 w-full h-0.5 bg-gradient-to-r from-gray-200 dark:from-gray-700 to-transparent" />
)}
<div className="relative bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 text-center">
{/* Step Number */}
<div className={`absolute -top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full ${colors.bg} ${colors.text} border ${colors.border} text-sm font-bold`}>
{step.number}
</div>
{/* Icon */}
<div className={`inline-flex p-4 rounded-2xl ${colors.bg} mb-6`}>
<step.icon className={`h-8 w-8 ${colors.text}`} />
</div>
{/* Content */}
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
{step.title}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{step.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</section>
);
};
export default HowItWorks;

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Menu, X, Sun, Moon } from 'lucide-react';
import SmoothScheduleLogo from '../SmoothScheduleLogo';
import LanguageSelector from '../LanguageSelector';
interface NavbarProps {
darkMode: boolean;
toggleTheme: () => void;
}
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
const { t } = useTranslation();
const location = useLocation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Close mobile menu on route change
useEffect(() => {
setIsMenuOpen(false);
}, [location.pathname]);
const navLinks = [
{ to: '/features', label: t('marketing.nav.features') },
{ to: '/pricing', label: t('marketing.nav.pricing') },
{ to: '/about', label: t('marketing.nav.about') },
{ to: '/contact', label: t('marketing.nav.contact') },
];
const isActive = (path: string) => location.pathname === path;
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled
? 'bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg shadow-sm'
: 'bg-transparent'
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 lg:h-20">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 group">
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
<span className="text-xl font-bold text-gray-900 dark:text-white hidden sm:block">
Smooth Schedule
</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden lg:flex items-center gap-8">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className={`text-sm font-medium transition-colors ${
isActive(link.to)
? 'text-brand-600 dark:text-brand-400'
: 'text-gray-600 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400'
}`}
>
{link.label}
</Link>
))}
</div>
{/* Right Section */}
<div className="flex items-center gap-3">
{/* Language Selector - Hidden on mobile */}
<div className="hidden md:block">
<LanguageSelector />
</div>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{/* Login Button - Hidden on mobile */}
<Link
to="/login"
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{t('marketing.nav.login')}
</Link>
{/* Get Started CTA */}
<Link
to="/signup"
className="hidden sm:inline-flex px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors shadow-sm"
>
{t('marketing.nav.getStarted')}
</Link>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="lg:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Toggle menu"
>
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
</div>
</div>
{/* Mobile Menu */}
<div
className={`lg:hidden overflow-hidden transition-all duration-300 ${
isMenuOpen ? 'max-h-96' : 'max-h-0'
}`}
>
<div className="px-4 py-4 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-2">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className={`px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive(link.to)
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-600 dark:text-brand-400'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{link.label}
</Link>
))}
<hr className="my-2 border-gray-200 dark:border-gray-800" />
<Link
to="/login"
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{t('marketing.nav.login')}
</Link>
<Link
to="/signup"
className="px-4 py-3 rounded-lg text-sm font-medium text-center text-white bg-brand-600 hover:bg-brand-700 transition-colors"
>
{t('marketing.nav.getStarted')}
</Link>
<div className="px-4 py-2">
<LanguageSelector />
</div>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,185 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check } from 'lucide-react';
interface PricingCardProps {
tier: 'free' | 'professional' | 'business' | 'enterprise';
highlighted?: boolean;
billingPeriod: 'monthly' | 'annual';
}
const PricingCard: React.FC<PricingCardProps> = ({
tier,
highlighted = false,
billingPeriod,
}) => {
const { t } = useTranslation();
const tierData = {
free: {
price: 0,
annualPrice: 0,
},
professional: {
price: 29,
annualPrice: 290,
},
business: {
price: 79,
annualPrice: 790,
},
enterprise: {
price: 'custom',
annualPrice: 'custom',
},
};
const data = tierData[tier];
const price = billingPeriod === 'annual' ? data.annualPrice : data.price;
const isCustom = price === 'custom';
// Get features array from i18n
const features = t(`marketing.pricing.tiers.${tier}.features`, { returnObjects: true }) as string[];
const transactionFee = t(`marketing.pricing.tiers.${tier}.transactionFee`);
const trialInfo = t(`marketing.pricing.tiers.${tier}.trial`);
if (highlighted) {
return (
<div className="relative flex flex-col p-8 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20">
{/* Most Popular Badge */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1.5 bg-brand-500 text-white text-sm font-semibold rounded-full whitespace-nowrap">
{t('marketing.pricing.mostPopular')}
</div>
{/* Header */}
<div className="mb-6">
<h3 className="text-xl font-bold text-white mb-2">
{t(`marketing.pricing.tiers.${tier}.name`)}
</h3>
<p className="text-brand-100">
{t(`marketing.pricing.tiers.${tier}.description`)}
</p>
</div>
{/* Price */}
<div className="mb-6">
{isCustom ? (
<span className="text-4xl font-bold text-white">
{t('marketing.pricing.tiers.enterprise.price')}
</span>
) : (
<>
<span className="text-5xl font-bold text-white">${price}</span>
<span className="text-brand-200 ml-2">
{billingPeriod === 'annual' ? '/year' : t('marketing.pricing.perMonth')}
</span>
</>
)}
{trialInfo && (
<div className="mt-2 text-sm text-brand-100">
{trialInfo}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-3 mb-8">
{features.map((feature, index) => (
<li key={index} className="flex items-start gap-3">
<Check className="h-5 w-5 text-brand-200 flex-shrink-0 mt-0.5" />
<span className="text-white">{feature}</span>
</li>
))}
<li className="flex items-start gap-3 pt-2 border-t border-brand-500">
<span className="text-brand-200 text-sm">{transactionFee}</span>
</li>
</ul>
{/* CTA */}
{isCustom ? (
<Link
to="/contact"
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
>
{t('marketing.pricing.contactSales')}
</Link>
) : (
<Link
to="/signup"
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
>
{t('marketing.pricing.getStarted')}
</Link>
)}
</div>
);
}
return (
<div className="relative flex flex-col p-8 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t(`marketing.pricing.tiers.${tier}.name`)}
</h3>
<p className="text-gray-500 dark:text-gray-400">
{t(`marketing.pricing.tiers.${tier}.description`)}
</p>
</div>
{/* Price */}
<div className="mb-6">
{isCustom ? (
<span className="text-4xl font-bold text-gray-900 dark:text-white">
{t('marketing.pricing.tiers.enterprise.price')}
</span>
) : (
<>
<span className="text-5xl font-bold text-gray-900 dark:text-white">${price}</span>
<span className="text-gray-500 dark:text-gray-400 ml-2">
{billingPeriod === 'annual' ? '/year' : t('marketing.pricing.perMonth')}
</span>
</>
)}
{trialInfo && (
<div className="mt-2 text-sm text-brand-600 dark:text-brand-400">
{trialInfo}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-3 mb-8">
{features.map((feature, index) => (
<li key={index} className="flex items-start gap-3">
<Check className="h-5 w-5 text-brand-600 dark:text-brand-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-700 dark:text-gray-300">{feature}</span>
</li>
))}
<li className="flex items-start gap-3 pt-2 border-t border-gray-100 dark:border-gray-700">
<span className="text-gray-500 dark:text-gray-400 text-sm">{transactionFee}</span>
</li>
</ul>
{/* CTA */}
{isCustom ? (
<Link
to="/contact"
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-xl hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
>
{t('marketing.pricing.contactSales')}
</Link>
) : (
<Link
to="/signup"
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-xl hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
>
{t('marketing.pricing.getStarted')}
</Link>
)}
</div>
);
};
export default PricingCard;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Calendar, Building2, Globe, Clock } from 'lucide-react';
const StatsSection: React.FC = () => {
const { t } = useTranslation();
const stats = [
{
icon: Calendar,
value: '1M+',
label: t('marketing.stats.appointments'),
color: 'brand',
},
{
icon: Building2,
value: '5,000+',
label: t('marketing.stats.businesses'),
color: 'green',
},
{
icon: Globe,
value: '50+',
label: t('marketing.stats.countries'),
color: 'purple',
},
{
icon: Clock,
value: '99.9%',
label: t('marketing.stats.uptime'),
color: 'orange',
},
];
const colorClasses: Record<string, string> = {
brand: 'text-brand-600 dark:text-brand-400',
green: 'text-green-600 dark:text-green-400',
purple: 'text-purple-600 dark:text-purple-400',
orange: 'text-orange-600 dark:text-orange-400',
};
return (
<section className="py-20 bg-white dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<div className="inline-flex p-3 rounded-xl bg-gray-100 dark:bg-gray-800 mb-4">
<stat.icon className={`h-6 w-6 ${colorClasses[stat.color]}`} />
</div>
<div className={`text-4xl lg:text-5xl font-bold mb-2 ${colorClasses[stat.color]}`}>
{stat.value}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{stat.label}
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default StatsSection;

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Star } from 'lucide-react';
interface TestimonialCardProps {
quote: string;
author: string;
role: string;
company: string;
avatarUrl?: string;
rating?: number;
}
const TestimonialCard: React.FC<TestimonialCardProps> = ({
quote,
author,
role,
company,
avatarUrl,
rating = 5,
}) => {
return (
<div className="flex flex-col p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow">
{/* Stars */}
<div className="flex gap-1 mb-4">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`h-5 w-5 ${
i < rating
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-300 dark:text-gray-600'
}`}
/>
))}
</div>
{/* Quote */}
<blockquote className="flex-1 text-gray-700 dark:text-gray-300 mb-6 leading-relaxed">
"{quote}"
</blockquote>
{/* Author */}
<div className="flex items-center gap-3">
{avatarUrl ? (
<img
src={avatarUrl}
alt={author}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<span className="text-lg font-semibold text-brand-600 dark:text-brand-400">
{author.charAt(0)}
</span>
</div>
)}
<div>
<div className="font-semibold text-gray-900 dark:text-white">{author}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{role} at {company}
</div>
</div>
</div>
</div>
);
};
export default TestimonialCard;

View File

@@ -0,0 +1,463 @@
import React, { useState } from 'react';
import { X, Shield, Copy, Check, Download, AlertTriangle, Smartphone } from 'lucide-react';
import { useSetupTOTP, useVerifyTOTP, useDisableTOTP, useRecoveryCodes, useRegenerateRecoveryCodes } from '../../hooks/useProfile';
interface TwoFactorSetupProps {
isEnabled: boolean;
phoneVerified?: boolean;
hasPhone?: boolean;
onClose: () => void;
onSuccess: () => void;
onVerifyPhone?: () => void;
}
type SetupStep = 'intro' | 'qrcode' | 'verify' | 'recovery' | 'complete' | 'disable' | 'view-recovery';
const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({ isEnabled, phoneVerified = false, hasPhone = false, onClose, onSuccess, onVerifyPhone }) => {
const [step, setStep] = useState<SetupStep>(isEnabled ? 'disable' : 'intro');
const [verificationCode, setVerificationCode] = useState('');
const [disableCode, setDisableCode] = useState('');
const [error, setError] = useState('');
const [copiedSecret, setCopiedSecret] = useState(false);
const [copiedCodes, setCopiedCodes] = useState(false);
const setupTOTP = useSetupTOTP();
const verifyTOTP = useVerifyTOTP();
const disableTOTP = useDisableTOTP();
const recoveryCodes = useRecoveryCodes();
const regenerateCodes = useRegenerateRecoveryCodes();
const handleStartSetup = async () => {
setError('');
try {
await setupTOTP.mutateAsync();
setStep('qrcode');
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to start 2FA setup');
}
};
const handleVerify = async () => {
if (verificationCode.length !== 6) {
setError('Please enter a 6-digit code');
return;
}
setError('');
try {
const result = await verifyTOTP.mutateAsync(verificationCode);
// Store recovery codes from response
setStep('recovery');
} catch (err: any) {
setError(err.response?.data?.detail || 'Invalid verification code');
}
};
const handleDisable = async () => {
if (disableCode.length !== 6) {
setError('Please enter a 6-digit code');
return;
}
setError('');
try {
await disableTOTP.mutateAsync(disableCode);
onSuccess();
onClose();
} catch (err: any) {
setError(err.response?.data?.detail || 'Invalid code');
}
};
const handleViewRecoveryCodes = async () => {
setError('');
try {
await recoveryCodes.refetch();
setStep('view-recovery');
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load recovery codes');
}
};
const handleRegenerateCodes = async () => {
setError('');
try {
await regenerateCodes.mutateAsync();
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to regenerate codes');
}
};
const copyToClipboard = (text: string, type: 'secret' | 'codes') => {
navigator.clipboard.writeText(text);
if (type === 'secret') {
setCopiedSecret(true);
setTimeout(() => setCopiedSecret(false), 2000);
} else {
setCopiedCodes(true);
setTimeout(() => setCopiedCodes(false), 2000);
}
};
const downloadRecoveryCodes = (codes: string[]) => {
const content = `SmoothSchedule Recovery Codes\n${'='.repeat(30)}\n\nKeep these codes safe. Each code can only be used once.\n\n${codes.join('\n')}\n\nGenerated: ${new Date().toISOString()}`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'smoothschedule-recovery-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
const handleComplete = () => {
onSuccess();
onClose();
};
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-md w-full mx-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
<Shield size={20} className="text-brand-600 dark:text-brand-400" />
</div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEnabled ? 'Manage Two-Factor Authentication' : 'Set Up Two-Factor Authentication'}
</h2>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6">
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-2 text-red-700 dark:text-red-400 text-sm">
<AlertTriangle size={16} />
{error}
</div>
)}
{/* Intro Step */}
{step === 'intro' && (
<div className="space-y-4">
<div className="text-center py-4">
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Smartphone size={32} className="text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Secure Your Account
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Two-factor authentication adds an extra layer of security. You'll need an authenticator app like Google Authenticator or Authy.
</p>
</div>
{/* SMS Backup Info */}
<div className={`p-4 rounded-lg border ${phoneVerified ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' : 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'}`}>
<div className="flex items-start gap-3">
{phoneVerified ? (
<Check size={18} className="text-green-600 dark:text-green-400 mt-0.5" />
) : (
<AlertTriangle size={18} className="text-amber-600 dark:text-amber-400 mt-0.5" />
)}
<div className="flex-1">
<p className={`text-sm font-medium ${phoneVerified ? 'text-green-700 dark:text-green-300' : 'text-amber-700 dark:text-amber-300'}`}>
SMS Backup {phoneVerified ? 'Available' : 'Not Available'}
</p>
<p className={`text-xs mt-1 ${phoneVerified ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
{phoneVerified
? 'Your verified phone can be used as a backup method.'
: hasPhone
? 'Your phone number is not verified. Verify it to enable SMS backup as a fallback when you can\'t access your authenticator app.'
: 'Add and verify a phone number in your profile settings to receive text message codes as a backup when you can\'t access your authenticator app.'}
</p>
{!phoneVerified && hasPhone && onVerifyPhone && (
<button
onClick={() => {
onClose();
onVerifyPhone();
}}
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 underline hover:no-underline"
>
Verify your phone number now
</button>
)}
{!phoneVerified && !hasPhone && (
<button
onClick={onClose}
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 underline hover:no-underline"
>
Go to profile settings to add a phone number
</button>
)}
</div>
</div>
</div>
<button
onClick={handleStartSetup}
disabled={setupTOTP.isPending}
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50 font-medium"
>
{setupTOTP.isPending ? 'Setting up...' : 'Get Started'}
</button>
</div>
)}
{/* QR Code Step */}
{step === 'qrcode' && setupTOTP.data && (
<div className="space-y-4">
<div className="text-center">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Scan this QR code with your authenticator app
</p>
<div className="bg-white p-4 rounded-lg inline-block mb-4">
<img
src={`data:image/png;base64,${setupTOTP.data.qr_code}`}
alt="2FA QR Code"
className="w-48 h-48"
/>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Can't scan? Enter this code manually:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-600 text-sm font-mono text-gray-900 dark:text-white break-all">
{setupTOTP.data.secret}
</code>
<button
onClick={() => copyToClipboard(setupTOTP.data!.secret, 'secret')}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
title="Copy to clipboard"
>
{copiedSecret ? <Check size={18} className="text-green-500" /> : <Copy size={18} />}
</button>
</div>
</div>
<button
onClick={() => setStep('verify')}
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Continue
</button>
</div>
)}
{/* Verify Step */}
{step === 'verify' && (
<div className="space-y-4">
<div className="text-center mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Enter the 6-digit code from your authenticator app
</p>
</div>
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="w-full text-center text-2xl tracking-widest py-4 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 font-mono"
autoFocus
/>
<div className="flex gap-3">
<button
onClick={() => setStep('qrcode')}
className="flex-1 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium"
>
Back
</button>
<button
onClick={handleVerify}
disabled={verifyTOTP.isPending || verificationCode.length !== 6}
className="flex-1 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50 font-medium"
>
{verifyTOTP.isPending ? 'Verifying...' : 'Verify'}
</button>
</div>
</div>
)}
{/* Recovery Codes Step */}
{step === 'recovery' && verifyTOTP.data?.recovery_codes && (
<div className="space-y-4">
<div className="text-center mb-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-3">
<Check size={24} className="text-green-600 dark:text-green-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
2FA Enabled Successfully!
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Save these recovery codes in a safe place
</p>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex items-start gap-2 mb-3">
<AlertTriangle size={16} className="text-amber-600 dark:text-amber-400 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-300">
Each code can only be used once. Store them securely - you won't see them again!
</p>
</div>
<div className="grid grid-cols-2 gap-2 bg-white dark:bg-gray-800 rounded-lg p-3">
{verifyTOTP.data.recovery_codes.map((code: string, index: number) => (
<code key={index} className="text-sm font-mono text-gray-900 dark:text-white">
{code}
</code>
))}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => copyToClipboard(verifyTOTP.data!.recovery_codes.join('\n'), 'codes')}
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
{copiedCodes ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
{copiedCodes ? 'Copied!' : 'Copy'}
</button>
<button
onClick={() => downloadRecoveryCodes(verifyTOTP.data!.recovery_codes)}
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Download size={16} />
Download
</button>
</div>
<button
onClick={handleComplete}
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Done
</button>
</div>
)}
{/* Complete Step (fallback) */}
{step === 'complete' && (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Check size={32} className="text-green-600 dark:text-green-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Two-Factor Authentication Enabled
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6">
Your account is now more secure
</p>
<button
onClick={handleComplete}
className="px-6 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
>
Close
</button>
</div>
)}
{/* Disable Step */}
{step === 'disable' && (
<div className="space-y-4">
<div className="space-y-3">
<button
onClick={handleViewRecoveryCodes}
disabled={recoveryCodes.isFetching}
className="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<span className="font-medium text-gray-900 dark:text-white">View Recovery Codes</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{recoveryCodes.isFetching ? 'Loading...' : '→'}
</span>
</button>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
To disable 2FA, enter a code from your authenticator app:
</p>
<input
type="text"
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="w-full text-center text-xl tracking-widest py-3 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 font-mono mb-3"
/>
<button
onClick={handleDisable}
disabled={disableTOTP.isPending || disableCode.length !== 6}
className="w-full py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 font-medium"
>
{disableTOTP.isPending ? 'Disabling...' : 'Disable Two-Factor Authentication'}
</button>
</div>
</div>
)}
{/* View Recovery Codes Step */}
{step === 'view-recovery' && recoveryCodes.data && (
<div className="space-y-4">
<button
onClick={() => setStep('disable')}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
Back
</button>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Your recovery codes (each can only be used once):
</p>
<div className="grid grid-cols-2 gap-2 bg-white dark:bg-gray-800 rounded-lg p-3">
{recoveryCodes.data.map((code: string, index: number) => (
<code key={index} className="text-sm font-mono text-gray-900 dark:text-white">
{code}
</code>
))}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => copyToClipboard(recoveryCodes.data!.join('\n'), 'codes')}
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
{copiedCodes ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
{copiedCodes ? 'Copied!' : 'Copy'}
</button>
<button
onClick={() => downloadRecoveryCodes(recoveryCodes.data!)}
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Download size={16} />
Download
</button>
</div>
<button
onClick={handleRegenerateCodes}
disabled={regenerateCodes.isPending}
className="w-full py-2 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors text-sm"
>
{regenerateCodes.isPending ? 'Regenerating...' : 'Regenerate Recovery Codes'}
</button>
</div>
)}
</div>
</div>
</div>
);
};
export default TwoFactorSetup;

View File

@@ -0,0 +1,332 @@
/**
* WebSocket hook for real-time appointment updates.
* Connects to the backend WebSocket and updates React Query cache.
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCookie } from '../utils/cookies';
import { getSubdomain } from '../api/config';
import { Appointment } from '../types';
interface WebSocketMessage {
type: 'connection_established' | 'appointment_created' | 'appointment_updated' | 'appointment_deleted' | 'pong';
appointment?: {
id: string;
business_id: string;
service_id: string;
resource_id: string | null;
customer_id: string;
customer_name: string;
start_time: string;
end_time: string;
duration_minutes: number;
status: string;
notes: string;
};
appointment_id?: string;
message?: string;
}
interface UseAppointmentWebSocketOptions {
enabled?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Event) => void;
}
/**
* Transform backend appointment format to frontend format
*/
function transformAppointment(data: WebSocketMessage['appointment']): Appointment | null {
if (!data) return null;
return {
id: data.id,
resourceId: data.resource_id,
customerId: data.customer_id,
customerName: data.customer_name,
serviceId: data.service_id,
startTime: new Date(data.start_time),
durationMinutes: data.duration_minutes,
status: data.status as Appointment['status'],
notes: data.notes,
};
}
/**
* Hook for real-time appointment updates via WebSocket.
* Handles React StrictMode's double-effect invocation gracefully.
*/
export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions = {}) {
const { enabled = true, onConnected, onDisconnected, onError } = options;
const queryClient = useQueryClient();
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isCleaningUpRef = useRef(false);
const maxReconnectAttempts = 5;
const [isConnected, setIsConnected] = useState(false);
// Store callbacks in refs to avoid effect re-runs
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
useEffect(() => {
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
}, [onConnected, onDisconnected, onError]);
// Get WebSocket URL - not a callback to avoid recreating
const getWebSocketUrl = () => {
const token = getCookie('access_token');
const subdomain = getSubdomain();
if (!token || !subdomain) {
return null;
}
// Determine WebSocket host - use api subdomain for WebSocket
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
return `${wsProtocol}//${wsHost}/ws/appointments/?token=${token}&subdomain=${subdomain}`;
};
const updateQueryCache = useCallback((message: WebSocketMessage) => {
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
appointmentQueries.forEach((query) => {
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
switch (message.type) {
case 'appointment_created': {
const newAppointment = transformAppointment(message.appointment);
if (!newAppointment) return old;
// Check if appointment already exists (avoid duplicates)
if (old.some(apt => apt.id === newAppointment.id)) {
return old;
}
return [...old, newAppointment];
}
case 'appointment_updated': {
const updatedAppointment = transformAppointment(message.appointment);
if (!updatedAppointment) return old;
return old.map(apt =>
apt.id === updatedAppointment.id ? updatedAppointment : apt
);
}
case 'appointment_deleted': {
if (!message.appointment_id) return old;
return old.filter(apt => apt.id !== message.appointment_id);
}
default:
return old;
}
});
});
}, [queryClient]);
// Main effect to manage WebSocket connection
// Only depends on `enabled` - other values are read from refs or called as functions
useEffect(() => {
if (!enabled) {
return;
}
// Reset cleanup flag at start of effect
isCleaningUpRef.current = false;
// Track the current effect's abort controller to handle StrictMode
let effectAborted = false;
const connect = () => {
// Don't connect if effect was aborted or we're cleaning up
if (effectAborted || isCleaningUpRef.current) {
return;
}
const url = getWebSocketUrl();
if (!url) {
console.log('WebSocket: Missing token or subdomain, skipping connection');
return;
}
// Close existing connection if any
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
wsRef.current.close();
}
console.log('WebSocket: Connecting to', url.replace(/token=[^&]+/, 'token=***'));
const ws = new WebSocket(url);
ws.onopen = () => {
// Don't process if effect was aborted or cleaning up
if (effectAborted || isCleaningUpRef.current) {
ws.close();
return;
}
console.log('WebSocket: Connected');
reconnectAttemptsRef.current = 0;
setIsConnected(true);
onConnectedRef.current?.();
// Start ping interval to keep connection alive
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN && !effectAborted) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Ping every 30 seconds
};
ws.onmessage = (event) => {
// Ignore messages if effect was aborted
if (effectAborted) return;
try {
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'connection_established':
console.log('WebSocket: Connection confirmed -', message.message);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'appointment_created':
case 'appointment_updated':
case 'appointment_deleted':
console.log('WebSocket: Received', message.type);
updateQueryCache(message);
break;
default:
console.log('WebSocket: Unknown message type', message);
}
} catch (err) {
console.error('WebSocket: Failed to parse message', err);
}
};
ws.onerror = (error) => {
// Only log error if not aborted (StrictMode cleanup causes expected errors)
if (!effectAborted) {
console.error('WebSocket: Error', error);
onErrorRef.current?.(error);
}
};
ws.onclose = (event) => {
// Don't log or handle if effect was aborted (expected during StrictMode)
if (effectAborted) {
return;
}
console.log('WebSocket: Disconnected', event.code, event.reason);
setIsConnected(false);
onDisconnectedRef.current?.();
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Only attempt reconnection if not cleaning up
if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
wsRef.current = ws;
};
connect();
// Cleanup function
return () => {
effectAborted = true;
isCleaningUpRef.current = true;
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
};
}, [enabled]); // Only re-run when enabled changes
const reconnect = useCallback(() => {
isCleaningUpRef.current = false;
reconnectAttemptsRef.current = 0;
// Close existing connection
if (wsRef.current) {
wsRef.current.close();
}
// Connection will be re-established by the effect when we force re-render
// For now, we'll rely on the onclose handler to trigger reconnection
}, []);
const disconnect = useCallback(() => {
isCleaningUpRef.current = true;
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
}, []);
return {
isConnected,
reconnect,
disconnect,
};
}

View File

@@ -0,0 +1,279 @@
/**
* Appointment Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Appointment, AppointmentStatus } from '../types';
import { format } from 'date-fns';
interface AppointmentFilters {
resource?: string;
status?: AppointmentStatus;
startDate?: Date;
endDate?: Date;
}
/**
* Hook to fetch appointments with optional filters
*/
export const useAppointments = (filters?: AppointmentFilters) => {
return useQuery<Appointment[]>({
queryKey: ['appointments', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.resource) params.append('resource', filters.resource);
if (filters?.status) params.append('status', filters.status);
// Send full ISO datetime strings to avoid timezone issues
// The backend will compare datetime fields properly
if (filters?.startDate) {
// Start of day in local timezone, converted to ISO
const startOfDay = new Date(filters.startDate);
startOfDay.setHours(0, 0, 0, 0);
params.append('start_date', startOfDay.toISOString());
}
if (filters?.endDate) {
// End of day (or start of next day) in local timezone, converted to ISO
const endOfDay = new Date(filters.endDate);
endOfDay.setHours(0, 0, 0, 0);
params.append('end_date', endOfDay.toISOString());
}
const { data } = await apiClient.get(`/api/appointments/?${params}`);
// Transform backend format to frontend format
return data.map((a: any) => ({
id: String(a.id),
resourceId: a.resource_id ? String(a.resource_id) : null,
customerId: String(a.customer_id || a.customer),
customerName: a.customer_name || '',
serviceId: String(a.service_id || a.service),
startTime: new Date(a.start_time),
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
status: a.status as AppointmentStatus,
notes: a.notes || '',
}));
},
});
};
/**
* Calculate duration in minutes from start and end times
*/
function calculateDuration(startTime: string, endTime: string): number {
const start = new Date(startTime);
const end = new Date(endTime);
return Math.round((end.getTime() - start.getTime()) / (1000 * 60));
}
/**
* Hook to get a single appointment
*/
export const useAppointment = (id: string) => {
return useQuery<Appointment>({
queryKey: ['appointments', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/appointments/${id}/`);
return {
id: String(data.id),
resourceId: data.resource_id ? String(data.resource_id) : null,
customerId: String(data.customer_id || data.customer),
customerName: data.customer_name || '',
serviceId: String(data.service_id || data.service),
startTime: new Date(data.start_time),
durationMinutes: data.duration_minutes || calculateDuration(data.start_time, data.end_time),
status: data.status as AppointmentStatus,
notes: data.notes || '',
};
},
enabled: !!id,
});
};
/**
* Hook to create an appointment
*/
export const useCreateAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (appointmentData: Omit<Appointment, 'id'>) => {
const startTime = appointmentData.startTime;
const endTime = new Date(startTime.getTime() + appointmentData.durationMinutes * 60000);
const backendData: Record<string, unknown> = {
service: parseInt(appointmentData.serviceId),
resource: appointmentData.resourceId ? parseInt(appointmentData.resourceId) : null,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
notes: appointmentData.notes || '',
};
// Include customer if provided (for business-created appointments)
if (appointmentData.customerId) {
backendData.customer = parseInt(appointmentData.customerId);
}
const { data } = await apiClient.post('/api/appointments/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to update an appointment with optimistic updates for instant UI feedback
*/
export const useUpdateAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Appointment> }) => {
const backendData: any = {};
if (updates.serviceId) backendData.service = parseInt(updates.serviceId);
if (updates.resourceId !== undefined) {
backendData.resource = updates.resourceId ? parseInt(updates.resourceId) : null;
}
if (updates.startTime) {
backendData.start_time = updates.startTime.toISOString();
// Calculate end_time if we have duration, otherwise backend will keep existing duration
if (updates.durationMinutes) {
const endTime = new Date(updates.startTime.getTime() + updates.durationMinutes * 60000);
backendData.end_time = endTime.toISOString();
}
} else if (updates.durationMinutes) {
// If only duration changed, we need to get the current appointment to calculate new end time
// For now, just send duration and let backend handle it
// This case is handled by the resize logic which sends both startTime and durationMinutes
}
if (updates.status) backendData.status = updates.status;
if (updates.notes !== undefined) backendData.notes = updates.notes;
const { data } = await apiClient.patch(`/api/appointments/${id}/`, backendData);
return data;
},
// Optimistic update: update UI immediately before API call completes
onMutate: async ({ id, updates }) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['appointments'] });
// Get all appointment queries and update them optimistically
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
const previousData: { queryKey: unknown[]; data: Appointment[] | undefined }[] = [];
appointmentQueries.forEach((query) => {
const data = queryClient.getQueryData<Appointment[]>(query.queryKey);
if (data) {
previousData.push({ queryKey: query.queryKey, data });
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
return old.map((apt) =>
apt.id === id ? { ...apt, ...updates } : apt
);
});
}
});
// Return context with the previous values for rollback
return { previousData };
},
// If mutation fails, rollback to the previous values
onError: (error, _variables, context) => {
console.error('Failed to update appointment', error);
if (context?.previousData) {
context.previousData.forEach(({ queryKey, data }) => {
queryClient.setQueryData(queryKey, data);
});
}
},
// Always refetch after error or success to ensure server state
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to delete an appointment with optimistic updates
*/
export const useDeleteAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/appointments/${id}/`);
return id;
},
// Optimistic update: remove from UI immediately
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['appointments'] });
// Get all appointment queries and update them optimistically
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
const previousData: { queryKey: unknown[]; data: Appointment[] | undefined }[] = [];
appointmentQueries.forEach((query) => {
const data = queryClient.getQueryData<Appointment[]>(query.queryKey);
if (data) {
previousData.push({ queryKey: query.queryKey, data });
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
return old.filter((apt) => apt.id !== id);
});
}
});
return { previousData };
},
onError: (error, _id, context) => {
console.error('Failed to delete appointment', error);
if (context?.previousData) {
context.previousData.forEach(({ queryKey, data }) => {
queryClient.setQueryData(queryKey, data);
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to reschedule an appointment (update start time and resource)
*/
export const useRescheduleAppointment = () => {
const updateMutation = useUpdateAppointment();
return useMutation({
mutationFn: async ({
id,
newStartTime,
newResourceId,
}: {
id: string;
newStartTime: Date;
newResourceId?: string | null;
}) => {
const appointment = await apiClient.get(`/api/appointments/${id}/`);
const durationMinutes = appointment.data.duration_minutes;
return updateMutation.mutateAsync({
id,
updates: {
startTime: newStartTime,
durationMinutes,
resourceId: newResourceId !== undefined ? newResourceId : undefined,
},
});
},
});
};

View File

@@ -0,0 +1,231 @@
/**
* Authentication Hooks
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
login,
logout,
getCurrentUser,
masquerade,
stopMasquerade,
LoginCredentials,
User,
MasqueradeStackEntry
} from '../api/auth';
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
/**
* Hook to get current user
*/
export const useCurrentUser = () => {
return useQuery<User | null, Error>({
queryKey: ['currentUser'],
queryFn: async () => {
// Check if token exists before making request (from cookie)
const token = getCookie('access_token');
if (!token) {
return null; // No token, return null instead of making request
}
try {
return await getCurrentUser();
} catch (error) {
// If getCurrentUser fails (e.g., 401), return null
// The API client interceptor will handle token refresh
console.error('Failed to get current user:', error);
return null;
}
},
retry: 1, // Retry once in case of token refresh
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnMount: true, // Always refetch when component mounts
refetchOnWindowFocus: false,
});
};
/**
* Hook to login
*/
export const useLogin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: login,
onSuccess: (data) => {
// Store tokens in cookies (domain=.lvh.me for cross-subdomain access)
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
// Set user in cache
queryClient.setQueryData(['currentUser'], data.user);
},
});
};
/**
* Hook to logout
*/
export const useLogout = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: logout,
onSuccess: () => {
// Clear tokens (from cookies)
deleteCookie('access_token');
deleteCookie('refresh_token');
// Clear user cache
queryClient.removeQueries({ queryKey: ['currentUser'] });
queryClient.clear();
// Redirect to login page
window.location.href = '/login';
},
});
};
/**
* Check if user is authenticated
*/
export const useIsAuthenticated = (): boolean => {
const { data: user, isLoading } = useCurrentUser();
return !isLoading && !!user;
};
/**
* Hook to masquerade as another user
*/
export const useMasquerade = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (username: string) => {
// Get current masquerading stack from localStorage
const stackJson = localStorage.getItem('masquerade_stack');
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
// Call masquerade API with current stack
return masquerade(username, currentStack);
},
onSuccess: async (data) => {
// Store the updated masquerading stack
if (data.masquerade_stack) {
localStorage.setItem('masquerade_stack', JSON.stringify(data.masquerade_stack));
}
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
let targetSubdomain: string | null = null;
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform';
} else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.lvh.me`;
if (needsRedirect) {
// CRITICAL: Clear the session cookie BEFORE redirect
// Call logout API to clear HttpOnly sessionid cookie
try {
await fetch('http://api.lvh.me:8000/api/auth/logout/', {
method: 'POST',
credentials: 'include',
});
} catch (e) {
// Continue anyway
}
const portStr = currentPort ? `:${currentPort}` : '';
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
const redirectUrl = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`;
window.location.href = redirectUrl;
return;
}
// If no redirect needed (same subdomain), we can just set cookies and reload
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
queryClient.setQueryData(['currentUser'], data.user);
window.location.reload();
},
});
};
/**
* Hook to stop masquerading and return to previous user
*/
export const useStopMasquerade = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
// Get current masquerading stack from localStorage
const stackJson = localStorage.getItem('masquerade_stack');
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
if (currentStack.length === 0) {
throw new Error('No masquerading session to stop');
}
// Call stop_masquerade API with current stack
return stopMasquerade(currentStack);
},
onSuccess: async (data) => {
// Update the masquerading stack
if (data.masquerade_stack && data.masquerade_stack.length > 0) {
localStorage.setItem('masquerade_stack', JSON.stringify(data.masquerade_stack));
} else {
// Clear the stack if empty
localStorage.removeItem('masquerade_stack');
}
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
let targetSubdomain: string | null = null;
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform';
} else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.lvh.me`;
if (needsRedirect) {
// CRITICAL: Clear the session cookie BEFORE redirect
try {
await fetch('http://api.lvh.me:8000/api/auth/logout/', {
method: 'POST',
credentials: 'include',
});
} catch (e) {
// Continue anyway
}
const portStr = currentPort ? `:${currentPort}` : '';
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
const redirectUrl = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`;
window.location.href = redirectUrl;
return;
}
// If no redirect needed (same subdomain), we can just set cookies and reload
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
queryClient.setQueryData(['currentUser'], data.user);
window.location.reload();
},
});
};

View File

@@ -0,0 +1,144 @@
/**
* Business Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Business } from '../types';
import { getCookie } from '../utils/cookies';
/**
* Hook to get current business
*/
export const useCurrentBusiness = () => {
// Check token outside the query to use as dependency
const token = getCookie('access_token');
return useQuery<Business | null>({
queryKey: ['currentBusiness', !!token], // Include token presence in query key to refetch when token changes
queryFn: async () => {
// Check if token exists before making request (from cookie)
const currentToken = getCookie('access_token');
if (!currentToken) {
return null; // No token, return null instead of making request
}
const { data } = await apiClient.get('/api/business/current/');
// Transform backend format to frontend format
return {
id: String(data.id),
name: data.name,
subdomain: data.subdomain,
primaryColor: data.primary_color,
secondaryColor: data.secondary_color,
logoUrl: data.logo_url,
whitelabelEnabled: data.whitelabel_enabled,
plan: data.tier, // Map tier to plan
status: data.status,
joinedAt: data.created_at ? new Date(data.created_at) : undefined,
resourcesCanReschedule: data.resources_can_reschedule,
requirePaymentMethodToBook: data.require_payment_method_to_book,
cancellationWindowHours: data.cancellation_window_hours,
lateCancellationFeePercent: data.late_cancellation_fee_percent,
initialSetupComplete: data.initial_setup_complete,
websitePages: data.website_pages || {},
customerDashboardContent: data.customer_dashboard_content || [],
};
},
});
};
/**
* Hook to update business settings
*/
export const useUpdateBusiness = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updates: Partial<Business>) => {
const backendData: any = {};
// Map frontend fields to backend fields
if (updates.name) backendData.name = updates.name;
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl;
if (updates.whitelabelEnabled !== undefined) {
backendData.whitelabel_enabled = updates.whitelabelEnabled;
}
if (updates.resourcesCanReschedule !== undefined) {
backendData.resources_can_reschedule = updates.resourcesCanReschedule;
}
if (updates.requirePaymentMethodToBook !== undefined) {
backendData.require_payment_method_to_book = updates.requirePaymentMethodToBook;
}
if (updates.cancellationWindowHours !== undefined) {
backendData.cancellation_window_hours = updates.cancellationWindowHours;
}
if (updates.lateCancellationFeePercent !== undefined) {
backendData.late_cancellation_fee_percent = updates.lateCancellationFeePercent;
}
if (updates.initialSetupComplete !== undefined) {
backendData.initial_setup_complete = updates.initialSetupComplete;
}
if (updates.websitePages !== undefined) {
backendData.website_pages = updates.websitePages;
}
if (updates.customerDashboardContent !== undefined) {
backendData.customer_dashboard_content = updates.customerDashboardContent;
}
const { data } = await apiClient.patch('/api/business/current/update/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentBusiness'] });
},
});
};
/**
* Hook to get all resources for the current business
*/
export const useResources = () => {
return useQuery({
queryKey: ['resources'],
queryFn: async () => {
const { data } = await apiClient.get('/api/resources/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to create a new resource
*/
export const useCreateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (resourceData: { name: string; type: string; user_id?: string }) => {
const { data } = await apiClient.post('/api/resources/', resourceData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to get all users for the current business
*/
export const useBusinessUsers = () => {
return useQuery({
queryKey: ['businessUsers'],
queryFn: async () => {
const { data } = await apiClient.get('/api/business/users/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};

View File

@@ -0,0 +1,34 @@
/**
* Business OAuth Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getBusinessOAuthSettings, updateBusinessOAuthSettings } from '../api/business';
import { BusinessOAuthSettings, BusinessOAuthSettingsResponse } from '../types';
/**
* Hook to get business OAuth settings and available providers
*/
export const useBusinessOAuthSettings = () => {
return useQuery<BusinessOAuthSettingsResponse>({
queryKey: ['businessOAuthSettings'],
queryFn: getBusinessOAuthSettings,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update business OAuth settings
*/
export const useUpdateBusinessOAuthSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: Partial<BusinessOAuthSettings>) =>
updateBusinessOAuthSettings(settings),
onSuccess: (data) => {
// Update the cached data
queryClient.setQueryData(['businessOAuthSettings'], data);
},
});
};

View File

@@ -0,0 +1,32 @@
/**
* React Query hooks for managing business OAuth credentials
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getBusinessOAuthCredentials, updateBusinessOAuthCredentials } from '../api/business';
import { BusinessOAuthCredentials } from '../types';
/**
* Fetch business OAuth credentials
*/
export const useBusinessOAuthCredentials = () => {
return useQuery({
queryKey: ['businessOAuthCredentials'],
queryFn: getBusinessOAuthCredentials,
});
};
/**
* Update business OAuth credentials
*/
export const useUpdateBusinessOAuthCredentials = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (credentials: Partial<BusinessOAuthCredentials>) =>
updateBusinessOAuthCredentials(credentials),
onSuccess: (data) => {
queryClient.setQueryData(['businessOAuthCredentials'], data);
},
});
};

View File

@@ -0,0 +1,83 @@
/**
* React Query hooks for custom domain management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getCustomDomains,
addCustomDomain,
deleteCustomDomain,
verifyCustomDomain,
setPrimaryDomain,
} from '../api/customDomains';
import { CustomDomain } from '../types';
/**
* Hook to fetch all custom domains for the current business
*/
export const useCustomDomains = () => {
return useQuery<CustomDomain[], Error>({
queryKey: ['customDomains'],
queryFn: getCustomDomains,
});
};
/**
* Hook to add a new custom domain
*/
export const useAddCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<CustomDomain, Error, string>({
mutationFn: addCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to delete a custom domain
*/
export const useDeleteCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: deleteCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to verify a custom domain
*/
export const useVerifyCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<{ verified: boolean; message: string }, Error, number>({
mutationFn: verifyCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to set a custom domain as primary
*/
export const useSetPrimaryDomain = () => {
const queryClient = useQueryClient();
return useMutation<CustomDomain, Error, number>({
mutationFn: setPrimaryDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};

View File

@@ -0,0 +1,118 @@
/**
* Customer Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Customer } from '../types';
interface CustomerFilters {
status?: 'Active' | 'Inactive' | 'Blocked';
search?: string;
}
/**
* Hook to fetch customers with optional filters
*/
export const useCustomers = (filters?: CustomerFilters) => {
return useQuery<Customer[]>({
queryKey: ['customers', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.search) params.append('search', filters.search);
const { data } = await apiClient.get(`/api/customers/?${params}`);
// Transform backend format to frontend format
return data.map((c: any) => ({
id: String(c.id),
name: c.name || c.user?.name || '',
email: c.email || c.user?.email || '',
phone: c.phone || '',
city: c.city,
state: c.state,
zip: c.zip,
totalSpend: parseFloat(c.total_spend || 0),
lastVisit: c.last_visit ? new Date(c.last_visit) : null,
status: c.status,
avatarUrl: c.avatar_url,
tags: c.tags || [],
userId: String(c.user_id || c.user),
paymentMethods: [], // Will be populated when payment feature is implemented
user_data: c.user_data, // Include user_data for masquerading
}));
},
});
};
/**
* Hook to create a customer
*/
export const useCreateCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (customerData: Partial<Customer>) => {
const backendData = {
user: customerData.userId ? parseInt(customerData.userId) : undefined,
phone: customerData.phone,
city: customerData.city,
state: customerData.state,
zip: customerData.zip,
status: customerData.status,
avatar_url: customerData.avatarUrl,
tags: customerData.tags,
};
const { data } = await apiClient.post('/api/customers/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};
/**
* Hook to update a customer
*/
export const useUpdateCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Customer> }) => {
const backendData = {
phone: updates.phone,
city: updates.city,
state: updates.state,
zip: updates.zip,
status: updates.status,
avatar_url: updates.avatarUrl,
tags: updates.tags,
};
const { data } = await apiClient.patch(`/api/customers/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};
/**
* Hook to delete a customer
*/
export const useDeleteCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/customers/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};

View File

@@ -0,0 +1,190 @@
/**
* Domain Management Hooks
* React Query hooks for NameSilo domain integration
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as domainsApi from '../api/domains';
import type {
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
} from '../api/domains';
// Query keys
const domainKeys = {
all: ['domains'] as const,
prices: () => [...domainKeys.all, 'prices'] as const,
registrations: () => [...domainKeys.all, 'registrations'] as const,
registration: (id: number) => [...domainKeys.registrations(), id] as const,
history: () => [...domainKeys.all, 'history'] as const,
search: (query: string, tlds: string[]) => [...domainKeys.all, 'search', query, tlds] as const,
};
// ============================================
// Search & Pricing
// ============================================
/**
* Hook to search for domain availability
*/
export const useDomainSearch = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ query, tlds }: { query: string; tlds?: string[] }) =>
domainsApi.searchDomains(query, tlds),
onSuccess: () => {
// Invalidate search history since new search was added
queryClient.invalidateQueries({ queryKey: domainKeys.history() });
},
});
};
/**
* Hook to get TLD pricing
*/
export const useDomainPrices = () => {
return useQuery({
queryKey: domainKeys.prices(),
queryFn: domainsApi.getDomainPrices,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
// ============================================
// Registration
// ============================================
/**
* Hook to register a new domain
*/
export const useRegisterDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: DomainRegisterRequest) => domainsApi.registerDomain(data),
onSuccess: () => {
// Invalidate registrations list
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
// Also invalidate custom domains since we auto-configure
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to get all registered domains
*/
export const useRegisteredDomains = () => {
return useQuery({
queryKey: domainKeys.registrations(),
queryFn: domainsApi.getRegisteredDomains,
staleTime: 30 * 1000, // 30 seconds
});
};
/**
* Hook to get a single domain registration
*/
export const useDomainRegistration = (id: number) => {
return useQuery({
queryKey: domainKeys.registration(id),
queryFn: () => domainsApi.getDomainRegistration(id),
enabled: !!id,
});
};
// ============================================
// Domain Management
// ============================================
/**
* Hook to update nameservers
*/
export const useUpdateNameservers = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, nameservers }: { id: number; nameservers: string[] }) =>
domainsApi.updateNameservers(id, nameservers),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to toggle auto-renewal
*/
export const useToggleAutoRenew = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, autoRenew }: { id: number; autoRenew: boolean }) =>
domainsApi.toggleAutoRenew(id, autoRenew),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to renew a domain
*/
export const useRenewDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, years }: { id: number; years?: number }) =>
domainsApi.renewDomain(id, years),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to sync domain info from NameSilo
*/
export const useSyncDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => domainsApi.syncDomain(id),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
// ============================================
// History
// ============================================
/**
* Hook to get search history
*/
export const useSearchHistory = () => {
return useQuery({
queryKey: domainKeys.history(),
queryFn: domainsApi.getSearchHistory,
staleTime: 60 * 1000, // 1 minute
});
};
// Re-export types for convenience
export type {
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
RegistrantContact,
} from '../api/domains';

View File

@@ -0,0 +1,104 @@
/**
* OAuth Hooks
* React Query hooks for OAuth authentication flows
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
getOAuthProviders,
getOAuthConnections,
initiateOAuth,
handleOAuthCallback,
disconnectOAuth,
OAuthProvider,
OAuthConnection,
OAuthTokenResponse,
} from '../api/oauth';
import { setCookie } from '../utils/cookies';
/**
* Hook to get list of enabled OAuth providers
*/
export const useOAuthProviders = () => {
return useQuery<OAuthProvider[], Error>({
queryKey: ['oauthProviders'],
queryFn: getOAuthProviders,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to get user's connected OAuth accounts
*/
export const useOAuthConnections = () => {
return useQuery<OAuthConnection[], Error>({
queryKey: ['oauthConnections'],
queryFn: getOAuthConnections,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to initiate OAuth flow
*/
export const useInitiateOAuth = () => {
return useMutation({
mutationFn: async (provider: string) => {
const response = await initiateOAuth(provider);
return { provider, authorizationUrl: response.authorization_url };
},
onSuccess: ({ authorizationUrl }) => {
// Open OAuth authorization URL in current window
window.location.href = authorizationUrl;
},
});
};
/**
* Hook to handle OAuth callback and exchange code for tokens
*/
export const useOAuthCallback = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
provider,
code,
state,
}: {
provider: string;
code: string;
state: string;
}) => {
return handleOAuthCallback(provider, code, state);
},
onSuccess: (data: OAuthTokenResponse) => {
// Store tokens in cookies
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
// Set user in cache
queryClient.setQueryData(['currentUser'], data.user);
// Invalidate OAuth connections to refetch
queryClient.invalidateQueries({ queryKey: ['oauthConnections'] });
},
});
};
/**
* Hook to disconnect OAuth account
*/
export const useDisconnectOAuth = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: disconnectOAuth,
onSuccess: () => {
// Invalidate connections list to refetch
queryClient.invalidateQueries({ queryKey: ['oauthConnections'] });
},
});
};

View File

@@ -0,0 +1,154 @@
/**
* Payment Hooks
* React Query hooks for payment configuration management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as paymentsApi from '../api/payments';
// ============================================================================
// Query Keys
// ============================================================================
export const paymentKeys = {
all: ['payments'] as const,
config: () => [...paymentKeys.all, 'config'] as const,
apiKeys: () => [...paymentKeys.all, 'apiKeys'] as const,
connectStatus: () => [...paymentKeys.all, 'connectStatus'] as const,
};
// ============================================================================
// Unified Configuration Hook
// ============================================================================
/**
* Get unified payment configuration status.
* Returns the complete payment setup for the business.
*/
export const usePaymentConfig = () => {
return useQuery({
queryKey: paymentKeys.config(),
queryFn: () => paymentsApi.getPaymentConfig().then(res => res.data),
staleTime: 30 * 1000, // 30 seconds
});
};
// ============================================================================
// API Keys Hooks (Free Tier)
// ============================================================================
/**
* Get current API key configuration (masked).
*/
export const useApiKeys = () => {
return useQuery({
queryKey: paymentKeys.apiKeys(),
queryFn: () => paymentsApi.getApiKeys().then(res => res.data),
staleTime: 30 * 1000,
});
};
/**
* Validate API keys without saving.
*/
export const useValidateApiKeys = () => {
return useMutation({
mutationFn: ({ secretKey, publishableKey }: { secretKey: string; publishableKey: string }) =>
paymentsApi.validateApiKeys(secretKey, publishableKey).then(res => res.data),
});
};
/**
* Save API keys.
*/
export const useSaveApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ secretKey, publishableKey }: { secretKey: string; publishableKey: string }) =>
paymentsApi.saveApiKeys(secretKey, publishableKey).then(res => res.data),
onSuccess: () => {
// Invalidate payment config to refresh status
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
/**
* Re-validate stored API keys.
*/
export const useRevalidateApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => paymentsApi.revalidateApiKeys().then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
/**
* Delete stored API keys.
*/
export const useDeleteApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => paymentsApi.deleteApiKeys().then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
// ============================================================================
// Stripe Connect Hooks (Paid Tiers)
// ============================================================================
/**
* Get current Connect account status.
*/
export const useConnectStatus = () => {
return useQuery({
queryKey: paymentKeys.connectStatus(),
queryFn: () => paymentsApi.getConnectStatus().then(res => res.data),
staleTime: 30 * 1000,
// Only fetch if we might have a Connect account
enabled: true,
});
};
/**
* Initiate Connect account onboarding.
*/
export const useConnectOnboarding = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ refreshUrl, returnUrl }: { refreshUrl: string; returnUrl: string }) =>
paymentsApi.initiateConnectOnboarding(refreshUrl, returnUrl).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.connectStatus() });
},
});
};
/**
* Refresh Connect onboarding link.
*/
export const useRefreshConnectLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ refreshUrl, returnUrl }: { refreshUrl: string; returnUrl: string }) =>
paymentsApi.refreshConnectOnboardingLink(refreshUrl, returnUrl).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.connectStatus() });
},
});
};

View File

@@ -0,0 +1,41 @@
/**
* Platform Hooks
* React Query hooks for platform-level operations
*/
import { useQuery } from '@tanstack/react-query';
import { getBusinesses, getUsers, getBusinessUsers } from '../api/platform';
/**
* Hook to get all businesses (platform admin only)
*/
export const useBusinesses = () => {
return useQuery({
queryKey: ['platform', 'businesses'],
queryFn: getBusinesses,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to get all users (platform admin only)
*/
export const usePlatformUsers = () => {
return useQuery({
queryKey: ['platform', 'users'],
queryFn: getUsers,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to get users for a specific business
*/
export const useBusinessUsers = (businessId: number | null) => {
return useQuery({
queryKey: ['platform', 'business-users', businessId],
queryFn: () => getBusinessUsers(businessId!),
enabled: !!businessId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};

View File

@@ -0,0 +1,36 @@
/**
* Platform OAuth Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getPlatformOAuthSettings,
updatePlatformOAuthSettings,
PlatformOAuthSettings,
PlatformOAuthSettingsUpdate,
} from '../api/platformOAuth';
/**
* Hook to get platform OAuth settings
*/
export const usePlatformOAuthSettings = () => {
return useQuery<PlatformOAuthSettings>({
queryKey: ['platformOAuthSettings'],
queryFn: getPlatformOAuthSettings,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update platform OAuth settings
*/
export const useUpdatePlatformOAuthSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updatePlatformOAuthSettings,
onSuccess: (data) => {
queryClient.setQueryData(['platformOAuthSettings'], data);
},
});
};

View File

@@ -0,0 +1,193 @@
/**
* Platform Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface PlatformSettings {
stripe_secret_key_masked: string;
stripe_publishable_key_masked: string;
stripe_webhook_secret_masked: string;
stripe_account_id: string;
stripe_account_name: string;
stripe_keys_validated_at: string | null;
stripe_validation_error: string;
has_stripe_keys: boolean;
stripe_keys_from_env: boolean;
updated_at: string;
}
export interface StripeKeysUpdate {
stripe_secret_key?: string;
stripe_publishable_key?: string;
stripe_webhook_secret?: string;
}
export interface SubscriptionPlan {
id: number;
name: string;
description: string;
plan_type: 'base' | 'addon';
stripe_product_id: string;
stripe_price_id: string;
price_monthly: string | null;
price_yearly: string | null;
business_tier: string;
features: string[];
transaction_fee_percent: string;
transaction_fee_fixed: string;
is_active: boolean;
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface SubscriptionPlanCreate {
name: string;
description?: string;
plan_type?: 'base' | 'addon';
price_monthly?: number | null;
price_yearly?: number | null;
business_tier?: string;
features?: string[];
transaction_fee_percent?: number;
transaction_fee_fixed?: number;
is_active?: boolean;
is_public?: boolean;
create_stripe_product?: boolean;
stripe_product_id?: string;
stripe_price_id?: string;
}
/**
* Hook to get platform settings
*/
export const usePlatformSettings = () => {
return useQuery<PlatformSettings>({
queryKey: ['platformSettings'],
queryFn: async () => {
const { data } = await apiClient.get('/api/platform/settings/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update platform Stripe keys
*/
export const useUpdateStripeKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (keys: StripeKeysUpdate) => {
const { data } = await apiClient.post('/api/platform/settings/stripe/keys/', keys);
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(['platformSettings'], data);
},
});
};
/**
* Hook to validate platform Stripe keys
*/
export const useValidateStripeKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post('/api/platform/settings/stripe/validate/');
return data;
},
onSuccess: (data) => {
if (data.settings) {
queryClient.setQueryData(['platformSettings'], data.settings);
}
},
});
};
/**
* Hook to get subscription plans
*/
export const useSubscriptionPlans = () => {
return useQuery<SubscriptionPlan[]>({
queryKey: ['subscriptionPlans'],
queryFn: async () => {
const { data } = await apiClient.get('/api/platform/subscription-plans/');
return data;
},
staleTime: 5 * 60 * 1000,
});
};
/**
* Hook to create a subscription plan
*/
export const useCreateSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (plan: SubscriptionPlanCreate) => {
const { data } = await apiClient.post('/api/platform/subscription-plans/', plan);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to update a subscription plan
*/
export const useUpdateSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }: Partial<SubscriptionPlan> & { id: number }) => {
const { data } = await apiClient.patch(`/api/platform/subscription-plans/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to delete a subscription plan
*/
export const useDeleteSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const { data } = await apiClient.delete(`/api/platform/subscription-plans/${id}/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to sync plans with Stripe
*/
export const useSyncPlansWithStripe = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post('/api/platform/subscription-plans/sync_with_stripe/');
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};

View File

@@ -0,0 +1,248 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as profileApi from '../api/profile';
// Profile hooks
export const useProfile = () => {
return useQuery({
queryKey: ['profile'],
queryFn: profileApi.getProfile,
});
};
export const useUpdateProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.updateProfile,
onSuccess: (data) => {
queryClient.setQueryData(['profile'], data);
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useUploadAvatar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.uploadAvatar,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useDeleteAvatar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.deleteAvatar,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Email hooks
export const useSendVerificationEmail = () => {
return useMutation({
mutationFn: profileApi.sendVerificationEmail,
});
};
export const useVerifyEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useRequestEmailChange = () => {
return useMutation({
mutationFn: profileApi.requestEmailChange,
});
};
export const useConfirmEmailChange = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.confirmEmailChange,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Password hooks
export const useChangePassword = () => {
return useMutation({
mutationFn: ({
currentPassword,
newPassword,
}: {
currentPassword: string;
newPassword: string;
}) => profileApi.changePassword(currentPassword, newPassword),
});
};
// 2FA hooks
export const useSetupTOTP = () => {
return useMutation({
mutationFn: profileApi.setupTOTP,
});
};
export const useVerifyTOTP = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyTOTP,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useDisableTOTP = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.disableTOTP,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useRecoveryCodes = () => {
return useQuery({
queryKey: ['recoveryCodes'],
queryFn: profileApi.getRecoveryCodes,
enabled: false, // Only fetch on demand
});
};
export const useRegenerateRecoveryCodes = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.regenerateRecoveryCodes,
onSuccess: (codes) => {
queryClient.setQueryData(['recoveryCodes'], codes);
},
});
};
// Phone verification hooks
export const useSendPhoneVerification = () => {
return useMutation({
mutationFn: profileApi.sendPhoneVerification,
});
};
export const useVerifyPhoneCode = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyPhoneCode,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Session hooks
export const useSessions = () => {
return useQuery({
queryKey: ['sessions'],
queryFn: profileApi.getSessions,
});
};
export const useRevokeSession = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.revokeSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
};
export const useRevokeOtherSessions = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.revokeOtherSessions,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
};
export const useLoginHistory = () => {
return useQuery({
queryKey: ['loginHistory'],
queryFn: profileApi.getLoginHistory,
});
};
// Multiple email hooks
export const useUserEmails = () => {
return useQuery({
queryKey: ['userEmails'],
queryFn: profileApi.getUserEmails,
});
};
export const useAddUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.addUserEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useDeleteUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.deleteUserEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useSendUserEmailVerification = () => {
return useMutation({
mutationFn: profileApi.sendUserEmailVerification,
});
};
export const useVerifyUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ emailId, token }: { emailId: number; token: string }) =>
profileApi.verifyUserEmail(emailId, token),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useSetPrimaryEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.setPrimaryEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};

View File

@@ -0,0 +1,118 @@
/**
* Resource Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Resource, ResourceType } from '../types';
interface ResourceFilters {
type?: ResourceType;
}
/**
* Hook to fetch resources with optional type filter
*/
export const useResources = (filters?: ResourceFilters) => {
return useQuery<Resource[]>({
queryKey: ['resources', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.type) params.append('type', filters.type);
const { data } = await apiClient.get(`/api/resources/?${params}`);
// Transform backend format to frontend format
return data.map((r: any) => ({
id: String(r.id),
name: r.name,
type: r.type as ResourceType,
userId: r.user_id ? String(r.user_id) : undefined,
}));
},
});
};
/**
* Hook to get a single resource
*/
export const useResource = (id: string) => {
return useQuery<Resource>({
queryKey: ['resources', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/resources/${id}/`);
return {
id: String(data.id),
name: data.name,
type: data.type as ResourceType,
userId: data.user_id ? String(data.user_id) : undefined,
};
},
enabled: !!id,
});
};
/**
* Hook to create a resource
*/
export const useCreateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (resourceData: Omit<Resource, 'id'>) => {
const backendData = {
name: resourceData.name,
type: resourceData.type,
user: resourceData.userId ? parseInt(resourceData.userId) : null,
timezone: 'UTC', // Default timezone
};
const { data } = await apiClient.post('/api/resources/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to update a resource
*/
export const useUpdateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Resource> }) => {
const backendData: any = {};
if (updates.name) backendData.name = updates.name;
if (updates.type) backendData.type = updates.type;
if (updates.userId !== undefined) {
backendData.user = updates.userId ? parseInt(updates.userId) : null;
}
const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to delete a resource
*/
export const useDeleteResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/resources/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
/**
* Hook to scroll to top on route changes
* Should be used in layout components to ensure scroll restoration
* works consistently across all routes
*/
export function useScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
}

View File

@@ -0,0 +1,112 @@
/**
* Service Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Service } from '../types';
/**
* Hook to fetch all services for current business
*/
export const useServices = () => {
return useQuery<Service[]>({
queryKey: ['services'],
queryFn: async () => {
const { data } = await apiClient.get('/api/services/');
// Transform backend format to frontend format
return data.map((s: any) => ({
id: String(s.id),
name: s.name,
durationMinutes: s.duration || s.duration_minutes,
price: parseFloat(s.price),
description: s.description || '',
}));
},
});
};
/**
* Hook to get a single service
*/
export const useService = (id: string) => {
return useQuery<Service>({
queryKey: ['services', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/services/${id}/`);
return {
id: String(data.id),
name: data.name,
durationMinutes: data.duration || data.duration_minutes,
price: parseFloat(data.price),
description: data.description || '',
};
},
enabled: !!id,
});
};
/**
* Hook to create a service
*/
export const useCreateService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (serviceData: Omit<Service, 'id'>) => {
const backendData = {
name: serviceData.name,
duration: serviceData.durationMinutes,
price: serviceData.price.toString(),
description: serviceData.description,
};
const { data } = await apiClient.post('/api/services/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to update a service
*/
export const useUpdateService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Service> }) => {
const backendData: any = {};
if (updates.name) backendData.name = updates.name;
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
if (updates.price) backendData.price = updates.price.toString();
if (updates.description !== undefined) backendData.description = updates.description;
const { data } = await apiClient.patch(`/api/services/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to delete a service
*/
export const useDeleteService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/services/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};

View File

@@ -0,0 +1,197 @@
/**
* Transaction Analytics Hooks
*
* React Query hooks for fetching and managing transaction analytics data.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getTransactions,
getTransaction,
getTransactionSummary,
getStripeCharges,
getStripePayouts,
getStripeBalance,
exportTransactions,
getTransactionDetail,
refundTransaction,
TransactionFilters,
ExportRequest,
RefundRequest,
} from '../api/payments';
/**
* Hook to fetch paginated transaction list with optional filters.
*/
export const useTransactions = (filters?: TransactionFilters) => {
return useQuery({
queryKey: ['transactions', filters],
queryFn: async () => {
const { data } = await getTransactions(filters);
return data;
},
staleTime: 30 * 1000, // 30 seconds
});
};
/**
* Hook to fetch a single transaction by ID.
*/
export const useTransaction = (id: number) => {
return useQuery({
queryKey: ['transaction', id],
queryFn: async () => {
const { data } = await getTransaction(id);
return data;
},
enabled: !!id,
});
};
/**
* Hook to fetch transaction summary/analytics.
*/
export const useTransactionSummary = (filters?: Pick<TransactionFilters, 'start_date' | 'end_date'>) => {
return useQuery({
queryKey: ['transactionSummary', filters],
queryFn: async () => {
const { data } = await getTransactionSummary(filters);
return data;
},
staleTime: 60 * 1000, // 1 minute
});
};
/**
* Hook to fetch Stripe charges directly from Stripe API.
*/
export const useStripeCharges = (limit: number = 20) => {
return useQuery({
queryKey: ['stripeCharges', limit],
queryFn: async () => {
const { data } = await getStripeCharges(limit);
return data;
},
staleTime: 30 * 1000,
});
};
/**
* Hook to fetch Stripe payouts.
*/
export const useStripePayouts = (limit: number = 20) => {
return useQuery({
queryKey: ['stripePayouts', limit],
queryFn: async () => {
const { data } = await getStripePayouts(limit);
return data;
},
staleTime: 30 * 1000,
});
};
/**
* Hook to fetch current Stripe balance.
*/
export const useStripeBalance = () => {
return useQuery({
queryKey: ['stripeBalance'],
queryFn: async () => {
const { data } = await getStripeBalance();
return data;
},
staleTime: 60 * 1000, // 1 minute
refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes
});
};
/**
* Hook to export transaction data.
* Returns a mutation that triggers file download.
*/
export const useExportTransactions = () => {
return useMutation({
mutationFn: async (request: ExportRequest) => {
const response = await exportTransactions(request);
return response;
},
onSuccess: (response, request) => {
// Create blob URL and trigger download
const blob = new Blob([response.data], { type: response.headers['content-type'] });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Determine file extension based on format
const extensions: Record<string, string> = {
csv: 'csv',
xlsx: 'xlsx',
pdf: 'pdf',
quickbooks: 'iif',
};
const ext = extensions[request.format] || 'txt';
link.download = `transactions.${ext}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
});
};
/**
* Hook to invalidate all transaction-related queries.
* Useful after actions that modify transaction data.
*/
export const useInvalidateTransactions = () => {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['transactionSummary'] });
queryClient.invalidateQueries({ queryKey: ['stripeCharges'] });
queryClient.invalidateQueries({ queryKey: ['stripePayouts'] });
queryClient.invalidateQueries({ queryKey: ['stripeBalance'] });
queryClient.invalidateQueries({ queryKey: ['transactionDetail'] });
};
};
/**
* Hook to fetch detailed transaction information including refund data.
*/
export const useTransactionDetail = (id: number | null) => {
return useQuery({
queryKey: ['transactionDetail', id],
queryFn: async () => {
if (!id) return null;
const { data } = await getTransactionDetail(id);
return data;
},
enabled: !!id,
staleTime: 10 * 1000, // 10 seconds (refresh often for live data)
});
};
/**
* Hook to issue a refund for a transaction.
* Automatically invalidates transaction queries on success.
*/
export const useRefundTransaction = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ transactionId, request }: { transactionId: number; request?: RefundRequest }) => {
const { data } = await refundTransaction(transactionId, request);
return data;
},
onSuccess: (data, variables) => {
// Invalidate all relevant queries
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['transactionSummary'] });
queryClient.invalidateQueries({ queryKey: ['transactionDetail', variables.transactionId] });
queryClient.invalidateQueries({ queryKey: ['stripeCharges'] });
queryClient.invalidateQueries({ queryKey: ['stripeBalance'] });
},
});
};

View File

@@ -0,0 +1,208 @@
/**
* WebSocket hook for real-time user notifications.
* Connects to the backend WebSocket and updates React Query cache.
*/
import { useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCookie } from '../utils/cookies';
import { UserEmail } from '../api/profile';
interface WebSocketMessage {
type: 'connection_established' | 'email_verified' | 'profile_updated' | 'pong';
email_id?: number;
email?: string;
user_id?: string;
message?: string;
fields?: string[];
}
interface UseUserNotificationsOptions {
enabled?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Event) => void;
onEmailVerified?: (emailId: number, email: string) => void;
}
/**
* Hook for real-time user notifications via WebSocket.
*/
export function useUserNotifications(options: UseUserNotificationsOptions = {}) {
const { enabled = true, onConnected, onDisconnected, onError, onEmailVerified } = options;
const queryClient = useQueryClient();
// Use refs for callbacks to avoid recreating connect function
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
const onEmailVerifiedRef = useRef(onEmailVerified);
// Update refs when callbacks change
useEffect(() => {
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
onEmailVerifiedRef.current = onEmailVerified;
}, [onConnected, onDisconnected, onError, onEmailVerified]);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
const maxReconnectAttempts = 5;
const updateEmailQueryCache = useCallback((emailId: number) => {
// Update the userEmails query cache to mark the email as verified
queryClient.setQueryData<UserEmail[]>(['userEmails'], (old) => {
if (!old) return old;
return old.map((email) =>
email.id === emailId ? { ...email, verified: true } : email
);
});
}, [queryClient]);
const disconnect = useCallback(() => {
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
isConnectingRef.current = false;
}, []);
const connect = useCallback(() => {
// Prevent multiple simultaneous connection attempts
if (isConnectingRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
const token = getCookie('access_token');
if (!token) {
console.log('UserNotifications WebSocket: Missing token, skipping connection');
return;
}
isConnectingRef.current = true;
// Close existing connection if any
if (wsRef.current) {
wsRef.current.close();
}
// Determine WebSocket host - use api subdomain for WebSocket
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
const url = `${wsProtocol}//${wsHost}/ws/user/?token=${token}`;
console.log('UserNotifications WebSocket: Connecting');
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('UserNotifications WebSocket: Connected');
reconnectAttemptsRef.current = 0;
isConnectingRef.current = false;
onConnectedRef.current?.();
// Start ping interval to keep connection alive
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Ping every 30 seconds
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'connection_established':
console.log('UserNotifications WebSocket: Connection confirmed -', message.message);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'email_verified':
console.log('UserNotifications WebSocket: Email verified', message.email);
if (message.email_id) {
updateEmailQueryCache(message.email_id);
onEmailVerifiedRef.current?.(message.email_id, message.email || '');
}
break;
case 'profile_updated':
console.log('UserNotifications WebSocket: Profile updated', message.fields);
// Invalidate profile queries to refresh data
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
break;
default:
console.log('UserNotifications WebSocket: Unknown message type', message);
}
} catch (err) {
console.error('UserNotifications WebSocket: Failed to parse message', err);
}
};
ws.onerror = (error) => {
console.error('UserNotifications WebSocket: Error', error);
isConnectingRef.current = false;
onErrorRef.current?.(error);
};
ws.onclose = (event) => {
console.log('UserNotifications WebSocket: Disconnected', event.code, event.reason);
isConnectingRef.current = false;
onDisconnectedRef.current?.();
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Only attempt reconnection if this wasn't a deliberate close
// Code 1000 = normal closure, 1001 = going away (page unload)
if (event.code !== 1000 && event.code !== 1001 && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`UserNotifications WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
wsRef.current = ws;
}, [queryClient, updateEmailQueryCache]);
useEffect(() => {
if (enabled) {
connect();
}
return () => {
disconnect();
};
}, [enabled, connect, disconnect]);
return {
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
reconnect: connect,
disconnect,
};
}

View File

@@ -0,0 +1,62 @@
/**
* i18n Configuration
* Internationalization setup using react-i18next
*/
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translation files
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'];
const resources = {
en: { translation: en },
es: { translation: es },
fr: { translation: fr },
de: { translation: de },
pt: { translation: pt },
ja: { translation: ja },
zh: { translation: zh },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
debug: false, // Disable debug logging
interpolation: {
escapeValue: false, // React already escapes values
},
detection: {
// Order of language detection
order: ['localStorage', 'navigator', 'htmlTag'],
// Cache user language preference
caches: ['localStorage'],
lookupLocalStorage: 'smoothschedule_language',
},
});
export default i18n;

View File

@@ -0,0 +1,688 @@
{
"common": {
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg",
"save": "Speichern",
"saveChanges": "Änderungen speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"update": "Aktualisieren",
"close": "Schließen",
"confirm": "Bestätigen",
"back": "Zurück",
"next": "Weiter",
"search": "Suchen",
"filter": "Filtern",
"actions": "Aktionen",
"settings": "Einstellungen",
"reload": "Neu laden",
"viewAll": "Alle Anzeigen",
"learnMore": "Mehr Erfahren",
"poweredBy": "Bereitgestellt von",
"required": "Erforderlich",
"optional": "Optional",
"masquerade": "Als Benutzer agieren",
"masqueradeAsUser": "Als Benutzer agieren"
},
"auth": {
"signIn": "Anmelden",
"signOut": "Abmelden",
"signingIn": "Anmeldung läuft...",
"username": "Benutzername",
"password": "Passwort",
"enterUsername": "Geben Sie Ihren Benutzernamen ein",
"enterPassword": "Geben Sie Ihr Passwort ein",
"welcomeBack": "Willkommen zurück",
"pleaseEnterDetails": "Bitte geben Sie Ihre Daten ein, um sich anzumelden.",
"authError": "Authentifizierungsfehler",
"invalidCredentials": "Ungültige Anmeldedaten",
"orContinueWith": "Oder fortfahren mit",
"loginAtSubdomain": "Bitte melden Sie sich bei Ihrer Geschäfts-Subdomain an. Mitarbeiter und Kunden können sich nicht von der Hauptseite aus anmelden.",
"forgotPassword": "Passwort vergessen?",
"rememberMe": "Angemeldet bleiben",
"twoFactorRequired": "Zwei-Faktor-Authentifizierung erforderlich",
"enterCode": "Bestätigungscode eingeben",
"verifyCode": "Code Bestätigen"
},
"nav": {
"dashboard": "Dashboard",
"scheduler": "Terminplaner",
"customers": "Kunden",
"resources": "Ressourcen",
"payments": "Zahlungen",
"messages": "Nachrichten",
"staff": "Personal",
"businessSettings": "Geschäftseinstellungen",
"profile": "Profil",
"platformDashboard": "Plattform-Dashboard",
"businesses": "Unternehmen",
"users": "Benutzer",
"support": "Support",
"platformSettings": "Plattform-Einstellungen"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Willkommen, {{name}}!",
"todayOverview": "Heutige Übersicht",
"upcomingAppointments": "Bevorstehende Termine",
"recentActivity": "Neueste Aktivitäten",
"quickActions": "Schnellaktionen",
"totalRevenue": "Gesamtumsatz",
"totalAppointments": "Termine Gesamt",
"newCustomers": "Neue Kunden",
"pendingPayments": "Ausstehende Zahlungen"
},
"scheduler": {
"title": "Terminplaner",
"newAppointment": "Neuer Termin",
"editAppointment": "Termin Bearbeiten",
"deleteAppointment": "Termin Löschen",
"selectResource": "Ressource Auswählen",
"selectService": "Service Auswählen",
"selectCustomer": "Kunde Auswählen",
"selectDate": "Datum Auswählen",
"selectTime": "Uhrzeit Auswählen",
"duration": "Dauer",
"notes": "Notizen",
"status": "Status",
"confirmed": "Bestätigt",
"pending": "Ausstehend",
"cancelled": "Storniert",
"completed": "Abgeschlossen",
"noShow": "Nicht Erschienen",
"today": "Heute",
"week": "Woche",
"month": "Monat",
"day": "Tag",
"timeline": "Zeitachse",
"agenda": "Agenda",
"allResources": "Alle Ressourcen"
},
"customers": {
"title": "Kunden",
"description": "Verwalten Sie Ihren Kundenstamm und sehen Sie die Historie ein.",
"addCustomer": "Kunde Hinzufügen",
"editCustomer": "Kunde Bearbeiten",
"customerDetails": "Kundendetails",
"name": "Name",
"fullName": "Vollständiger Name",
"email": "E-Mail",
"emailAddress": "E-Mail-Adresse",
"phone": "Telefon",
"phoneNumber": "Telefonnummer",
"address": "Adresse",
"city": "Stadt",
"state": "Bundesland",
"zipCode": "PLZ",
"tags": "Tags",
"tagsPlaceholder": "z.B. VIP, Empfehlung",
"tagsCommaSeparated": "Tags (kommagetrennt)",
"appointmentHistory": "Terminverlauf",
"noAppointments": "Noch keine Termine",
"totalSpent": "Gesamtausgaben",
"totalSpend": "Gesamtausgaben",
"lastVisit": "Letzter Besuch",
"nextAppointment": "Nächster Termin",
"contactInfo": "Kontaktinfo",
"status": "Status",
"active": "Aktiv",
"inactive": "Inaktiv",
"never": "Nie",
"customer": "Kunde",
"searchPlaceholder": "Nach Name, E-Mail oder Telefon suchen...",
"filters": "Filter",
"noCustomersFound": "Keine Kunden gefunden, die Ihrer Suche entsprechen.",
"addNewCustomer": "Neuen Kunden Hinzufügen",
"createCustomer": "Kunden Erstellen",
"errorLoading": "Fehler beim Laden der Kunden"
},
"staff": {
"title": "Personal & Management",
"description": "Benutzerkonten und Berechtigungen verwalten.",
"inviteStaff": "Personal Einladen",
"name": "Name",
"role": "Rolle",
"bookableResource": "Buchbare Ressource",
"makeBookable": "Buchbar Machen",
"yes": "Ja",
"errorLoading": "Fehler beim Laden des Personals",
"inviteModalTitle": "Personal Einladen",
"inviteModalDescription": "Der Benutzereinladungsablauf würde hier sein."
},
"resources": {
"title": "Ressourcen",
"description": "Verwalten Sie Ihr Personal, Räume und Geräte.",
"addResource": "Ressource Hinzufügen",
"editResource": "Ressource Bearbeiten",
"resourceDetails": "Ressourcendetails",
"resourceName": "Ressourcenname",
"name": "Name",
"type": "Typ",
"resourceType": "Ressourcentyp",
"availability": "Verfügbarkeit",
"services": "Services",
"schedule": "Zeitplan",
"active": "Aktiv",
"inactive": "Inaktiv",
"upcoming": "Bevorstehend",
"appointments": "Termine",
"viewCalendar": "Kalender Anzeigen",
"noResourcesFound": "Keine Ressourcen gefunden.",
"addNewResource": "Neue Ressource Hinzufügen",
"createResource": "Ressource Erstellen",
"staffMember": "Mitarbeiter",
"room": "Raum",
"equipment": "Gerät",
"resourceNote": "Ressourcen sind Platzhalter für die Terminplanung. Personal kann Terminen separat zugewiesen werden.",
"errorLoading": "Fehler beim Laden der Ressourcen"
},
"services": {
"title": "Services",
"addService": "Service Hinzufügen",
"editService": "Service Bearbeiten",
"name": "Name",
"description": "Beschreibung",
"duration": "Dauer",
"price": "Preis",
"category": "Kategorie",
"active": "Aktiv"
},
"payments": {
"title": "Zahlungen",
"transactions": "Transaktionen",
"invoices": "Rechnungen",
"amount": "Betrag",
"status": "Status",
"date": "Datum",
"method": "Methode",
"paid": "Bezahlt",
"unpaid": "Unbezahlt",
"refunded": "Erstattet",
"pending": "Ausstehend",
"viewDetails": "Details Anzeigen",
"issueRefund": "Erstattung Ausstellen",
"sendReminder": "Erinnerung Senden",
"paymentSettings": "Zahlungseinstellungen",
"stripeConnect": "Stripe Connect",
"apiKeys": "API-Schlüssel"
},
"settings": {
"title": "Einstellungen",
"businessSettings": "Geschäftseinstellungen",
"businessSettingsDescription": "Verwalten Sie Ihr Branding, Ihre Domain und Richtlinien.",
"domainIdentity": "Domain & Identität",
"bookingPolicy": "Buchungs- und Stornierungsrichtlinie",
"savedSuccessfully": "Einstellungen erfolgreich gespeichert",
"general": "Allgemein",
"branding": "Markengestaltung",
"notifications": "Benachrichtigungen",
"security": "Sicherheit",
"integrations": "Integrationen",
"billing": "Abrechnung",
"businessName": "Firmenname",
"subdomain": "Subdomain",
"primaryColor": "Primärfarbe",
"secondaryColor": "Sekundärfarbe",
"logo": "Logo",
"uploadLogo": "Logo Hochladen",
"timezone": "Zeitzone",
"language": "Sprache",
"currency": "Währung",
"dateFormat": "Datumsformat",
"timeFormat": "Zeitformat",
"oauth": {
"title": "OAuth-Einstellungen",
"enabledProviders": "Aktivierte Anbieter",
"allowRegistration": "Registrierung über OAuth erlauben",
"autoLinkByEmail": "Konten automatisch per E-Mail verknüpfen",
"customCredentials": "Eigene OAuth-Anmeldedaten",
"customCredentialsDesc": "Verwenden Sie Ihre eigenen OAuth-Anmeldedaten für ein White-Label-Erlebnis",
"platformCredentials": "Plattform-Anmeldedaten",
"platformCredentialsDesc": "Verwendung der von der Plattform bereitgestellten OAuth-Anmeldedaten",
"clientId": "Client-ID",
"clientSecret": "Client-Geheimnis",
"paidTierOnly": "Eigene OAuth-Anmeldedaten sind nur für kostenpflichtige Tarife verfügbar"
}
},
"profile": {
"title": "Profileinstellungen",
"personalInfo": "Persönliche Informationen",
"changePassword": "Passwort Ändern",
"twoFactor": "Zwei-Faktor-Authentifizierung",
"sessions": "Aktive Sitzungen",
"emails": "E-Mail-Adressen",
"preferences": "Einstellungen",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort Bestätigen",
"passwordChanged": "Passwort erfolgreich geändert",
"enable2FA": "Zwei-Faktor-Authentifizierung Aktivieren",
"disable2FA": "Zwei-Faktor-Authentifizierung Deaktivieren",
"scanQRCode": "QR-Code Scannen",
"enterBackupCode": "Backup-Code Eingeben",
"recoveryCodes": "Wiederherstellungscodes"
},
"platform": {
"title": "Plattformverwaltung",
"dashboard": "Plattform-Dashboard",
"overview": "Plattformübersicht",
"overviewDescription": "Globale Metriken für alle Mandanten.",
"mrrGrowth": "MRR-Wachstum",
"totalBusinesses": "Unternehmen Gesamt",
"totalUsers": "Benutzer Gesamt",
"monthlyRevenue": "Monatlicher Umsatz",
"activeSubscriptions": "Aktive Abonnements",
"recentSignups": "Neueste Anmeldungen",
"supportTickets": "Support-Tickets",
"supportDescription": "Probleme von Mandanten lösen.",
"reportedBy": "Gemeldet von",
"priority": "Priorität",
"businessManagement": "Unternehmensverwaltung",
"userManagement": "Benutzerverwaltung",
"masquerade": "Als Benutzer agieren",
"masqueradeAs": "Agieren als",
"exitMasquerade": "Benutzeransicht Beenden",
"businesses": "Unternehmen",
"businessesDescription": "Mandanten, Pläne und Zugriffe verwalten.",
"addNewTenant": "Neuen Mandanten Hinzufügen",
"searchBusinesses": "Unternehmen suchen...",
"businessName": "Firmenname",
"subdomain": "Subdomain",
"plan": "Plan",
"status": "Status",
"joined": "Beigetreten",
"userDirectory": "Benutzerverzeichnis",
"userDirectoryDescription": "Alle Benutzer der Plattform anzeigen und verwalten.",
"searchUsers": "Benutzer nach Name oder E-Mail suchen...",
"allRoles": "Alle Rollen",
"user": "Benutzer",
"role": "Rolle",
"email": "E-Mail",
"noUsersFound": "Keine Benutzer gefunden, die Ihren Filtern entsprechen.",
"roles": {
"superuser": "Superuser",
"platformManager": "Plattform-Manager",
"businessOwner": "Geschäftsinhaber",
"staff": "Personal",
"customer": "Kunde"
},
"settings": {
"title": "Plattform-Einstellungen",
"description": "Plattformweite Einstellungen und Integrationen konfigurieren",
"tiersPricing": "Stufen und Preise",
"oauthProviders": "OAuth-Anbieter",
"general": "Allgemein",
"oauth": "OAuth-Anbieter",
"payments": "Zahlungen",
"email": "E-Mail",
"branding": "Markengestaltung"
}
},
"errors": {
"generic": "Etwas ist schief gelaufen. Bitte versuchen Sie es erneut.",
"networkError": "Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.",
"unauthorized": "Sie sind nicht berechtigt, diese Aktion durchzuführen.",
"notFound": "Die angeforderte Ressource wurde nicht gefunden.",
"validation": "Bitte überprüfen Sie Ihre Eingabe und versuchen Sie es erneut.",
"businessNotFound": "Unternehmen Nicht Gefunden",
"wrongLocation": "Falscher Standort",
"accessDenied": "Zugriff Verweigert"
},
"validation": {
"required": "Dieses Feld ist erforderlich",
"email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"minLength": "Muss mindestens {{min}} Zeichen haben",
"maxLength": "Darf maximal {{max}} Zeichen haben",
"passwordMatch": "Passwörter stimmen nicht überein",
"invalidPhone": "Bitte geben Sie eine gültige Telefonnummer ein"
},
"time": {
"minutes": "Minuten",
"hours": "Stunden",
"days": "Tage",
"today": "Heute",
"tomorrow": "Morgen",
"yesterday": "Gestern",
"thisWeek": "Diese Woche",
"thisMonth": "Diesen Monat",
"am": "AM",
"pm": "PM"
},
"marketing": {
"tagline": "Orchestrieren Sie Ihr Unternehmen mit Präzision.",
"description": "Die All-in-One-Terminplanungsplattform für Unternehmen jeder Größe. Verwalten Sie Ressourcen, Personal und Buchungen mühelos.",
"copyright": "Smooth Schedule Inc.",
"nav": {
"features": "Funktionen",
"pricing": "Preise",
"about": "Über uns",
"contact": "Kontakt",
"login": "Anmelden",
"getStarted": "Loslegen",
"startFreeTrial": "Kostenlos testen"
},
"hero": {
"headline": "Terminplanung Vereinfacht",
"subheadline": "Die All-in-One-Plattform für Termine, Ressourcen und Kunden. Starten Sie kostenlos, skalieren Sie nach Bedarf.",
"cta": "Kostenlos testen",
"secondaryCta": "Demo ansehen",
"trustedBy": "Über 1.000 Unternehmen vertrauen uns"
},
"features": {
"title": "Alles was Sie brauchen",
"subtitle": "Leistungsstarke Funktionen für Ihr Dienstleistungsunternehmen",
"scheduling": {
"title": "Intelligente Terminplanung",
"description": "Drag-and-Drop-Kalender mit Echtzeit-Verfügbarkeit, automatischen Erinnerungen und Konfliktererkennung."
},
"resources": {
"title": "Ressourcenverwaltung",
"description": "Verwalten Sie Personal, Räume und Ausrüstung. Konfigurieren Sie Verfügbarkeit, Fähigkeiten und Buchungsregeln."
},
"customers": {
"title": "Kundenportal",
"description": "Self-Service-Portal für Kunden. Verlauf einsehen, Termine verwalten und Zahlungsmethoden speichern."
},
"payments": {
"title": "Integrierte Zahlungen",
"description": "Akzeptieren Sie Online-Zahlungen mit Stripe. Anzahlungen, Vollzahlungen und automatische Rechnungsstellung."
},
"multiTenant": {
"title": "Multi-Standort-Support",
"description": "Verwalten Sie mehrere Standorte oder Marken von einem Dashboard mit isolierten Daten."
},
"whiteLabel": {
"title": "White-Label bereit",
"description": "Eigene Domain, Branding und SmoothSchedule-Branding entfernen für ein nahtloses Erlebnis."
},
"analytics": {
"title": "Analysen & Berichte",
"description": "Verfolgen Sie Umsatz, Termine und Kundentrends mit schönen Dashboards."
},
"integrations": {
"title": "Leistungsstarke Integrationen",
"description": "Verbinden Sie sich mit Google Calendar, Zoom, Stripe und mehr. API-Zugang für eigene Integrationen."
}
},
"howItWorks": {
"title": "In wenigen Minuten starten",
"subtitle": "Drei einfache Schritte zur Transformation Ihrer Terminplanung",
"step1": {
"title": "Konto erstellen",
"description": "Registrieren Sie sich kostenlos und richten Sie Ihr Unternehmensprofil in Minuten ein."
},
"step2": {
"title": "Dienste hinzufügen",
"description": "Konfigurieren Sie Ihre Dienste, Preise und verfügbaren Ressourcen."
},
"step3": {
"title": "Buchungen starten",
"description": "Teilen Sie Ihren Buchungslink und lassen Sie Kunden sofort Termine buchen."
}
},
"pricing": {
"title": "Einfache, transparente Preise",
"subtitle": "Starten Sie kostenlos, upgraden Sie nach Bedarf. Keine versteckten Gebühren.",
"monthly": "Monatlich",
"annual": "Jährlich",
"annualSave": "20% sparen",
"perMonth": "/Monat",
"period": "Monat",
"popular": "Beliebteste",
"mostPopular": "Beliebteste",
"getStarted": "Loslegen",
"contactSales": "Vertrieb kontaktieren",
"freeTrial": "14 Tage kostenlos testen",
"noCredit": "Keine Kreditkarte erforderlich",
"features": "Funktionen",
"tiers": {
"free": {
"name": "Kostenlos",
"description": "Perfekt zum Einstieg",
"price": "0",
"features": [
"Bis zu 2 Ressourcen",
"Basis-Terminplanung",
"Kundenverwaltung",
"Direkte Stripe-Integration",
"Subdomain (firma.smoothschedule.com)",
"Community-Support"
],
"transactionFee": "2,5% + 0,30€ pro Transaktion"
},
"professional": {
"name": "Professional",
"description": "Für wachsende Unternehmen",
"price": "29",
"annualPrice": "290",
"features": [
"Bis zu 10 Ressourcen",
"Eigene Domain",
"Stripe Connect (niedrigere Gebühren)",
"White-Label-Branding",
"E-Mail-Erinnerungen",
"Prioritäts-E-Mail-Support"
],
"transactionFee": "1,5% + 0,25€ pro Transaktion"
},
"business": {
"name": "Business",
"description": "Für etablierte Teams",
"price": "79",
"annualPrice": "790",
"features": [
"Unbegrenzte Ressourcen",
"Alle Professional-Funktionen",
"Teamverwaltung",
"Erweiterte Analysen",
"API-Zugang",
"Telefon-Support"
],
"transactionFee": "0,5% + 0,20€ pro Transaktion"
},
"enterprise": {
"name": "Enterprise",
"description": "Für große Organisationen",
"price": "Individuell",
"features": [
"Alle Business-Funktionen",
"Individuelle Integrationen",
"Dedizierter Success Manager",
"SLA-Garantien",
"Individuelle Verträge",
"On-Premise-Option"
],
"transactionFee": "Individuelle Transaktionsgebühren"
}
}
},
"testimonials": {
"title": "Beliebt bei Unternehmen überall",
"subtitle": "Sehen Sie, was unsere Kunden sagen"
},
"stats": {
"appointments": "Geplante Termine",
"businesses": "Unternehmen",
"countries": "Länder",
"uptime": "Verfügbarkeit"
},
"signup": {
"title": "Konto erstellen",
"subtitle": "Starten Sie Ihre kostenlose Testversion heute. Keine Kreditkarte erforderlich.",
"steps": {
"business": "Unternehmen",
"account": "Konto",
"plan": "Plan",
"confirm": "Bestätigen"
},
"businessInfo": {
"title": "Erzählen Sie uns von Ihrem Unternehmen",
"name": "Unternehmensname",
"namePlaceholder": "z.B., Acme Salon & Spa",
"subdomain": "Wählen Sie Ihre Subdomain",
"checking": "Verfügbarkeit prüfen...",
"available": "Verfügbar!",
"taken": "Bereits vergeben"
},
"accountInfo": {
"title": "Admin-Konto erstellen",
"firstName": "Vorname",
"lastName": "Nachname",
"email": "E-Mail-Adresse",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen"
},
"planSelection": {
"title": "Plan wählen"
},
"confirm": {
"title": "Überprüfen Sie Ihre Angaben",
"business": "Unternehmen",
"account": "Konto",
"plan": "Gewählter Plan",
"terms": "Mit der Kontoerstellung akzeptieren Sie unsere Nutzungsbedingungen und Datenschutzrichtlinie."
},
"errors": {
"businessNameRequired": "Unternehmensname ist erforderlich",
"subdomainRequired": "Subdomain ist erforderlich",
"subdomainTooShort": "Subdomain muss mindestens 3 Zeichen haben",
"subdomainInvalid": "Subdomain darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten",
"subdomainTaken": "Diese Subdomain ist bereits vergeben",
"firstNameRequired": "Vorname ist erforderlich",
"lastNameRequired": "Nachname ist erforderlich",
"emailRequired": "E-Mail ist erforderlich",
"emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordRequired": "Passwort ist erforderlich",
"passwordTooShort": "Passwort muss mindestens 8 Zeichen haben",
"passwordMismatch": "Passwörter stimmen nicht überein",
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
},
"success": {
"title": "Willkommen bei Smooth Schedule!",
"message": "Ihr Konto wurde erfolgreich erstellt.",
"yourUrl": "Ihre Buchungs-URL",
"checkEmail": "Wir haben Ihnen eine Bestätigungs-E-Mail gesendet. Bitte bestätigen Sie Ihre E-Mail, um alle Funktionen zu aktivieren.",
"goToLogin": "Zur Anmeldung"
},
"back": "Zurück",
"next": "Weiter",
"creating": "Konto wird erstellt...",
"createAccount": "Konto erstellen",
"haveAccount": "Haben Sie bereits ein Konto?",
"signIn": "Anmelden"
},
"faq": {
"title": "Häufig gestellte Fragen",
"subtitle": "Fragen? Wir haben Antworten.",
"questions": {
"trial": {
"question": "Bieten Sie eine kostenlose Testversion an?",
"answer": "Ja! Alle kostenpflichtigen Pläne beinhalten 14 Tage kostenlose Testversion. Keine Kreditkarte zum Start erforderlich."
},
"cancel": {
"question": "Kann ich jederzeit kündigen?",
"answer": "Absolut. Sie können Ihr Abonnement jederzeit ohne Kündigungsgebühren beenden."
},
"payment": {
"question": "Welche Zahlungsmethoden akzeptieren Sie?",
"answer": "Wir akzeptieren alle gängigen Kreditkarten über Stripe, einschließlich Visa, Mastercard und American Express."
},
"migrate": {
"question": "Kann ich von einer anderen Plattform migrieren?",
"answer": "Ja! Unser Team kann Ihnen helfen, Ihre vorhandenen Daten von anderen Planungsplattformen zu migrieren."
},
"support": {
"question": "Welche Art von Support bieten Sie an?",
"answer": "Der kostenlose Plan beinhaltet Community-Support. Professional und höher haben E-Mail-Support, Business/Enterprise haben Telefon-Support."
},
"customDomain": {
"question": "Wie funktionieren eigene Domains?",
"answer": "Professional und höhere Pläne können Ihre eigene Domain (z.B. buchen.ihrefirma.com) anstelle unserer Subdomain verwenden."
}
}
},
"about": {
"title": "Über Smooth Schedule",
"subtitle": "Unsere Mission ist es, die Terminplanung für Unternehmen überall zu vereinfachen.",
"story": {
"title": "Unsere Geschichte",
"content": "Smooth Schedule wurde mit einer einfachen Überzeugung gegründet: Terminplanung sollte nicht kompliziert sein. Wir haben eine Plattform gebaut, die es Unternehmen jeder Größe erleichtert, ihre Termine, Ressourcen und Kunden zu verwalten."
},
"mission": {
"title": "Unsere Mission",
"content": "Dienstleistungsunternehmen mit den Werkzeugen auszustatten, die sie zum Wachsen brauchen, und gleichzeitig ihren Kunden ein nahtloses Buchungserlebnis zu bieten."
},
"values": {
"title": "Unsere Werte",
"simplicity": {
"title": "Einfachheit",
"description": "Wir glauben, dass leistungsstarke Software auch einfach zu bedienen sein kann."
},
"reliability": {
"title": "Zuverlässigkeit",
"description": "Ihr Unternehmen hängt von uns ab, deshalb machen wir bei der Verfügbarkeit keine Kompromisse."
},
"transparency": {
"title": "Transparenz",
"description": "Keine versteckten Gebühren, keine Überraschungen. Was Sie sehen, ist was Sie bekommen."
},
"support": {
"title": "Support",
"description": "Wir sind hier, um Ihnen bei jedem Schritt zum Erfolg zu verhelfen."
}
}
},
"contact": {
"title": "Kontaktieren Sie uns",
"subtitle": "Fragen? Wir würden gerne von Ihnen hören.",
"form": {
"name": "Ihr Name",
"namePlaceholder": "Max Mustermann",
"email": "E-Mail-Adresse",
"emailPlaceholder": "sie@beispiel.de",
"subject": "Betreff",
"subjectPlaceholder": "Wie können wir helfen?",
"message": "Nachricht",
"messagePlaceholder": "Erzählen Sie uns mehr über Ihre Anforderungen...",
"submit": "Nachricht senden",
"sending": "Wird gesendet...",
"success": "Danke für Ihre Nachricht! Wir melden uns bald.",
"error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
},
"info": {
"email": "support@smoothschedule.com",
"phone": "+1 (555) 123-4567",
"address": "123 Schedule Street, San Francisco, CA 94102"
},
"sales": {
"title": "Mit dem Vertrieb sprechen",
"description": "Interessiert an unserem Enterprise-Plan? Unser Vertriebsteam freut sich auf ein Gespräch."
}
},
"cta": {
"ready": "Bereit loszulegen?",
"readySubtitle": "Schließen Sie sich Tausenden von Unternehmen an, die bereits SmoothSchedule nutzen.",
"startFree": "Kostenlos testen",
"noCredit": "Keine Kreditkarte erforderlich"
},
"footer": {
"product": "Produkt",
"company": "Unternehmen",
"legal": "Rechtliches",
"features": "Funktionen",
"pricing": "Preise",
"integrations": "Integrationen",
"about": "Über uns",
"blog": "Blog",
"careers": "Karriere",
"contact": "Kontakt",
"terms": "AGB",
"privacy": "Datenschutz",
"cookies": "Cookies",
"allRightsReserved": "Alle Rechte vorbehalten."
}
}
}

View File

@@ -0,0 +1,849 @@
{
"common": {
"loading": "Loading...",
"error": "Error",
"success": "Success",
"save": "Save",
"saveChanges": "Save Changes",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"update": "Update",
"close": "Close",
"confirm": "Confirm",
"back": "Back",
"next": "Next",
"search": "Search",
"filter": "Filter",
"actions": "Actions",
"settings": "Settings",
"reload": "Reload",
"viewAll": "View All",
"learnMore": "Learn More",
"poweredBy": "Powered by",
"required": "Required",
"optional": "Optional",
"masquerade": "Masquerade",
"masqueradeAsUser": "Masquerade as User"
},
"auth": {
"signIn": "Sign in",
"signOut": "Sign Out",
"signingIn": "Signing in...",
"username": "Username",
"password": "Password",
"enterUsername": "Enter your username",
"enterPassword": "Enter your password",
"welcomeBack": "Welcome back",
"pleaseEnterDetails": "Please enter your details to sign in.",
"authError": "Authentication Error",
"invalidCredentials": "Invalid credentials",
"orContinueWith": "Or continue with",
"loginAtSubdomain": "Please login at your business subdomain. Staff and customers cannot login from the main site.",
"forgotPassword": "Forgot password?",
"rememberMe": "Remember me",
"twoFactorRequired": "Two-factor authentication required",
"enterCode": "Enter verification code",
"verifyCode": "Verify Code"
},
"nav": {
"dashboard": "Dashboard",
"scheduler": "Scheduler",
"customers": "Customers",
"resources": "Resources",
"services": "Services",
"payments": "Payments",
"paymentsDisabledTooltip": "Payments are disabled. Enable them in Business Settings to accept payments from customers.",
"messages": "Messages",
"staff": "Staff",
"businessSettings": "Business Settings",
"profile": "Profile",
"platformDashboard": "Platform Dashboard",
"businesses": "Businesses",
"users": "Users",
"support": "Support",
"platformSettings": "Platform Settings"
},
"staff": {
"title": "Staff & Management",
"description": "Manage user accounts and permissions.",
"inviteStaff": "Invite Staff",
"name": "Name",
"role": "Role",
"bookableResource": "Bookable Resource",
"makeBookable": "Make Bookable",
"yes": "Yes",
"errorLoading": "Error loading staff",
"inviteModalTitle": "Invite Staff",
"inviteModalDescription": "User invitation flow would go here."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome, {{name}}!",
"todayOverview": "Today's Overview",
"upcomingAppointments": "Upcoming Appointments",
"recentActivity": "Recent Activity",
"quickActions": "Quick Actions",
"totalRevenue": "Total Revenue",
"totalAppointments": "Total Appointments",
"newCustomers": "New Customers",
"pendingPayments": "Pending Payments"
},
"scheduler": {
"title": "Scheduler",
"newAppointment": "New Appointment",
"editAppointment": "Edit Appointment",
"deleteAppointment": "Delete Appointment",
"selectResource": "Select Resource",
"selectService": "Select Service",
"selectCustomer": "Select Customer",
"selectDate": "Select Date",
"selectTime": "Select Time",
"duration": "Duration",
"notes": "Notes",
"status": "Status",
"confirmed": "Confirmed",
"pending": "Pending",
"cancelled": "Cancelled",
"completed": "Completed",
"noShow": "No Show",
"today": "Today",
"week": "Week",
"month": "Month",
"day": "Day",
"timeline": "Timeline",
"agenda": "Agenda",
"allResources": "All Resources"
},
"customers": {
"title": "Customers",
"description": "Manage your client base and view history.",
"addCustomer": "Add Customer",
"editCustomer": "Edit Customer",
"customerDetails": "Customer Details",
"name": "Name",
"fullName": "Full Name",
"email": "Email",
"emailAddress": "Email Address",
"phone": "Phone",
"phoneNumber": "Phone Number",
"address": "Address",
"city": "City",
"state": "State",
"zipCode": "Zip Code",
"tags": "Tags",
"tagsPlaceholder": "e.g. VIP, Referral",
"tagsCommaSeparated": "Tags (comma separated)",
"appointmentHistory": "Appointment History",
"noAppointments": "No appointments yet",
"totalSpent": "Total Spent",
"totalSpend": "Total Spend",
"lastVisit": "Last Visit",
"nextAppointment": "Next Appointment",
"contactInfo": "Contact Info",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"never": "Never",
"customer": "Customer",
"searchPlaceholder": "Search by name, email, or phone...",
"filters": "Filters",
"noCustomersFound": "No customers found matching your search.",
"addNewCustomer": "Add New Customer",
"createCustomer": "Create Customer",
"errorLoading": "Error loading customers"
},
"resources": {
"title": "Resources",
"description": "Manage your staff, rooms, and equipment.",
"addResource": "Add Resource",
"editResource": "Edit Resource",
"resourceDetails": "Resource Details",
"resourceName": "Resource Name",
"name": "Name",
"type": "Type",
"resourceType": "Resource Type",
"availability": "Availability",
"services": "Services",
"schedule": "Schedule",
"active": "Active",
"inactive": "Inactive",
"upcoming": "Upcoming",
"appointments": "appts",
"viewCalendar": "View Calendar",
"noResourcesFound": "No resources found.",
"addNewResource": "Add New Resource",
"createResource": "Create Resource",
"staffMember": "Staff Member",
"room": "Room",
"equipment": "Equipment",
"resourceNote": "Resources are placeholders for scheduling. Staff can be assigned to appointments separately.",
"errorLoading": "Error loading resources"
},
"services": {
"title": "Services",
"addService": "Add Service",
"editService": "Edit Service",
"name": "Name",
"description": "Description",
"duration": "Duration",
"price": "Price",
"category": "Category",
"active": "Active"
},
"payments": {
"title": "Payments",
"transactions": "Transactions",
"invoices": "Invoices",
"amount": "Amount",
"status": "Status",
"date": "Date",
"method": "Method",
"paid": "Paid",
"unpaid": "Unpaid",
"refunded": "Refunded",
"pending": "Pending",
"viewDetails": "View Details",
"issueRefund": "Issue Refund",
"sendReminder": "Send Reminder",
"paymentSettings": "Payment Settings",
"stripeConnect": "Stripe Connect",
"apiKeys": "API Keys"
},
"settings": {
"title": "Settings",
"businessSettings": "Business Settings",
"businessSettingsDescription": "Manage your branding, domain, and policies.",
"domainIdentity": "Domain & Identity",
"bookingPolicy": "Booking & Cancellation Policy",
"savedSuccessfully": "Settings saved successfully",
"general": "General",
"branding": "Branding",
"notifications": "Notifications",
"security": "Security",
"integrations": "Integrations",
"billing": "Billing",
"businessName": "Business Name",
"subdomain": "Subdomain",
"primaryColor": "Primary Color",
"secondaryColor": "Secondary Color",
"logo": "Logo",
"uploadLogo": "Upload Logo",
"timezone": "Timezone",
"language": "Language",
"currency": "Currency",
"dateFormat": "Date Format",
"timeFormat": "Time Format",
"oauth": {
"title": "OAuth Settings",
"enabledProviders": "Enabled Providers",
"allowRegistration": "Allow Registration via OAuth",
"autoLinkByEmail": "Auto-link accounts by email",
"customCredentials": "Custom OAuth Credentials",
"customCredentialsDesc": "Use your own OAuth credentials for a white-label experience",
"platformCredentials": "Platform Credentials",
"platformCredentialsDesc": "Using platform-provided OAuth credentials",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"paidTierOnly": "Custom OAuth credentials are only available for paid tiers"
},
"payments": "Payments",
"acceptPayments": "Accept Payments",
"acceptPaymentsDescription": "Enable payment acceptance from customers for appointments and services.",
"stripeSetupRequired": "Stripe Connect Setup Required",
"stripeSetupDescription": "You'll need to complete Stripe onboarding to accept payments. Go to the Payments page to get started."
},
"profile": {
"title": "Profile Settings",
"personalInfo": "Personal Information",
"changePassword": "Change Password",
"twoFactor": "Two-Factor Authentication",
"sessions": "Active Sessions",
"emails": "Email Addresses",
"preferences": "Preferences",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"passwordChanged": "Password changed successfully",
"enable2FA": "Enable Two-Factor Authentication",
"disable2FA": "Disable Two-Factor Authentication",
"scanQRCode": "Scan QR Code",
"enterBackupCode": "Enter Backup Code",
"recoveryCodes": "Recovery Codes"
},
"platform": {
"title": "Platform Administration",
"dashboard": "Platform Dashboard",
"overview": "Platform Overview",
"overviewDescription": "Global metrics across all tenants.",
"mrrGrowth": "MRR Growth",
"totalBusinesses": "Total Businesses",
"totalUsers": "Total Users",
"monthlyRevenue": "Monthly Revenue",
"activeSubscriptions": "Active Subscriptions",
"recentSignups": "Recent Signups",
"supportTickets": "Support Tickets",
"supportDescription": "Resolve issues reported by tenants.",
"reportedBy": "Reported by",
"priority": "Priority",
"businessManagement": "Business Management",
"userManagement": "User Management",
"masquerade": "Masquerade",
"masqueradeAs": "Masquerade as",
"exitMasquerade": "Exit Masquerade",
"businesses": "Businesses",
"businessesDescription": "Manage tenants, plans, and access.",
"addNewTenant": "Add New Tenant",
"searchBusinesses": "Search businesses...",
"businessName": "Business Name",
"subdomain": "Subdomain",
"plan": "Plan",
"status": "Status",
"joined": "Joined",
"userDirectory": "User Directory",
"userDirectoryDescription": "View and manage all users across the platform.",
"searchUsers": "Search users by name or email...",
"allRoles": "All Roles",
"user": "User",
"role": "Role",
"email": "Email",
"noUsersFound": "No users found matching your filters.",
"roles": {
"superuser": "Superuser",
"platformManager": "Platform Manager",
"businessOwner": "Business Owner",
"staff": "Staff",
"customer": "Customer"
},
"settings": {
"title": "Platform Settings",
"description": "Configure platform-wide settings and integrations",
"tiersPricing": "Tiers & Pricing",
"oauthProviders": "OAuth Providers",
"general": "General",
"oauth": "OAuth Providers",
"payments": "Payments",
"email": "Email",
"branding": "Branding"
}
},
"errors": {
"generic": "Something went wrong. Please try again.",
"networkError": "Network error. Please check your connection.",
"unauthorized": "You are not authorized to perform this action.",
"notFound": "The requested resource was not found.",
"validation": "Please check your input and try again.",
"businessNotFound": "Business Not Found",
"wrongLocation": "Wrong Location",
"accessDenied": "Access Denied"
},
"validation": {
"required": "This field is required",
"email": "Please enter a valid email address",
"minLength": "Must be at least {{min}} characters",
"maxLength": "Must be at most {{max}} characters",
"passwordMatch": "Passwords do not match",
"invalidPhone": "Please enter a valid phone number"
},
"time": {
"minutes": "minutes",
"hours": "hours",
"days": "days",
"today": "Today",
"tomorrow": "Tomorrow",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"thisMonth": "This Month",
"am": "AM",
"pm": "PM"
},
"marketing": {
"tagline": "Orchestrate your business with precision.",
"description": "The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.",
"copyright": "Smooth Schedule Inc.",
"nav": {
"features": "Features",
"pricing": "Pricing",
"about": "About",
"contact": "Contact",
"login": "Login",
"getStarted": "Get Started",
"signup": "Sign Up"
},
"hero": {
"headline": "Scheduling Made Simple",
"subheadline": "The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.",
"cta": "Get Started Free",
"secondaryCta": "Watch Demo",
"trustedBy": "Trusted by 1,000+ businesses worldwide"
},
"features": {
"title": "Everything You Need",
"subtitle": "Powerful features to run your service business",
"scheduling": {
"title": "Smart Scheduling",
"description": "Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection."
},
"resources": {
"title": "Resource Management",
"description": "Manage staff, rooms, and equipment. Set availability, skills, and booking rules."
},
"customers": {
"title": "Customer Portal",
"description": "Self-service booking portal for customers. View history, manage appointments, and save payment methods."
},
"payments": {
"title": "Integrated Payments",
"description": "Accept payments online with Stripe. Deposits, full payments, and automatic invoicing."
},
"multiTenant": {
"title": "Multi-Location Support",
"description": "Manage multiple locations or brands from a single dashboard with isolated data."
},
"whiteLabel": {
"title": "White-Label Ready",
"description": "Custom domain, branding, and remove SmoothSchedule branding for a seamless experience."
},
"analytics": {
"title": "Analytics & Reports",
"description": "Track revenue, appointments, and customer trends with beautiful dashboards."
},
"integrations": {
"title": "Powerful Integrations",
"description": "Connect with Google Calendar, Zoom, Stripe, and more. API access for custom integrations."
}
},
"howItWorks": {
"title": "Get Started in Minutes",
"subtitle": "Three simple steps to transform your scheduling",
"step1": {
"title": "Create Your Account",
"description": "Sign up for free and set up your business profile in minutes."
},
"step2": {
"title": "Add Your Services",
"description": "Configure your services, pricing, and available resources."
},
"step3": {
"title": "Start Booking",
"description": "Share your booking link and let customers schedule instantly."
}
},
"pricing": {
"title": "Simple, Transparent Pricing",
"subtitle": "Start free, upgrade as you grow. No hidden fees.",
"monthly": "Monthly",
"annual": "Annual",
"annualSave": "Save 20%",
"perMonth": "/month",
"period": "month",
"popular": "Most Popular",
"mostPopular": "Most Popular",
"getStarted": "Get Started",
"contactSales": "Contact Sales",
"startToday": "Get started today",
"noCredit": "No credit card required",
"features": "Features",
"tiers": {
"free": {
"name": "Free",
"description": "Perfect for getting started",
"price": "0",
"trial": "Free forever - no trial needed",
"features": [
"Up to 2 resources",
"Basic scheduling",
"Customer management",
"Direct Stripe integration",
"Subdomain (business.smoothschedule.com)",
"Community support"
],
"transactionFee": "2.5% + $0.30 per transaction"
},
"professional": {
"name": "Professional",
"description": "For growing businesses",
"price": "29",
"annualPrice": "290",
"trial": "14-day free trial",
"features": [
"Up to 10 resources",
"Custom domain",
"Stripe Connect (lower fees)",
"White-label branding",
"Email reminders",
"Priority email support"
],
"transactionFee": "1.5% + $0.25 per transaction"
},
"business": {
"name": "Business",
"description": "For established teams",
"price": "79",
"annualPrice": "790",
"trial": "14-day free trial",
"features": [
"Unlimited resources",
"All Professional features",
"Team management",
"Advanced analytics",
"API access",
"Phone support"
],
"transactionFee": "0.5% + $0.20 per transaction"
},
"enterprise": {
"name": "Enterprise",
"description": "For large organizations",
"price": "Custom",
"trial": "14-day free trial",
"features": [
"All Business features",
"Custom integrations",
"Dedicated success manager",
"SLA guarantees",
"Custom contracts",
"On-premise option"
],
"transactionFee": "Custom transaction fees"
}
}
},
"testimonials": {
"title": "Loved by Businesses Everywhere",
"subtitle": "See what our customers have to say"
},
"stats": {
"appointments": "Appointments Scheduled",
"businesses": "Businesses",
"countries": "Countries",
"uptime": "Uptime"
},
"signup": {
"title": "Create Your Account",
"subtitle": "Get started for free. No credit card required.",
"steps": {
"business": "Business",
"account": "Account",
"plan": "Plan",
"confirm": "Confirm"
},
"businessInfo": {
"title": "Tell us about your business",
"name": "Business Name",
"namePlaceholder": "e.g., Acme Salon & Spa",
"subdomain": "Choose Your Subdomain",
"subdomainNote": "A subdomain is required even if you plan to use your own custom domain later.",
"checking": "Checking availability...",
"available": "Available!",
"taken": "Already taken",
"address": "Business Address",
"addressLine1": "Street Address",
"addressLine1Placeholder": "123 Main Street",
"addressLine2": "Address Line 2",
"addressLine2Placeholder": "Suite 100 (optional)",
"city": "City",
"state": "State / Province",
"postalCode": "Postal Code",
"phone": "Phone Number",
"phonePlaceholder": "(555) 123-4567"
},
"accountInfo": {
"title": "Create your admin account",
"firstName": "First Name",
"lastName": "Last Name",
"email": "Email Address",
"password": "Password",
"confirmPassword": "Confirm Password"
},
"planSelection": {
"title": "Choose Your Plan"
},
"paymentSetup": {
"title": "Accept Payments",
"question": "Would you like to accept payments from your customers?",
"description": "Enable online payment collection for appointments and services. You can change this later in settings.",
"yes": "Yes, I want to accept payments",
"yesDescription": "Set up Stripe Connect to accept credit cards, debit cards, and more.",
"no": "No, not right now",
"noDescription": "Skip payment setup. You can enable it later in your business settings.",
"stripeNote": "Payment processing is powered by Stripe. You'll complete Stripe's secure onboarding after signup."
},
"confirm": {
"title": "Review Your Details",
"business": "Business",
"account": "Account",
"plan": "Selected Plan",
"payments": "Payments",
"paymentsEnabled": "Payment acceptance enabled",
"paymentsDisabled": "Payment acceptance disabled",
"terms": "By creating your account, you agree to our Terms of Service and Privacy Policy."
},
"errors": {
"businessNameRequired": "Business name is required",
"subdomainRequired": "Subdomain is required",
"subdomainTooShort": "Subdomain must be at least 3 characters",
"subdomainInvalid": "Subdomain can only contain lowercase letters, numbers, and hyphens",
"subdomainTaken": "This subdomain is already taken",
"addressRequired": "Street address is required",
"cityRequired": "City is required",
"stateRequired": "State/province is required",
"postalCodeRequired": "Postal code is required",
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",
"emailRequired": "Email is required",
"emailInvalid": "Please enter a valid email address",
"passwordRequired": "Password is required",
"passwordTooShort": "Password must be at least 8 characters",
"passwordMismatch": "Passwords do not match",
"generic": "Something went wrong. Please try again."
},
"success": {
"title": "Welcome to Smooth Schedule!",
"message": "Your account has been created successfully.",
"yourUrl": "Your booking URL",
"checkEmail": "We've sent a verification email to your inbox. Please verify your email to activate all features.",
"goToLogin": "Go to Login"
},
"back": "Back",
"next": "Next",
"creating": "Creating account...",
"createAccount": "Create Account",
"haveAccount": "Already have an account?",
"signIn": "Sign in"
},
"faq": {
"title": "Frequently Asked Questions",
"subtitle": "Got questions? We've got answers.",
"questions": {
"freePlan": {
"question": "Is there a free plan?",
"answer": "Yes! Our Free plan includes all the essential features to get started. You can upgrade to a paid plan anytime as your business grows."
},
"cancel": {
"question": "Can I cancel anytime?",
"answer": "Absolutely. You can cancel your subscription at any time with no cancellation fees."
},
"payment": {
"question": "What payment methods do you accept?",
"answer": "We accept all major credit cards through Stripe, including Visa, Mastercard, and American Express."
},
"migrate": {
"question": "Can I migrate from another platform?",
"answer": "Yes! Our team can help you migrate your existing data from other scheduling platforms."
},
"support": {
"question": "What kind of support do you offer?",
"answer": "Free plan includes community support. Professional and above get email support, and Business/Enterprise get phone support."
},
"customDomain": {
"question": "How do custom domains work?",
"answer": "Professional and above plans can use your own domain (e.g., book.yourbusiness.com) instead of our subdomain."
}
}
},
"about": {
"title": "About Smooth Schedule",
"subtitle": "We're on a mission to simplify scheduling for businesses everywhere.",
"story": {
"title": "Our Story",
"content": "We started creating bespoke custom scheduling and payment solutions in 2017. Through that work, we became convinced that we had a better way of doing things than other scheduling services out there.",
"content2": "Along the way, we discovered features and options that customers love, capabilities that nobody else offers. That's when we decided to change our model so we could help more businesses. SmoothSchedule was born from years of hands-on experience building what businesses actually need.",
"founded": "Building scheduling solutions"
},
"mission": {
"title": "Our Mission",
"content": "To empower service businesses with the tools they need to grow, while giving their customers a seamless booking experience."
},
"values": {
"title": "Our Values",
"simplicity": {
"title": "Simplicity",
"description": "We believe powerful software can still be simple to use."
},
"reliability": {
"title": "Reliability",
"description": "Your business depends on us, so we never compromise on uptime."
},
"transparency": {
"title": "Transparency",
"description": "No hidden fees, no surprises. What you see is what you get."
},
"support": {
"title": "Support",
"description": "We're here to help you succeed, every step of the way."
}
}
},
"contact": {
"title": "Get in Touch",
"subtitle": "Have questions? We'd love to hear from you.",
"form": {
"name": "Your Name",
"namePlaceholder": "John Smith",
"email": "Email Address",
"emailPlaceholder": "you@example.com",
"subject": "Subject",
"subjectPlaceholder": "How can we help?",
"message": "Message",
"messagePlaceholder": "Tell us more about your needs...",
"submit": "Send Message",
"sending": "Sending...",
"success": "Thanks for reaching out! We'll get back to you soon.",
"error": "Something went wrong. Please try again."
},
"info": {
"email": "support@smoothschedule.com",
"phone": "+1 (555) 123-4567",
"address": "123 Schedule Street, San Francisco, CA 94102"
},
"sales": {
"title": "Talk to Sales",
"description": "Interested in our Enterprise plan? Our sales team would love to chat."
}
},
"cta": {
"ready": "Ready to get started?",
"readySubtitle": "Join thousands of businesses already using SmoothSchedule.",
"startFree": "Get Started Free",
"noCredit": "No credit card required",
"or": "or",
"talkToSales": "Talk to Sales"
},
"footer": {
"product": {
"title": "Product"
},
"company": {
"title": "Company"
},
"legal": {
"title": "Legal",
"privacy": "Privacy Policy",
"terms": "Terms of Service"
},
"copyright": "Smooth Schedule Inc. All rights reserved."
}
},
"trial": {
"banner": {
"title": "Trial Active",
"daysLeft": "{{days}} days left in trial",
"expiresOn": "Trial expires on {{date}}",
"upgradeNow": "Upgrade Now",
"dismiss": "Dismiss"
},
"expired": {
"title": "Trial Expired",
"subtitle": "Your trial period has ended",
"message": "Thank you for trying SmoothSchedule! Your trial for {{businessName}} has expired. To continue using all features, please upgrade to a paid plan.",
"whatYouGet": "What You'll Get",
"features": {
"unlimited": "Unlimited appointments and bookings",
"payments": "Accept payments from customers",
"analytics": "Advanced analytics and reporting",
"support": "Priority customer support",
"customization": "Full branding and customization"
},
"upgradeNow": "Upgrade Now",
"viewSettings": "View Settings",
"needHelp": "Need help choosing a plan?",
"contactSupport": "Contact Support",
"dataRetention": "Your data is safe and will be retained for 30 days."
}
},
"upgrade": {
"title": "Upgrade Your Plan",
"subtitle": "Choose the perfect plan for {{businessName}}",
"mostPopular": "Most Popular",
"plan": "Plan",
"selected": "Selected",
"selectPlan": "Select Plan",
"custom": "Custom",
"month": "month",
"year": "year",
"billing": {
"monthly": "Monthly",
"annual": "Annual",
"save20": "Save 20%",
"saveAmount": "Save ${{amount}}/year"
},
"features": {
"resources": "Up to {{count}} resources",
"unlimitedResources": "Unlimited resources",
"customDomain": "Custom domain",
"stripeConnect": "Stripe Connect (lower fees)",
"whitelabel": "White-label branding",
"emailReminders": "Email reminders",
"prioritySupport": "Priority email support",
"teamManagement": "Team management",
"advancedAnalytics": "Advanced analytics",
"apiAccess": "API access",
"phoneSupport": "Phone support",
"everything": "Everything in Business",
"customIntegrations": "Custom integrations",
"dedicatedManager": "Dedicated success manager",
"sla": "SLA guarantees",
"customContracts": "Custom contracts",
"onPremise": "On-premise option"
},
"orderSummary": "Order Summary",
"billedMonthly": "Billed monthly",
"billedAnnually": "Billed annually",
"annualSavings": "Annual Savings",
"trust": {
"secure": "Secure Checkout",
"instant": "Instant Access",
"support": "24/7 Support"
},
"continueToPayment": "Continue to Payment",
"contactSales": "Contact Sales",
"processing": "Processing...",
"secureCheckout": "Secure checkout powered by Stripe",
"questions": "Questions?",
"contactUs": "Contact us",
"errors": {
"processingFailed": "Payment processing failed. Please try again."
}
},
"onboarding": {
"steps": {
"welcome": "Welcome",
"payments": "Payments",
"complete": "Complete"
},
"skipForNow": "Skip for now",
"welcome": {
"title": "Welcome to {{businessName}}!",
"subtitle": "Let's get your business set up to accept payments. This will only take a few minutes.",
"whatsIncluded": "What's included in setup:",
"connectStripe": "Connect your Stripe account for payments",
"automaticPayouts": "Automatic payouts to your bank",
"pciCompliance": "PCI compliance handled for you",
"getStarted": "Get Started",
"skip": "Skip for now"
},
"stripe": {
"title": "Connect Stripe",
"subtitle": "As a {{plan}} plan customer, you'll use Stripe Connect to securely process payments.",
"checkingStatus": "Checking payment status...",
"connected": {
"title": "Stripe Connected!",
"subtitle": "Your account is ready to accept payments."
},
"continue": "Continue",
"doLater": "I'll do this later"
},
"complete": {
"title": "You're All Set!",
"subtitle": "Your business is ready to accept payments. Start scheduling appointments and collecting payments from your customers.",
"checklist": {
"accountCreated": "Business account created",
"stripeConfigured": "Stripe Connect configured",
"readyForPayments": "Ready to accept payments"
},
"goToDashboard": "Go to Dashboard"
}
}
}

View File

@@ -0,0 +1,688 @@
{
"common": {
"loading": "Cargando...",
"error": "Error",
"success": "Exitoso",
"save": "Guardar",
"saveChanges": "Guardar Cambios",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"create": "Crear",
"update": "Actualizar",
"close": "Cerrar",
"confirm": "Confirmar",
"back": "Atrás",
"next": "Siguiente",
"search": "Buscar",
"filter": "Filtrar",
"actions": "Acciones",
"settings": "Configuración",
"reload": "Recargar",
"viewAll": "Ver Todo",
"learnMore": "Más Información",
"poweredBy": "Desarrollado por",
"required": "Requerido",
"optional": "Opcional",
"masquerade": "Suplantar",
"masqueradeAsUser": "Suplantar como Usuario"
},
"auth": {
"signIn": "Iniciar sesión",
"signOut": "Cerrar Sesión",
"signingIn": "Iniciando sesión...",
"username": "Nombre de usuario",
"password": "Contraseña",
"enterUsername": "Ingresa tu nombre de usuario",
"enterPassword": "Ingresa tu contraseña",
"welcomeBack": "Bienvenido de nuevo",
"pleaseEnterDetails": "Por favor ingresa tus datos para iniciar sesión.",
"authError": "Error de Autenticación",
"invalidCredentials": "Credenciales inválidas",
"orContinueWith": "O continuar con",
"loginAtSubdomain": "Por favor inicia sesión en el subdominio de tu negocio. El personal y los clientes no pueden iniciar sesión desde el sitio principal.",
"forgotPassword": "¿Olvidaste tu contraseña?",
"rememberMe": "Recordarme",
"twoFactorRequired": "Se requiere autenticación de dos factores",
"enterCode": "Ingresa el código de verificación",
"verifyCode": "Verificar Código"
},
"nav": {
"dashboard": "Panel",
"scheduler": "Agenda",
"customers": "Clientes",
"resources": "Recursos",
"payments": "Pagos",
"messages": "Mensajes",
"staff": "Personal",
"businessSettings": "Configuración del Negocio",
"profile": "Perfil",
"platformDashboard": "Panel de Plataforma",
"businesses": "Negocios",
"users": "Usuarios",
"support": "Soporte",
"platformSettings": "Configuración de Plataforma"
},
"dashboard": {
"title": "Panel",
"welcome": "¡Bienvenido, {{name}}!",
"todayOverview": "Resumen de Hoy",
"upcomingAppointments": "Próximas Citas",
"recentActivity": "Actividad Reciente",
"quickActions": "Acciones Rápidas",
"totalRevenue": "Ingresos Totales",
"totalAppointments": "Citas Totales",
"newCustomers": "Nuevos Clientes",
"pendingPayments": "Pagos Pendientes"
},
"scheduler": {
"title": "Agenda",
"newAppointment": "Nueva Cita",
"editAppointment": "Editar Cita",
"deleteAppointment": "Eliminar Cita",
"selectResource": "Seleccionar Recurso",
"selectService": "Seleccionar Servicio",
"selectCustomer": "Seleccionar Cliente",
"selectDate": "Seleccionar Fecha",
"selectTime": "Seleccionar Hora",
"duration": "Duración",
"notes": "Notas",
"status": "Estado",
"confirmed": "Confirmada",
"pending": "Pendiente",
"cancelled": "Cancelada",
"completed": "Completada",
"noShow": "No Presentado",
"today": "Hoy",
"week": "Semana",
"month": "Mes",
"day": "Día",
"timeline": "Línea de Tiempo",
"agenda": "Agenda",
"allResources": "Todos los Recursos"
},
"customers": {
"title": "Clientes",
"description": "Administra tu base de clientes y consulta el historial.",
"addCustomer": "Agregar Cliente",
"editCustomer": "Editar Cliente",
"customerDetails": "Detalles del Cliente",
"name": "Nombre",
"fullName": "Nombre Completo",
"email": "Correo Electrónico",
"emailAddress": "Dirección de Correo",
"phone": "Teléfono",
"phoneNumber": "Número de Teléfono",
"address": "Dirección",
"city": "Ciudad",
"state": "Estado",
"zipCode": "Código Postal",
"tags": "Etiquetas",
"tagsPlaceholder": "ej. VIP, Referido",
"tagsCommaSeparated": "Etiquetas (separadas por coma)",
"appointmentHistory": "Historial de Citas",
"noAppointments": "Sin citas aún",
"totalSpent": "Total Gastado",
"totalSpend": "Gasto Total",
"lastVisit": "Última Visita",
"nextAppointment": "Próxima Cita",
"contactInfo": "Información de Contacto",
"status": "Estado",
"active": "Activo",
"inactive": "Inactivo",
"never": "Nunca",
"customer": "Cliente",
"searchPlaceholder": "Buscar por nombre, correo o teléfono...",
"filters": "Filtros",
"noCustomersFound": "No se encontraron clientes que coincidan con tu búsqueda.",
"addNewCustomer": "Agregar Nuevo Cliente",
"createCustomer": "Crear Cliente",
"errorLoading": "Error al cargar clientes"
},
"staff": {
"title": "Personal y Administración",
"description": "Administra cuentas de usuario y permisos.",
"inviteStaff": "Invitar Personal",
"name": "Nombre",
"role": "Rol",
"bookableResource": "Recurso Reservable",
"makeBookable": "Hacer Reservable",
"yes": "Sí",
"errorLoading": "Error al cargar personal",
"inviteModalTitle": "Invitar Personal",
"inviteModalDescription": "El flujo de invitación de usuarios iría aquí."
},
"resources": {
"title": "Recursos",
"description": "Administra tu personal, salas y equipos.",
"addResource": "Agregar Recurso",
"editResource": "Editar Recurso",
"resourceDetails": "Detalles del Recurso",
"resourceName": "Nombre del Recurso",
"name": "Nombre",
"type": "Tipo",
"resourceType": "Tipo de Recurso",
"availability": "Disponibilidad",
"services": "Servicios",
"schedule": "Horario",
"active": "Activo",
"inactive": "Inactivo",
"upcoming": "Próximas",
"appointments": "citas",
"viewCalendar": "Ver Calendario",
"noResourcesFound": "No se encontraron recursos.",
"addNewResource": "Agregar Nuevo Recurso",
"createResource": "Crear Recurso",
"staffMember": "Miembro del Personal",
"room": "Sala",
"equipment": "Equipo",
"resourceNote": "Los recursos son marcadores de posición para programación. El personal puede asignarse a las citas por separado.",
"errorLoading": "Error al cargar recursos"
},
"services": {
"title": "Servicios",
"addService": "Agregar Servicio",
"editService": "Editar Servicio",
"name": "Nombre",
"description": "Descripción",
"duration": "Duración",
"price": "Precio",
"category": "Categoría",
"active": "Activo"
},
"payments": {
"title": "Pagos",
"transactions": "Transacciones",
"invoices": "Facturas",
"amount": "Monto",
"status": "Estado",
"date": "Fecha",
"method": "Método",
"paid": "Pagado",
"unpaid": "Sin Pagar",
"refunded": "Reembolsado",
"pending": "Pendiente",
"viewDetails": "Ver Detalles",
"issueRefund": "Emitir Reembolso",
"sendReminder": "Enviar Recordatorio",
"paymentSettings": "Configuración de Pagos",
"stripeConnect": "Stripe Connect",
"apiKeys": "Claves API"
},
"settings": {
"title": "Configuración",
"businessSettings": "Configuración del Negocio",
"businessSettingsDescription": "Administra tu marca, dominio y políticas.",
"domainIdentity": "Dominio e Identidad",
"bookingPolicy": "Política de Reservas y Cancelaciones",
"savedSuccessfully": "Configuración guardada exitosamente",
"general": "General",
"branding": "Marca",
"notifications": "Notificaciones",
"security": "Seguridad",
"integrations": "Integraciones",
"billing": "Facturación",
"businessName": "Nombre del Negocio",
"subdomain": "Subdominio",
"primaryColor": "Color Primario",
"secondaryColor": "Color Secundario",
"logo": "Logo",
"uploadLogo": "Subir Logo",
"timezone": "Zona Horaria",
"language": "Idioma",
"currency": "Moneda",
"dateFormat": "Formato de Fecha",
"timeFormat": "Formato de Hora",
"oauth": {
"title": "Configuración OAuth",
"enabledProviders": "Proveedores Habilitados",
"allowRegistration": "Permitir Registro vía OAuth",
"autoLinkByEmail": "Vincular cuentas automáticamente por correo",
"customCredentials": "Credenciales OAuth Personalizadas",
"customCredentialsDesc": "Usa tus propias credenciales OAuth para una experiencia de marca blanca",
"platformCredentials": "Credenciales de Plataforma",
"platformCredentialsDesc": "Usando credenciales OAuth proporcionadas por la plataforma",
"clientId": "ID de Cliente",
"clientSecret": "Secreto de Cliente",
"paidTierOnly": "Las credenciales OAuth personalizadas solo están disponibles para planes de pago"
}
},
"profile": {
"title": "Configuración de Perfil",
"personalInfo": "Información Personal",
"changePassword": "Cambiar Contraseña",
"twoFactor": "Autenticación de Dos Factores",
"sessions": "Sesiones Activas",
"emails": "Direcciones de Correo",
"preferences": "Preferencias",
"currentPassword": "Contraseña Actual",
"newPassword": "Nueva Contraseña",
"confirmPassword": "Confirmar Contraseña",
"passwordChanged": "Contraseña cambiada exitosamente",
"enable2FA": "Habilitar Autenticación de Dos Factores",
"disable2FA": "Deshabilitar Autenticación de Dos Factores",
"scanQRCode": "Escanear Código QR",
"enterBackupCode": "Ingresar Código de Respaldo",
"recoveryCodes": "Códigos de Recuperación"
},
"platform": {
"title": "Administración de Plataforma",
"dashboard": "Panel de Plataforma",
"overview": "Resumen de Plataforma",
"overviewDescription": "Métricas globales de todos los inquilinos.",
"mrrGrowth": "Crecimiento MRR",
"totalBusinesses": "Negocios Totales",
"totalUsers": "Usuarios Totales",
"monthlyRevenue": "Ingresos Mensuales",
"activeSubscriptions": "Suscripciones Activas",
"recentSignups": "Registros Recientes",
"supportTickets": "Tickets de Soporte",
"supportDescription": "Resolver problemas reportados por inquilinos.",
"reportedBy": "Reportado por",
"priority": "Prioridad",
"businessManagement": "Gestión de Negocios",
"userManagement": "Gestión de Usuarios",
"masquerade": "Suplantar",
"masqueradeAs": "Suplantar a",
"exitMasquerade": "Salir de Suplantación",
"businesses": "Negocios",
"businessesDescription": "Administrar inquilinos, planes y acceso.",
"addNewTenant": "Agregar Nuevo Inquilino",
"searchBusinesses": "Buscar negocios...",
"businessName": "Nombre del Negocio",
"subdomain": "Subdominio",
"plan": "Plan",
"status": "Estado",
"joined": "Registrado",
"userDirectory": "Directorio de Usuarios",
"userDirectoryDescription": "Ver y administrar todos los usuarios de la plataforma.",
"searchUsers": "Buscar usuarios por nombre o email...",
"allRoles": "Todos los Roles",
"user": "Usuario",
"role": "Rol",
"email": "Email",
"noUsersFound": "No se encontraron usuarios con los filtros seleccionados.",
"roles": {
"superuser": "Superusuario",
"platformManager": "Administrador de Plataforma",
"businessOwner": "Propietario de Negocio",
"staff": "Personal",
"customer": "Cliente"
},
"settings": {
"title": "Configuración de Plataforma",
"description": "Configurar ajustes e integraciones de la plataforma",
"tiersPricing": "Niveles y Precios",
"oauthProviders": "Proveedores OAuth",
"general": "General",
"oauth": "Proveedores OAuth",
"payments": "Pagos",
"email": "Correo Electrónico",
"branding": "Marca"
}
},
"errors": {
"generic": "Algo salió mal. Por favor intenta de nuevo.",
"networkError": "Error de red. Por favor verifica tu conexión.",
"unauthorized": "No estás autorizado para realizar esta acción.",
"notFound": "El recurso solicitado no fue encontrado.",
"validation": "Por favor verifica tu entrada e intenta de nuevo.",
"businessNotFound": "Negocio No Encontrado",
"wrongLocation": "Ubicación Incorrecta",
"accessDenied": "Acceso Denegado"
},
"validation": {
"required": "Este campo es requerido",
"email": "Por favor ingresa una dirección de correo válida",
"minLength": "Debe tener al menos {{min}} caracteres",
"maxLength": "Debe tener como máximo {{max}} caracteres",
"passwordMatch": "Las contraseñas no coinciden",
"invalidPhone": "Por favor ingresa un número de teléfono válido"
},
"time": {
"minutes": "minutos",
"hours": "horas",
"days": "días",
"today": "Hoy",
"tomorrow": "Mañana",
"yesterday": "Ayer",
"thisWeek": "Esta Semana",
"thisMonth": "Este Mes",
"am": "AM",
"pm": "PM"
},
"marketing": {
"tagline": "Orquesta tu negocio con precisión.",
"description": "La plataforma de agendamiento todo en uno para negocios de todos los tamaños. Gestiona recursos, personal y reservas sin esfuerzo.",
"copyright": "Smooth Schedule Inc.",
"nav": {
"features": "Características",
"pricing": "Precios",
"about": "Nosotros",
"contact": "Contacto",
"login": "Iniciar Sesión",
"getStarted": "Comenzar",
"startFreeTrial": "Prueba Gratuita"
},
"hero": {
"headline": "Programación Simple",
"subheadline": "La plataforma todo en uno para gestionar citas, recursos y clientes. Comienza gratis, escala según crezcas.",
"cta": "Comenzar Prueba Gratuita",
"secondaryCta": "Ver Demo",
"trustedBy": "Más de 1,000 empresas confían en nosotros"
},
"features": {
"title": "Todo lo que Necesitas",
"subtitle": "Características poderosas para tu negocio de servicios",
"scheduling": {
"title": "Programación Inteligente",
"description": "Calendario drag-and-drop con disponibilidad en tiempo real, recordatorios automáticos y detección de conflictos."
},
"resources": {
"title": "Gestión de Recursos",
"description": "Gestiona personal, salas y equipos. Configura disponibilidad, habilidades y reglas de reserva."
},
"customers": {
"title": "Portal de Clientes",
"description": "Portal de autoservicio para clientes. Ver historial, gestionar citas y guardar métodos de pago."
},
"payments": {
"title": "Pagos Integrados",
"description": "Acepta pagos online con Stripe. Depósitos, pagos completos y facturación automática."
},
"multiTenant": {
"title": "Soporte Multi-Ubicación",
"description": "Gestiona múltiples ubicaciones o marcas desde un solo panel con datos aislados."
},
"whiteLabel": {
"title": "Marca Blanca",
"description": "Dominio personalizado, branding y elimina la marca SmoothSchedule para una experiencia sin costuras."
},
"analytics": {
"title": "Analíticas e Informes",
"description": "Rastrea ingresos, citas y tendencias de clientes con hermosos dashboards."
},
"integrations": {
"title": "Integraciones Poderosas",
"description": "Conecta con Google Calendar, Zoom, Stripe y más. Acceso API para integraciones personalizadas."
}
},
"howItWorks": {
"title": "Comienza en Minutos",
"subtitle": "Tres pasos simples para transformar tu programación",
"step1": {
"title": "Crea tu Cuenta",
"description": "Regístrate gratis y configura tu perfil de negocio en minutos."
},
"step2": {
"title": "Añade tus Servicios",
"description": "Configura tus servicios, precios y recursos disponibles."
},
"step3": {
"title": "Comienza a Reservar",
"description": "Comparte tu enlace de reservas y deja que los clientes agenden al instante."
}
},
"pricing": {
"title": "Precios Simples y Transparentes",
"subtitle": "Comienza gratis, actualiza según crezcas. Sin cargos ocultos.",
"monthly": "Mensual",
"annual": "Anual",
"annualSave": "Ahorra 20%",
"perMonth": "/mes",
"period": "mes",
"popular": "Más Popular",
"mostPopular": "Más Popular",
"getStarted": "Comenzar",
"contactSales": "Contactar Ventas",
"freeTrial": "14 días de prueba gratis",
"noCredit": "Sin tarjeta de crédito requerida",
"features": "Características",
"tiers": {
"free": {
"name": "Gratis",
"description": "Perfecto para comenzar",
"price": "0",
"features": [
"Hasta 2 recursos",
"Programación básica",
"Gestión de clientes",
"Integración directa con Stripe",
"Subdominio (negocio.smoothschedule.com)",
"Soporte comunitario"
],
"transactionFee": "2.5% + $0.30 por transacción"
},
"professional": {
"name": "Profesional",
"description": "Para negocios en crecimiento",
"price": "29",
"annualPrice": "290",
"features": [
"Hasta 10 recursos",
"Dominio personalizado",
"Stripe Connect (menores comisiones)",
"Marca blanca",
"Recordatorios por email",
"Soporte email prioritario"
],
"transactionFee": "1.5% + $0.25 por transacción"
},
"business": {
"name": "Negocio",
"description": "Para equipos establecidos",
"price": "79",
"annualPrice": "790",
"features": [
"Recursos ilimitados",
"Todas las características Profesional",
"Gestión de equipo",
"Analíticas avanzadas",
"Acceso API",
"Soporte telefónico"
],
"transactionFee": "0.5% + $0.20 por transacción"
},
"enterprise": {
"name": "Empresarial",
"description": "Para grandes organizaciones",
"price": "Personalizado",
"features": [
"Todas las características Negocio",
"Integraciones personalizadas",
"Gerente de éxito dedicado",
"Garantías SLA",
"Contratos personalizados",
"Opción on-premise"
],
"transactionFee": "Comisiones de transacción personalizadas"
}
}
},
"testimonials": {
"title": "Amado por Negocios en Todas Partes",
"subtitle": "Mira lo que dicen nuestros clientes"
},
"stats": {
"appointments": "Citas Programadas",
"businesses": "Negocios",
"countries": "Países",
"uptime": "Tiempo de Actividad"
},
"signup": {
"title": "Crea tu Cuenta",
"subtitle": "Comienza tu prueba gratis hoy. Sin tarjeta de crédito requerida.",
"steps": {
"business": "Negocio",
"account": "Cuenta",
"plan": "Plan",
"confirm": "Confirmar"
},
"businessInfo": {
"title": "Cuéntanos sobre tu negocio",
"name": "Nombre del Negocio",
"namePlaceholder": "ej., Salón y Spa Acme",
"subdomain": "Elige tu Subdominio",
"checking": "Verificando disponibilidad...",
"available": "¡Disponible!",
"taken": "Ya está en uso"
},
"accountInfo": {
"title": "Crea tu cuenta de administrador",
"firstName": "Nombre",
"lastName": "Apellido",
"email": "Correo Electrónico",
"password": "Contraseña",
"confirmPassword": "Confirmar Contraseña"
},
"planSelection": {
"title": "Elige tu Plan"
},
"confirm": {
"title": "Revisa tus Datos",
"business": "Negocio",
"account": "Cuenta",
"plan": "Plan Seleccionado",
"terms": "Al crear tu cuenta, aceptas nuestros Términos de Servicio y Política de Privacidad."
},
"errors": {
"businessNameRequired": "El nombre del negocio es requerido",
"subdomainRequired": "El subdominio es requerido",
"subdomainTooShort": "El subdominio debe tener al menos 3 caracteres",
"subdomainInvalid": "El subdominio solo puede contener letras minúsculas, números y guiones",
"subdomainTaken": "Este subdominio ya está en uso",
"firstNameRequired": "El nombre es requerido",
"lastNameRequired": "El apellido es requerido",
"emailRequired": "El correo electrónico es requerido",
"emailInvalid": "Ingresa un correo electrónico válido",
"passwordRequired": "La contraseña es requerida",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"passwordMismatch": "Las contraseñas no coinciden",
"generic": "Algo salió mal. Por favor intenta de nuevo."
},
"success": {
"title": "¡Bienvenido a Smooth Schedule!",
"message": "Tu cuenta ha sido creada exitosamente.",
"yourUrl": "Tu URL de reservas",
"checkEmail": "Te hemos enviado un email de verificación. Por favor verifica tu email para activar todas las funciones.",
"goToLogin": "Ir al Inicio de Sesión"
},
"back": "Atrás",
"next": "Siguiente",
"creating": "Creando cuenta...",
"createAccount": "Crear Cuenta",
"haveAccount": "¿Ya tienes una cuenta?",
"signIn": "Iniciar sesión"
},
"faq": {
"title": "Preguntas Frecuentes",
"subtitle": "¿Tienes preguntas? Tenemos respuestas.",
"questions": {
"trial": {
"question": "¿Ofrecen prueba gratuita?",
"answer": "¡Sí! Todos los planes de pago incluyen 14 días de prueba gratis. No se requiere tarjeta de crédito para comenzar."
},
"cancel": {
"question": "¿Puedo cancelar en cualquier momento?",
"answer": "Absolutamente. Puedes cancelar tu suscripción en cualquier momento sin cargos de cancelación."
},
"payment": {
"question": "¿Qué métodos de pago aceptan?",
"answer": "Aceptamos todas las tarjetas de crédito principales a través de Stripe, incluyendo Visa, Mastercard y American Express."
},
"migrate": {
"question": "¿Puedo migrar desde otra plataforma?",
"answer": "¡Sí! Nuestro equipo puede ayudarte a migrar tus datos existentes desde otras plataformas de programación."
},
"support": {
"question": "¿Qué tipo de soporte ofrecen?",
"answer": "El plan gratuito incluye soporte comunitario. Profesional y superiores tienen soporte por email, y Negocio/Empresarial tienen soporte telefónico."
},
"customDomain": {
"question": "¿Cómo funcionan los dominios personalizados?",
"answer": "Los planes Profesional y superiores pueden usar tu propio dominio (ej., reservas.tunegocio.com) en lugar de nuestro subdominio."
}
}
},
"about": {
"title": "Sobre Smooth Schedule",
"subtitle": "Estamos en una misión para simplificar la programación para negocios en todas partes.",
"story": {
"title": "Nuestra Historia",
"content": "Smooth Schedule fue fundado con una simple creencia: la programación no debería ser complicada. Hemos construido una plataforma que facilita a negocios de todos los tamaños gestionar sus citas, recursos y clientes."
},
"mission": {
"title": "Nuestra Misión",
"content": "Empoderar negocios de servicios con las herramientas que necesitan para crecer, mientras dan a sus clientes una experiencia de reserva sin problemas."
},
"values": {
"title": "Nuestros Valores",
"simplicity": {
"title": "Simplicidad",
"description": "Creemos que el software poderoso aún puede ser simple de usar."
},
"reliability": {
"title": "Confiabilidad",
"description": "Tu negocio depende de nosotros, así que nunca comprometemos el tiempo de actividad."
},
"transparency": {
"title": "Transparencia",
"description": "Sin cargos ocultos, sin sorpresas. Lo que ves es lo que obtienes."
},
"support": {
"title": "Soporte",
"description": "Estamos aquí para ayudarte a tener éxito, en cada paso del camino."
}
}
},
"contact": {
"title": "Contáctanos",
"subtitle": "¿Tienes preguntas? Nos encantaría saber de ti.",
"form": {
"name": "Tu Nombre",
"namePlaceholder": "Juan Pérez",
"email": "Correo Electrónico",
"emailPlaceholder": "tu@ejemplo.com",
"subject": "Asunto",
"subjectPlaceholder": "¿Cómo podemos ayudarte?",
"message": "Mensaje",
"messagePlaceholder": "Cuéntanos más sobre tus necesidades...",
"submit": "Enviar Mensaje",
"sending": "Enviando...",
"success": "¡Gracias por contactarnos! Te responderemos pronto.",
"error": "Algo salió mal. Por favor intenta de nuevo."
},
"info": {
"email": "soporte@smoothschedule.com",
"phone": "+1 (555) 123-4567",
"address": "123 Schedule Street, San Francisco, CA 94102"
},
"sales": {
"title": "Habla con Ventas",
"description": "¿Interesado en nuestro plan Empresarial? A nuestro equipo de ventas le encantaría conversar."
}
},
"cta": {
"ready": "¿Listo para comenzar?",
"readySubtitle": "Únete a miles de negocios que ya usan SmoothSchedule.",
"startFree": "Comenzar Prueba Gratuita",
"noCredit": "Sin tarjeta de crédito requerida"
},
"footer": {
"product": "Producto",
"company": "Empresa",
"legal": "Legal",
"features": "Características",
"pricing": "Precios",
"integrations": "Integraciones",
"about": "Nosotros",
"blog": "Blog",
"careers": "Carreras",
"contact": "Contacto",
"terms": "Términos",
"privacy": "Privacidad",
"cookies": "Cookies",
"allRightsReserved": "Todos los derechos reservados."
}
}
}

View File

@@ -0,0 +1,688 @@
{
"common": {
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès",
"save": "Enregistrer",
"saveChanges": "Enregistrer les modifications",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"create": "Créer",
"update": "Mettre à jour",
"close": "Fermer",
"confirm": "Confirmer",
"back": "Retour",
"next": "Suivant",
"search": "Rechercher",
"filter": "Filtrer",
"actions": "Actions",
"settings": "Paramètres",
"reload": "Recharger",
"viewAll": "Voir Tout",
"learnMore": "En Savoir Plus",
"poweredBy": "Propulsé par",
"required": "Requis",
"optional": "Optionnel",
"masquerade": "Usurper",
"masqueradeAsUser": "Usurper l'identité de l'Utilisateur"
},
"auth": {
"signIn": "Se connecter",
"signOut": "Déconnexion",
"signingIn": "Connexion en cours...",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"enterUsername": "Entrez votre nom d'utilisateur",
"enterPassword": "Entrez votre mot de passe",
"welcomeBack": "Bon retour",
"pleaseEnterDetails": "Veuillez entrer vos informations pour vous connecter.",
"authError": "Erreur d'Authentification",
"invalidCredentials": "Identifiants invalides",
"orContinueWith": "Ou continuer avec",
"loginAtSubdomain": "Veuillez vous connecter sur le sous-domaine de votre entreprise. Le personnel et les clients ne peuvent pas se connecter depuis le site principal.",
"forgotPassword": "Mot de passe oublié ?",
"rememberMe": "Se souvenir de moi",
"twoFactorRequired": "Authentification à deux facteurs requise",
"enterCode": "Entrez le code de vérification",
"verifyCode": "Vérifier le Code"
},
"nav": {
"dashboard": "Tableau de Bord",
"scheduler": "Planificateur",
"customers": "Clients",
"resources": "Ressources",
"payments": "Paiements",
"messages": "Messages",
"staff": "Personnel",
"businessSettings": "Paramètres de l'Entreprise",
"profile": "Profil",
"platformDashboard": "Tableau de Bord Plateforme",
"businesses": "Entreprises",
"users": "Utilisateurs",
"support": "Support",
"platformSettings": "Paramètres Plateforme"
},
"dashboard": {
"title": "Tableau de Bord",
"welcome": "Bienvenue, {{name}} !",
"todayOverview": "Aperçu du Jour",
"upcomingAppointments": "Rendez-vous à Venir",
"recentActivity": "Activité Récente",
"quickActions": "Actions Rapides",
"totalRevenue": "Revenus Totaux",
"totalAppointments": "Total des Rendez-vous",
"newCustomers": "Nouveaux Clients",
"pendingPayments": "Paiements en Attente"
},
"scheduler": {
"title": "Planificateur",
"newAppointment": "Nouveau Rendez-vous",
"editAppointment": "Modifier le Rendez-vous",
"deleteAppointment": "Supprimer le Rendez-vous",
"selectResource": "Sélectionner la Ressource",
"selectService": "Sélectionner le Service",
"selectCustomer": "Sélectionner le Client",
"selectDate": "Sélectionner la Date",
"selectTime": "Sélectionner l'Heure",
"duration": "Durée",
"notes": "Notes",
"status": "Statut",
"confirmed": "Confirmé",
"pending": "En Attente",
"cancelled": "Annulé",
"completed": "Terminé",
"noShow": "Absent",
"today": "Aujourd'hui",
"week": "Semaine",
"month": "Mois",
"day": "Jour",
"timeline": "Chronologie",
"agenda": "Agenda",
"allResources": "Toutes les Ressources"
},
"customers": {
"title": "Clients",
"description": "Gérez votre base clients et consultez l'historique.",
"addCustomer": "Ajouter un Client",
"editCustomer": "Modifier le Client",
"customerDetails": "Détails du Client",
"name": "Nom",
"fullName": "Nom Complet",
"email": "Email",
"emailAddress": "Adresse Email",
"phone": "Téléphone",
"phoneNumber": "Numéro de Téléphone",
"address": "Adresse",
"city": "Ville",
"state": "État",
"zipCode": "Code Postal",
"tags": "Tags",
"tagsPlaceholder": "ex. VIP, Parrainage",
"tagsCommaSeparated": "Tags (séparés par des virgules)",
"appointmentHistory": "Historique des Rendez-vous",
"noAppointments": "Aucun rendez-vous pour l'instant",
"totalSpent": "Total Dépensé",
"totalSpend": "Dépenses Totales",
"lastVisit": "Dernière Visite",
"nextAppointment": "Prochain Rendez-vous",
"contactInfo": "Informations de Contact",
"status": "Statut",
"active": "Actif",
"inactive": "Inactif",
"never": "Jamais",
"customer": "Client",
"searchPlaceholder": "Rechercher par nom, email ou téléphone...",
"filters": "Filtres",
"noCustomersFound": "Aucun client trouvé correspondant à votre recherche.",
"addNewCustomer": "Ajouter un Nouveau Client",
"createCustomer": "Créer le Client",
"errorLoading": "Erreur lors du chargement des clients"
},
"staff": {
"title": "Personnel et Direction",
"description": "Gérez les comptes utilisateurs et les permissions.",
"inviteStaff": "Inviter du Personnel",
"name": "Nom",
"role": "Rôle",
"bookableResource": "Ressource Réservable",
"makeBookable": "Rendre Réservable",
"yes": "Oui",
"errorLoading": "Erreur lors du chargement du personnel",
"inviteModalTitle": "Inviter du Personnel",
"inviteModalDescription": "Le flux d'invitation utilisateur irait ici."
},
"resources": {
"title": "Ressources",
"description": "Gérez votre personnel, salles et équipements.",
"addResource": "Ajouter une Ressource",
"editResource": "Modifier la Ressource",
"resourceDetails": "Détails de la Ressource",
"resourceName": "Nom de la Ressource",
"name": "Nom",
"type": "Type",
"resourceType": "Type de Ressource",
"availability": "Disponibilité",
"services": "Services",
"schedule": "Horaire",
"active": "Actif",
"inactive": "Inactif",
"upcoming": "À Venir",
"appointments": "rdv",
"viewCalendar": "Voir le Calendrier",
"noResourcesFound": "Aucune ressource trouvée.",
"addNewResource": "Ajouter une Nouvelle Ressource",
"createResource": "Créer la Ressource",
"staffMember": "Membre du Personnel",
"room": "Salle",
"equipment": "Équipement",
"resourceNote": "Les ressources sont des espaces réservés pour la planification. Le personnel peut être assigné aux rendez-vous séparément.",
"errorLoading": "Erreur lors du chargement des ressources"
},
"services": {
"title": "Services",
"addService": "Ajouter un Service",
"editService": "Modifier le Service",
"name": "Nom",
"description": "Description",
"duration": "Durée",
"price": "Prix",
"category": "Catégorie",
"active": "Actif"
},
"payments": {
"title": "Paiements",
"transactions": "Transactions",
"invoices": "Factures",
"amount": "Montant",
"status": "Statut",
"date": "Date",
"method": "Méthode",
"paid": "Payé",
"unpaid": "Non Payé",
"refunded": "Remboursé",
"pending": "En Attente",
"viewDetails": "Voir les Détails",
"issueRefund": "Émettre un Remboursement",
"sendReminder": "Envoyer un Rappel",
"paymentSettings": "Paramètres de Paiement",
"stripeConnect": "Stripe Connect",
"apiKeys": "Clés API"
},
"settings": {
"title": "Paramètres",
"businessSettings": "Paramètres de l'Entreprise",
"businessSettingsDescription": "Gérez votre image de marque, domaine et politiques.",
"domainIdentity": "Domaine et Identité",
"bookingPolicy": "Politique de Réservation et d'Annulation",
"savedSuccessfully": "Paramètres enregistrés avec succès",
"general": "Général",
"branding": "Image de Marque",
"notifications": "Notifications",
"security": "Sécurité",
"integrations": "Intégrations",
"billing": "Facturation",
"businessName": "Nom de l'Entreprise",
"subdomain": "Sous-domaine",
"primaryColor": "Couleur Principale",
"secondaryColor": "Couleur Secondaire",
"logo": "Logo",
"uploadLogo": "Télécharger le Logo",
"timezone": "Fuseau Horaire",
"language": "Langue",
"currency": "Devise",
"dateFormat": "Format de Date",
"timeFormat": "Format d'Heure",
"oauth": {
"title": "Paramètres OAuth",
"enabledProviders": "Fournisseurs Activés",
"allowRegistration": "Autoriser l'Inscription via OAuth",
"autoLinkByEmail": "Lier automatiquement les comptes par email",
"customCredentials": "Identifiants OAuth Personnalisés",
"customCredentialsDesc": "Utilisez vos propres identifiants OAuth pour une expérience en marque blanche",
"platformCredentials": "Identifiants Plateforme",
"platformCredentialsDesc": "Utilisation des identifiants OAuth fournis par la plateforme",
"clientId": "ID Client",
"clientSecret": "Secret Client",
"paidTierOnly": "Les identifiants OAuth personnalisés ne sont disponibles que pour les forfaits payants"
}
},
"profile": {
"title": "Paramètres du Profil",
"personalInfo": "Informations Personnelles",
"changePassword": "Changer le Mot de Passe",
"twoFactor": "Authentification à Deux Facteurs",
"sessions": "Sessions Actives",
"emails": "Adresses Email",
"preferences": "Préférences",
"currentPassword": "Mot de Passe Actuel",
"newPassword": "Nouveau Mot de Passe",
"confirmPassword": "Confirmer le Mot de Passe",
"passwordChanged": "Mot de passe changé avec succès",
"enable2FA": "Activer l'Authentification à Deux Facteurs",
"disable2FA": "Désactiver l'Authentification à Deux Facteurs",
"scanQRCode": "Scanner le Code QR",
"enterBackupCode": "Entrer le Code de Secours",
"recoveryCodes": "Codes de Récupération"
},
"platform": {
"title": "Administration Plateforme",
"dashboard": "Tableau de Bord Plateforme",
"overview": "Aperçu de la Plateforme",
"overviewDescription": "Métriques globales pour tous les locataires.",
"mrrGrowth": "Croissance MRR",
"totalBusinesses": "Total des Entreprises",
"totalUsers": "Total des Utilisateurs",
"monthlyRevenue": "Revenus Mensuels",
"activeSubscriptions": "Abonnements Actifs",
"recentSignups": "Inscriptions Récentes",
"supportTickets": "Tickets Support",
"supportDescription": "Résoudre les problèmes signalés par les locataires.",
"reportedBy": "Signalé par",
"priority": "Priorité",
"businessManagement": "Gestion des Entreprises",
"userManagement": "Gestion des Utilisateurs",
"masquerade": "Usurper",
"masqueradeAs": "Usurper l'identité de",
"exitMasquerade": "Quitter l'Usurpation",
"businesses": "Entreprises",
"businessesDescription": "Gérer les locataires, les plans et les accès.",
"addNewTenant": "Ajouter un Nouveau Locataire",
"searchBusinesses": "Rechercher des entreprises...",
"businessName": "Nom de l'Entreprise",
"subdomain": "Sous-domaine",
"plan": "Plan",
"status": "Statut",
"joined": "Inscrit le",
"userDirectory": "Répertoire des Utilisateurs",
"userDirectoryDescription": "Voir et gérer tous les utilisateurs de la plateforme.",
"searchUsers": "Rechercher des utilisateurs par nom ou email...",
"allRoles": "Tous les Rôles",
"user": "Utilisateur",
"role": "Rôle",
"email": "Email",
"noUsersFound": "Aucun utilisateur trouvé correspondant à vos filtres.",
"roles": {
"superuser": "Super Utilisateur",
"platformManager": "Gestionnaire de Plateforme",
"businessOwner": "Propriétaire d'Entreprise",
"staff": "Personnel",
"customer": "Client"
},
"settings": {
"title": "Paramètres Plateforme",
"description": "Configurer les paramètres et intégrations de la plateforme",
"tiersPricing": "Niveaux et Tarification",
"oauthProviders": "Fournisseurs OAuth",
"general": "Général",
"oauth": "Fournisseurs OAuth",
"payments": "Paiements",
"email": "Email",
"branding": "Image de Marque"
}
},
"errors": {
"generic": "Une erreur s'est produite. Veuillez réessayer.",
"networkError": "Erreur réseau. Veuillez vérifier votre connexion.",
"unauthorized": "Vous n'êtes pas autorisé à effectuer cette action.",
"notFound": "La ressource demandée n'a pas été trouvée.",
"validation": "Veuillez vérifier vos données et réessayer.",
"businessNotFound": "Entreprise Non Trouvée",
"wrongLocation": "Mauvais Emplacement",
"accessDenied": "Accès Refusé"
},
"validation": {
"required": "Ce champ est requis",
"email": "Veuillez entrer une adresse email valide",
"minLength": "Doit contenir au moins {{min}} caractères",
"maxLength": "Doit contenir au maximum {{max}} caractères",
"passwordMatch": "Les mots de passe ne correspondent pas",
"invalidPhone": "Veuillez entrer un numéro de téléphone valide"
},
"time": {
"minutes": "minutes",
"hours": "heures",
"days": "jours",
"today": "Aujourd'hui",
"tomorrow": "Demain",
"yesterday": "Hier",
"thisWeek": "Cette Semaine",
"thisMonth": "Ce Mois",
"am": "AM",
"pm": "PM"
},
"marketing": {
"tagline": "Orchestrez votre entreprise avec précision.",
"description": "La plateforme de planification tout-en-un pour les entreprises de toutes tailles. Gérez les ressources, le personnel et les réservations sans effort.",
"copyright": "Smooth Schedule Inc.",
"nav": {
"features": "Fonctionnalités",
"pricing": "Tarifs",
"about": "À propos",
"contact": "Contact",
"login": "Connexion",
"getStarted": "Commencer",
"startFreeTrial": "Essai Gratuit"
},
"hero": {
"headline": "Planification Simplifiée",
"subheadline": "La plateforme tout-en-un pour gérer les rendez-vous, ressources et clients. Commencez gratuitement, évoluez selon vos besoins.",
"cta": "Commencer l'Essai Gratuit",
"secondaryCta": "Voir la Démo",
"trustedBy": "Plus de 1 000 entreprises nous font confiance"
},
"features": {
"title": "Tout ce dont Vous Avez Besoin",
"subtitle": "Des fonctionnalités puissantes pour votre entreprise de services",
"scheduling": {
"title": "Planification Intelligente",
"description": "Calendrier glisser-déposer avec disponibilité en temps réel, rappels automatiques et détection des conflits."
},
"resources": {
"title": "Gestion des Ressources",
"description": "Gérez le personnel, les salles et l'équipement. Configurez la disponibilité, les compétences et les règles de réservation."
},
"customers": {
"title": "Portail Client",
"description": "Portail en libre-service pour les clients. Consultez l'historique, gérez les rendez-vous et enregistrez les moyens de paiement."
},
"payments": {
"title": "Paiements Intégrés",
"description": "Acceptez les paiements en ligne avec Stripe. Acomptes, paiements complets et facturation automatique."
},
"multiTenant": {
"title": "Support Multi-Sites",
"description": "Gérez plusieurs sites ou marques depuis un seul tableau de bord avec des données isolées."
},
"whiteLabel": {
"title": "Marque Blanche",
"description": "Domaine personnalisé, branding et suppression de la marque SmoothSchedule pour une expérience fluide."
},
"analytics": {
"title": "Analyses et Rapports",
"description": "Suivez les revenus, rendez-vous et tendances clients avec de beaux tableaux de bord."
},
"integrations": {
"title": "Intégrations Puissantes",
"description": "Connectez-vous à Google Calendar, Zoom, Stripe et plus. Accès API pour des intégrations personnalisées."
}
},
"howItWorks": {
"title": "Commencez en Quelques Minutes",
"subtitle": "Trois étapes simples pour transformer votre planification",
"step1": {
"title": "Créez Votre Compte",
"description": "Inscrivez-vous gratuitement et configurez votre profil d'entreprise en quelques minutes."
},
"step2": {
"title": "Ajoutez Vos Services",
"description": "Configurez vos services, tarifs et ressources disponibles."
},
"step3": {
"title": "Commencez à Réserver",
"description": "Partagez votre lien de réservation et laissez les clients prendre rendez-vous instantanément."
}
},
"pricing": {
"title": "Tarifs Simples et Transparents",
"subtitle": "Commencez gratuitement, évoluez selon vos besoins. Pas de frais cachés.",
"monthly": "Mensuel",
"annual": "Annuel",
"annualSave": "Économisez 20%",
"perMonth": "/mois",
"period": "mois",
"popular": "Plus Populaire",
"mostPopular": "Plus Populaire",
"getStarted": "Commencer",
"contactSales": "Contacter les Ventes",
"freeTrial": "14 jours d'essai gratuit",
"noCredit": "Pas de carte de crédit requise",
"features": "Fonctionnalités",
"tiers": {
"free": {
"name": "Gratuit",
"description": "Parfait pour commencer",
"price": "0",
"features": [
"Jusqu'à 2 ressources",
"Planification de base",
"Gestion des clients",
"Intégration Stripe directe",
"Sous-domaine (entreprise.smoothschedule.com)",
"Support communautaire"
],
"transactionFee": "2,5% + 0,30€ par transaction"
},
"professional": {
"name": "Professionnel",
"description": "Pour les entreprises en croissance",
"price": "29",
"annualPrice": "290",
"features": [
"Jusqu'à 10 ressources",
"Domaine personnalisé",
"Stripe Connect (frais réduits)",
"Marque blanche",
"Rappels par email",
"Support email prioritaire"
],
"transactionFee": "1,5% + 0,25€ par transaction"
},
"business": {
"name": "Business",
"description": "Pour les équipes établies",
"price": "79",
"annualPrice": "790",
"features": [
"Ressources illimitées",
"Toutes les fonctionnalités Pro",
"Gestion d'équipe",
"Analyses avancées",
"Accès API",
"Support téléphonique"
],
"transactionFee": "0,5% + 0,20€ par transaction"
},
"enterprise": {
"name": "Entreprise",
"description": "Pour les grandes organisations",
"price": "Personnalisé",
"features": [
"Toutes les fonctionnalités Business",
"Intégrations personnalisées",
"Gestionnaire de succès dédié",
"Garanties SLA",
"Contrats personnalisés",
"Option sur site"
],
"transactionFee": "Frais de transaction personnalisés"
}
}
},
"testimonials": {
"title": "Apprécié par les Entreprises Partout",
"subtitle": "Découvrez ce que disent nos clients"
},
"stats": {
"appointments": "Rendez-vous Planifiés",
"businesses": "Entreprises",
"countries": "Pays",
"uptime": "Disponibilité"
},
"signup": {
"title": "Créez Votre Compte",
"subtitle": "Commencez votre essai gratuit aujourd'hui. Pas de carte de crédit requise.",
"steps": {
"business": "Entreprise",
"account": "Compte",
"plan": "Plan",
"confirm": "Confirmer"
},
"businessInfo": {
"title": "Parlez-nous de votre entreprise",
"name": "Nom de l'Entreprise",
"namePlaceholder": "ex., Salon & Spa Acme",
"subdomain": "Choisissez Votre Sous-domaine",
"checking": "Vérification de la disponibilité...",
"available": "Disponible !",
"taken": "Déjà pris"
},
"accountInfo": {
"title": "Créez votre compte administrateur",
"firstName": "Prénom",
"lastName": "Nom",
"email": "Adresse Email",
"password": "Mot de Passe",
"confirmPassword": "Confirmer le Mot de Passe"
},
"planSelection": {
"title": "Choisissez Votre Plan"
},
"confirm": {
"title": "Vérifiez Vos Informations",
"business": "Entreprise",
"account": "Compte",
"plan": "Plan Sélectionné",
"terms": "En créant votre compte, vous acceptez nos Conditions d'Utilisation et Politique de Confidentialité."
},
"errors": {
"businessNameRequired": "Le nom de l'entreprise est requis",
"subdomainRequired": "Le sous-domaine est requis",
"subdomainTooShort": "Le sous-domaine doit contenir au moins 3 caractères",
"subdomainInvalid": "Le sous-domaine ne peut contenir que des lettres minuscules, des chiffres et des tirets",
"subdomainTaken": "Ce sous-domaine est déjà pris",
"firstNameRequired": "Le prénom est requis",
"lastNameRequired": "Le nom est requis",
"emailRequired": "L'adresse email est requise",
"emailInvalid": "Veuillez entrer une adresse email valide",
"passwordRequired": "Le mot de passe est requis",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"passwordMismatch": "Les mots de passe ne correspondent pas",
"generic": "Une erreur s'est produite. Veuillez réessayer."
},
"success": {
"title": "Bienvenue sur Smooth Schedule !",
"message": "Votre compte a été créé avec succès.",
"yourUrl": "Votre URL de réservation",
"checkEmail": "Nous vous avons envoyé un email de vérification. Veuillez vérifier votre email pour activer toutes les fonctionnalités.",
"goToLogin": "Aller à la Connexion"
},
"back": "Retour",
"next": "Suivant",
"creating": "Création du compte...",
"createAccount": "Créer le Compte",
"haveAccount": "Vous avez déjà un compte ?",
"signIn": "Se connecter"
},
"faq": {
"title": "Questions Fréquentes",
"subtitle": "Des questions ? Nous avons les réponses.",
"questions": {
"trial": {
"question": "Proposez-vous un essai gratuit ?",
"answer": "Oui ! Tous les plans payants incluent 14 jours d'essai gratuit. Pas de carte de crédit requise pour commencer."
},
"cancel": {
"question": "Puis-je annuler à tout moment ?",
"answer": "Absolument. Vous pouvez annuler votre abonnement à tout moment sans frais d'annulation."
},
"payment": {
"question": "Quels moyens de paiement acceptez-vous ?",
"answer": "Nous acceptons toutes les principales cartes de crédit via Stripe, y compris Visa, Mastercard et American Express."
},
"migrate": {
"question": "Puis-je migrer depuis une autre plateforme ?",
"answer": "Oui ! Notre équipe peut vous aider à migrer vos données existantes depuis d'autres plateformes de planification."
},
"support": {
"question": "Quel type de support proposez-vous ?",
"answer": "Le plan gratuit inclut le support communautaire. Professionnel et supérieur ont le support email, et Business/Entreprise ont le support téléphonique."
},
"customDomain": {
"question": "Comment fonctionnent les domaines personnalisés ?",
"answer": "Les plans Professionnel et supérieur peuvent utiliser votre propre domaine (ex., reservations.votreentreprise.com) au lieu de notre sous-domaine."
}
}
},
"about": {
"title": "À propos de Smooth Schedule",
"subtitle": "Notre mission est de simplifier la planification pour les entreprises partout.",
"story": {
"title": "Notre Histoire",
"content": "Smooth Schedule a été fondé avec une conviction simple : la planification ne devrait pas être compliquée. Nous avons construit une plateforme qui facilite la gestion des rendez-vous, ressources et clients pour les entreprises de toutes tailles."
},
"mission": {
"title": "Notre Mission",
"content": "Donner aux entreprises de services les outils dont elles ont besoin pour croître, tout en offrant à leurs clients une expérience de réservation fluide."
},
"values": {
"title": "Nos Valeurs",
"simplicity": {
"title": "Simplicité",
"description": "Nous croyons qu'un logiciel puissant peut aussi être simple à utiliser."
},
"reliability": {
"title": "Fiabilité",
"description": "Votre entreprise dépend de nous, nous ne compromettons jamais la disponibilité."
},
"transparency": {
"title": "Transparence",
"description": "Pas de frais cachés, pas de surprises. Ce que vous voyez est ce que vous obtenez."
},
"support": {
"title": "Support",
"description": "Nous sommes là pour vous aider à réussir, à chaque étape."
}
}
},
"contact": {
"title": "Contactez-nous",
"subtitle": "Des questions ? Nous serions ravis de vous entendre.",
"form": {
"name": "Votre Nom",
"namePlaceholder": "Jean Dupont",
"email": "Adresse Email",
"emailPlaceholder": "vous@exemple.com",
"subject": "Sujet",
"subjectPlaceholder": "Comment pouvons-nous vous aider ?",
"message": "Message",
"messagePlaceholder": "Parlez-nous de vos besoins...",
"submit": "Envoyer le Message",
"sending": "Envoi en cours...",
"success": "Merci de nous avoir contactés ! Nous vous répondrons bientôt.",
"error": "Une erreur s'est produite. Veuillez réessayer."
},
"info": {
"email": "support@smoothschedule.com",
"phone": "+1 (555) 123-4567",
"address": "123 Schedule Street, San Francisco, CA 94102"
},
"sales": {
"title": "Parler aux Ventes",
"description": "Intéressé par notre plan Entreprise ? Notre équipe commerciale serait ravie d'échanger."
}
},
"cta": {
"ready": "Prêt à commencer ?",
"readySubtitle": "Rejoignez des milliers d'entreprises qui utilisent déjà SmoothSchedule.",
"startFree": "Commencer l'Essai Gratuit",
"noCredit": "Pas de carte de crédit requise"
},
"footer": {
"product": "Produit",
"company": "Entreprise",
"legal": "Légal",
"features": "Fonctionnalités",
"pricing": "Tarifs",
"integrations": "Intégrations",
"about": "À propos",
"blog": "Blog",
"careers": "Carrières",
"contact": "Contact",
"terms": "Conditions",
"privacy": "Confidentialité",
"cookies": "Cookies",
"allRightsReserved": "Tous droits réservés."
}
}
}

View File

@@ -0,0 +1,688 @@
{
"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."
}
}
}

View File

@@ -0,0 +1,688 @@
{
"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."
}
}
}

View File

@@ -0,0 +1,713 @@
{
"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": "前往登录"
}
}
}
}

View File

@@ -0,0 +1,35 @@
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: Inter, sans-serif;
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bfdbfe;
--color-brand-300: #93c5fd;
--color-brand-400: #60a5fa;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a8a;
}
:root {
font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
min-height: 100vh;
}

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect, useRef } from 'react';
import { Outlet, useLocation, useSearchParams, useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import TopBar from '../components/TopBar';
import TrialBanner from '../components/TrialBanner';
import { Business, User } from '../types';
import MasqueradeBanner from '../components/MasqueradeBanner';
import OnboardingWizard from '../components/OnboardingWizard';
import { useStopMasquerade } from '../hooks/useAuth';
import { MasqueradeStackEntry } from '../api/auth';
import { useScrollToTop } from '../hooks/useScrollToTop';
interface BusinessLayoutProps {
business: Business;
user: User;
darkMode: boolean;
toggleTheme: () => void;
onSignOut: () => void;
updateBusiness: (updates: Partial<Business>) => void;
}
const BusinessLayout: 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 mainContentRef = useRef<HTMLElement>(null);
const location = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useScrollToTop();
// Check for trial expiration and redirect
useEffect(() => {
// Don't check if already on trial-expired page
if (location.pathname === '/trial-expired') {
return;
}
// Redirect to trial-expired page if trial has expired
if (business.isTrialExpired && business.status === 'Trial') {
navigate('/trial-expired', { replace: true });
}
}, [business.isTrialExpired, business.status, location.pathname, navigate]);
// Masquerade logic - now using the stack system
const [masqueradeStack, setMasqueradeStack] = useState<MasqueradeStackEntry[]>([]);
const stopMasqueradeMutation = useStopMasquerade();
useEffect(() => {
const stackJson = localStorage.getItem('masquerade_stack');
if (stackJson) {
try {
setMasqueradeStack(JSON.parse(stackJson));
} catch (e) {
console.error('Failed to parse masquerade stack data', e);
}
}
}, []);
const handleStopMasquerade = () => {
stopMasqueradeMutation.mutate();
};
// Get the previous user from the stack (the one we'll return to)
const previousUser = masqueradeStack.length > 0
? {
id: masqueradeStack[masqueradeStack.length - 1].user_id,
username: masqueradeStack[masqueradeStack.length - 1].username,
name: masqueradeStack[masqueradeStack.length - 1].username,
role: masqueradeStack[masqueradeStack.length - 1].role,
email: '',
is_staff: false,
is_superuser: false,
} as User
: null;
// Get the original user (first in the stack)
const originalUser = masqueradeStack.length > 0
? {
id: masqueradeStack[0].user_id,
username: masqueradeStack[0].username,
name: masqueradeStack[0].username,
role: masqueradeStack[0].role,
email: '',
is_staff: false,
is_superuser: false,
} as User
: null;
useEffect(() => {
mainContentRef.current?.focus();
setIsMobileMenuOpen(false);
}, [location.pathname]);
// Check if returning from Stripe Connect onboarding
useEffect(() => {
const isOnboardingReturn = searchParams.get('onboarding') === 'true';
// Only show onboarding if returning from Stripe Connect
if (isOnboardingReturn) {
setShowOnboarding(true);
}
}, [searchParams]);
const handleOnboardingComplete = () => {
setShowOnboarding(false);
// Update local state immediately so wizard doesn't re-appear
updateBusiness({ initialSetupComplete: true });
};
const handleOnboardingSkip = () => {
setShowOnboarding(false);
// If they skip Stripe setup, disable payments
updateBusiness({ paymentsEnabled: false });
};
return (
<div className="flex h-full bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
<div className={`fixed inset-y-0 left-0 z-40 transform ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out md:hidden`}>
<Sidebar business={business} user={user} isCollapsed={false} toggleCollapse={() => { }} />
</div>
{isMobileMenuOpen && <div className="fixed inset-0 z-30 bg-black/50 md:hidden" onClick={() => setIsMobileMenuOpen(false)}></div>}
<div className="hidden md:flex md:flex-shrink-0">
<Sidebar business={business} user={user} isCollapsed={isCollapsed} toggleCollapse={() => setIsCollapsed(!isCollapsed)} />
</div>
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
{originalUser && (
<MasqueradeBanner
effectiveUser={user}
originalUser={originalUser}
previousUser={null}
onStop={handleStopMasquerade}
/>
)}
{/* Show trial banner if trial is active and payments not yet enabled */}
{business.isTrialActive && !business.paymentsEnabled && business.plan !== 'Free' && (
<TrialBanner business={business} />
)}
<TopBar
user={user}
isDarkMode={darkMode}
toggleTheme={toggleTheme}
onMenuClick={() => setIsMobileMenuOpen(true)}
/>
<main ref={mainContentRef} tabIndex={-1} className="flex-1 overflow-auto focus:outline-none">
{/* Pass all necessary context down to child routes */}
<Outlet context={{ user, business, updateBusiness }} />
</main>
</div>
{/* Onboarding wizard for paid-tier businesses */}
{showOnboarding && (
<OnboardingWizard
business={business}
onComplete={handleOnboardingComplete}
onSkip={handleOnboardingSkip}
/>
)}
</div>
);
};
export default BusinessLayout;

View File

@@ -0,0 +1,103 @@
import React, { useState, useEffect } from 'react';
import { Outlet, Link } from 'react-router-dom';
import { User, Business } from '../types';
import { LayoutDashboard, CalendarPlus, CreditCard } from 'lucide-react';
import MasqueradeBanner from '../components/MasqueradeBanner';
import UserProfileDropdown from '../components/UserProfileDropdown';
import { useStopMasquerade } from '../hooks/useAuth';
import { MasqueradeStackEntry } from '../api/auth';
import { useScrollToTop } from '../hooks/useScrollToTop';
interface CustomerLayoutProps {
business: Business;
user: User;
}
const CustomerLayout: React.FC<CustomerLayoutProps> = ({ business, user }) => {
useScrollToTop();
// Masquerade logic
const [masqueradeStack, setMasqueradeStack] = useState<MasqueradeStackEntry[]>([]);
const stopMasqueradeMutation = useStopMasquerade();
useEffect(() => {
const stackJson = localStorage.getItem('masquerade_stack');
if (stackJson) {
try {
setMasqueradeStack(JSON.parse(stackJson));
} catch (e) {
console.error('Failed to parse masquerade stack data', e);
}
}
}, []);
const handleStopMasquerade = () => {
stopMasqueradeMutation.mutate();
};
// Get the original user (first in the stack)
const originalUser = masqueradeStack.length > 0
? {
id: masqueradeStack[0].user_id,
username: masqueradeStack[0].username,
name: masqueradeStack[0].username,
role: masqueradeStack[0].role,
email: '',
is_staff: false,
is_superuser: false,
} as User
: null;
return (
<div className="h-full flex flex-col bg-gray-50 dark:bg-gray-900">
{originalUser && (
<MasqueradeBanner
effectiveUser={user}
originalUser={originalUser}
previousUser={null}
onStop={handleStopMasquerade}
/>
)}
<header
className="text-white shadow-md"
style={{ backgroundColor: business.primaryColor }}
>
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo and Business Name */}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 bg-white rounded-lg font-bold text-lg" style={{ color: business.primaryColor }}>
{business.name.charAt(0)}
</div>
<span className="font-bold text-lg">{business.name}</span>
</div>
{/* Navigation and User Menu */}
<div className="flex items-center gap-6">
<nav className="hidden md:flex gap-1">
<Link to="/" 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">
<LayoutDashboard size={16} /> Dashboard
</Link>
<Link to="/book" 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">
<CalendarPlus size={16} /> Book Appointment
</Link>
<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>
</nav>
<UserProfileDropdown user={user} variant="light" />
</div>
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet context={{ business, user }} />
</div>
</main>
</div>
);
};
export default CustomerLayout;

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Moon, Sun, Bell, Globe, Menu } from 'lucide-react';
import { User } from '../types';
import PlatformSidebar from '../components/PlatformSidebar';
import { useScrollToTop } from '../hooks/useScrollToTop';
interface ManagerLayoutProps {
user: User;
darkMode: boolean;
toggleTheme: () => void;
onSignOut: () => void;
}
const ManagerLayout: React.FC<ManagerLayoutProps> = ({ user, darkMode, toggleTheme, onSignOut }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
useScrollToTop();
return (
<div className="flex h-full bg-gray-100 dark:bg-gray-900">
{/* Mobile menu */}
<div className={`fixed inset-y-0 left-0 z-40 transform ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out md:hidden`}>
<PlatformSidebar user={user} isCollapsed={false} toggleCollapse={() => {}} onSignOut={onSignOut} />
</div>
{isMobileMenuOpen && <div className="fixed inset-0 z-30 bg-black/50 md:hidden" onClick={() => setIsMobileMenuOpen(false)}></div>}
{/* Static sidebar for desktop */}
<div className="hidden md:flex md:flex-shrink-0">
<PlatformSidebar user={user} isCollapsed={isCollapsed} toggleCollapse={() => setIsCollapsed(!isCollapsed)} onSignOut={onSignOut} />
</div>
{/* Main Content Area */}
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
<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">
<div className="flex items-center gap-4">
<button
onClick={() => setIsMobileMenuOpen(true)}
className="p-2 -ml-2 text-gray-500 rounded-md md:hidden hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Open sidebar"
>
<Menu size={24} />
</button>
<div className="hidden md:flex items-center text-gray-500 dark:text-gray-400 text-sm gap-2">
<Globe size={16} />
<span>smoothschedule.com</span>
<span className="mx-2 text-gray-300">/</span>
<span className="text-gray-900 dark:text-white font-medium">Management Console</span>
</div>
</div>
<div className="flex items-center gap-4">
<button
onClick={toggleTheme}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
>
{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>
</div>
</header>
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900 p-8">
<Outlet />
</main>
</div>
</div>
);
};
export default ManagerLayout;

View File

@@ -0,0 +1,43 @@
import React, { useState, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import Navbar from '../components/marketing/Navbar';
import Footer from '../components/marketing/Footer';
import { useScrollToTop } from '../hooks/useScrollToTop';
const MarketingLayout: React.FC = () => {
useScrollToTop();
const [darkMode, setDarkMode] = useState(() => {
// Check for saved preference or system preference
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
return JSON.parse(saved);
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
});
useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode);
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}, [darkMode]);
const toggleTheme = () => setDarkMode((prev: boolean) => !prev);
return (
<div className="min-h-screen flex flex-col bg-white dark:bg-gray-900 transition-colors duration-200">
<Navbar darkMode={darkMode} toggleTheme={toggleTheme} />
{/* Main Content - with padding for fixed navbar */}
<main className="flex-1 pt-16 lg:pt-20">
<Outlet />
</main>
<Footer />
</div>
);
};
export default MarketingLayout;

View File

@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Moon, Sun, Bell, Globe, Menu } from 'lucide-react';
import { User } from '../types';
import PlatformSidebar from '../components/PlatformSidebar';
import UserProfileDropdown from '../components/UserProfileDropdown';
import { useScrollToTop } from '../hooks/useScrollToTop';
interface PlatformLayoutProps {
user: User;
darkMode: boolean;
toggleTheme: () => void;
onSignOut: () => void;
}
const PlatformLayout: React.FC<PlatformLayoutProps> = ({ user, darkMode, toggleTheme, onSignOut }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
useScrollToTop();
return (
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
{/* Mobile menu */}
<div className={`fixed inset-y-0 left-0 z-40 transform ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} transition-transform duration-300 ease-in-out md:hidden`}>
<PlatformSidebar user={user} isCollapsed={false} toggleCollapse={() => {}} />
</div>
{isMobileMenuOpen && <div className="fixed inset-0 z-30 bg-black/50 md:hidden" onClick={() => setIsMobileMenuOpen(false)}></div>}
{/* Static sidebar for desktop */}
<div className="hidden md:flex md:flex-shrink-0">
<PlatformSidebar user={user} isCollapsed={isCollapsed} toggleCollapse={() => setIsCollapsed(!isCollapsed)} />
</div>
{/* Main Content Area */}
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Platform Top Bar */}
<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">
<div className="flex items-center gap-4">
<button
onClick={() => setIsMobileMenuOpen(true)}
className="p-2 -ml-2 text-gray-500 rounded-md md:hidden hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Open sidebar"
>
<Menu size={24} />
</button>
<div className="hidden md:flex items-center text-gray-500 dark:text-gray-400 text-sm gap-2">
<Globe size={16} />
<span>smoothschedule.com</span>
<span className="mx-2 text-gray-300">/</span>
<span className="text-gray-900 dark:text-white font-medium">Admin Console</span>
</div>
</div>
<div className="flex items-center gap-4">
<button
onClick={toggleTheme}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
>
{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>
<UserProfileDropdown user={user} />
</div>
</header>
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900 p-8">
<Outlet />
</main>
</div>
</div>
);
};
export default PlatformLayout;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Business } from '../types';
import { useScrollToTop } from '../hooks/useScrollToTop';
interface PublicSiteLayoutProps {
business: Business;
children: React.ReactNode;
}
const PublicSiteLayout: React.FC<PublicSiteLayoutProps> = ({ business, children }) => {
useScrollToTop();
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
<header
className="shadow-md"
style={{ backgroundColor: business.primaryColor }}
>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg font-bold text-xl" style={{ color: business.primaryColor }}>
{business.name.substring(0, 2).toUpperCase()}
</div>
<span className="font-bold text-xl text-white">{business.name}</span>
</div>
<nav className="flex items-center gap-4">
{/* FIX: Property 'websitePages' is optional. Added a check before mapping. */}
{business.websitePages && Object.entries(business.websitePages).map(([path, page]) => (
<Link key={path} to={path} className="text-sm font-medium text-white/80 hover:text-white transition-colors">{page.name}</Link>
))}
<Link to="/portal/dashboard" className="px-4 py-2 text-sm font-medium bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">
Customer Login
</Link>
</nav>
</div>
</header>
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
{children}
</main>
<footer className="bg-gray-100 dark:bg-gray-800 py-6 mt-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-500 dark:text-gray-400">
&copy; {new Date().getFullYear()} {business.name}. All Rights Reserved.
</div>
</footer>
</div>
);
};
export default PublicSiteLayout;

View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './i18n' // Initialize i18n
import App from './App.tsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,319 @@
import { Appointment, Business, Resource, User, Metric, Customer, PlatformMetric, Ticket, Blocker, Service } from './types';
export const CURRENT_BUSINESS: Business = {
id: 'b1',
name: 'Acme Auto Repair',
subdomain: 'acme-auto',
primaryColor: '#3B82F6', // Blue-500
secondaryColor: '#10B981', // Emerald-500
whitelabelEnabled: true,
plan: 'Business',
status: 'Active',
joinedAt: new Date('2023-01-15'),
resourcesCanReschedule: false,
requirePaymentMethodToBook: true,
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
};
// Tenant Users
export const CURRENT_USER: User = {
id: 'u1',
name: 'John Owner',
email: 'john@acme-auto.com',
role: 'owner',
avatarUrl: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
};
export const MANAGER_USER: User = {
id: 'u_manager_acme',
name: 'Manny Manager',
email: 'manny@acme-auto.com',
role: 'manager',
avatarUrl: 'https://images.unsplash.com/photo-1560250097-0b93528c311a?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
};
export const STAFF_USER: User = {
id: 'u_staff_main',
name: 'Stacy Staff',
email: 'stacy@acme-auto.com',
role: 'staff',
avatarUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
};
export const RESOURCE_USER: User = {
id: 'u_res_main',
username: 'bay3',
name: 'Service Bay 3',
email: 'bay3@internal.acme-auto.com',
role: 'resource',
avatarUrl: 'https://images.unsplash.com/photo-1581092918056-0c9c7e344934?ixlib=rb-1.2.1&auto=format&fit=crop&w=256&h=256&q=60',
};
export const CUSTOMER_USER: User = {
id: 'u_cust1',
username: 'alice',
name: 'Alice Smith',
email: 'alice@example.com',
role: 'customer',
avatarUrl: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
};
// Platform Users
export const SUPERUSER_USER: User = {
id: 'u_super',
name: 'Sarah Super',
email: 'sarah@smoothschedule.com',
role: 'superuser',
avatarUrl: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
};
export const PLATFORM_MANAGER_USER: User = {
id: 'u_manager',
name: 'Mike Manager',
email: 'mike@smoothschedule.com',
role: 'platform_manager',
avatarUrl: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
};
export const PLATFORM_SUPPORT_USER: User = {
id: 'u_support',
name: 'Sam Support',
email: 'sam@smoothschedule.com',
role: 'platform_support',
avatarUrl: 'https://images.unsplash.com/photo-1599566150163-29194dcaad36?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
};
const staffUserAcme: User = { id: 'u_staff_acme', name: 'Mike Mechanic', email: 'mike@acme-auto.com', role: 'staff', avatarUrl: 'https://randomuser.me/api/portraits/men/45.jpg' };
const staffUserTech: User = { id: 'u_staff_tech', name: 'Jen IT', email: 'jen@techsol.com', role: 'staff', avatarUrl: 'https://randomuser.me/api/portraits/women/44.jpg' };
export const RESOURCES: Resource[] = [
{ id: 'r1', name: 'Bay 1 (Lift)', type: 'ROOM' },
{ id: 'r2', name: 'Bay 2 (Lift)', type: 'ROOM' },
{ id: 'r3', name: 'Mike (Senior Mech)', type: 'STAFF', userId: staffUserAcme.id },
{ id: 'r4', name: 'Stacy Staff (Diag Tech)', type: 'STAFF', userId: STAFF_USER.id },
{ id: 'r5', name: 'Alignment Machine', type: 'EQUIPMENT' },
{ id: 'r6', name: 'Service Bay 3', type: 'ROOM', userId: RESOURCE_USER.id },
];
export const SERVICES: Service[] = [
{ id: 's1', name: 'Full Synthetic Oil Change', durationMinutes: 60, price: 89.99, description: 'Premium oil and filter change.' },
{ id: 's2', name: 'Brake Pad Replacement', durationMinutes: 120, price: 245.00, description: 'Front and rear brake pad replacement.' },
{ id: 's3', name: 'Engine Diagnostics', durationMinutes: 90, price: 120.00, description: 'Full computer diagnostics of engine.' },
{ id: 's4', name: 'Tire Rotation', durationMinutes: 45, price: 40.00, description: 'Rotate and balance all four tires.' },
{ id: 's5', name: '4-Wheel Alignment', durationMinutes: 60, price: 95.50, description: 'Precision laser alignment.' },
{ id: 's6', name: 'Tire Patch', durationMinutes: 30, price: 25.00, description: 'Repair minor tire punctures.' },
{ id: 's7', name: 'Vehicle Inspection', durationMinutes: 60, price: 75.00, description: 'Comprehensive multi-point vehicle inspection.' },
];
const dayOffset = (days: number) => {
const d = new Date();
d.setDate(d.getDate() + days);
return d;
}
const setTimeOnDate = (date: Date, hours: number, minutes: number) => {
const d = new Date(date);
d.setHours(hours, minutes, 0, 0);
return d;
};
const today = dayOffset(0);
const yesterday = dayOffset(-1);
const tomorrow = dayOffset(1);
const lastWeek = dayOffset(-7);
export const APPOINTMENTS: Appointment[] = [
// Today for other resources
{ id: 'a1', resourceId: 'r1', customerId: 'c1', customerName: 'Alice Smith', serviceId: 's1', startTime: setTimeOnDate(today, 9, 0), durationMinutes: 60, status: 'CONFIRMED' },
{ id: 'a2', resourceId: 'r3', customerId: 'c1', customerName: 'Alice Smith', serviceId: 's7', startTime: setTimeOnDate(today, 9, 0), durationMinutes: 60, status: 'CONFIRMED' },
{ id: 'a3', resourceId: 'r2', customerId: 'c2', customerName: 'Bob Jones', serviceId: 's2', startTime: setTimeOnDate(today, 10, 30), durationMinutes: 120, status: 'CONFIRMED' },
{ id: 'a4', resourceId: 'r4', customerId: 'c3', customerName: 'Charlie Day', serviceId: 's3', startTime: setTimeOnDate(today, 13, 0), durationMinutes: 90, status: 'COMPLETED' },
{ id: 'a5', resourceId: null, customerId: 'c4', customerName: 'Dana White', serviceId: 's4', startTime: setTimeOnDate(today, 14, 0), durationMinutes: 45, status: 'PENDING' },
{ id: 'a6', resourceId: 'r5', customerId: 'c5', customerName: 'Evan Stone', serviceId: 's5', startTime: setTimeOnDate(today, 11, 0), durationMinutes: 60, status: 'CONFIRMED' },
// Appointments for our Resource User (r6 / Service Bay 3)
// Today
{ id: 'a7', resourceId: 'r6', customerId: 'c6', customerName: 'Fiona Gallagher', serviceId: 's6', startTime: setTimeOnDate(today, 15, 0), durationMinutes: 30, status: 'CONFIRMED' },
{ id: 'a8', resourceId: 'r6', customerId: 'c7', customerName: 'George Costanza', serviceId: 's7', startTime: setTimeOnDate(today, 10, 0), durationMinutes: 45, status: 'CONFIRMED' },
{ id: 'a9', resourceId: 'r6', customerId: 'c8', customerName: 'Harry Potter', serviceId: 's3', startTime: setTimeOnDate(today, 14, 0), durationMinutes: 60, status: 'CONFIRMED' },
// Yesterday
{ id: 'a10', resourceId: 'r6', customerId: 'c9', customerName: 'Iris West', serviceId: 's5', startTime: setTimeOnDate(yesterday, 11, 0), durationMinutes: 90, status: 'COMPLETED' },
{ id: 'a11', resourceId: 'r6', customerId: 'c10', customerName: 'Jack Sparrow', serviceId: 's6', startTime: setTimeOnDate(yesterday, 14, 30), durationMinutes: 30, status: 'COMPLETED' },
{ id: 'a12', resourceId: 'r6', customerId: 'c11', customerName: 'Kara Danvers', serviceId: 's2', startTime: setTimeOnDate(yesterday, 9, 0), durationMinutes: 120, status: 'NO_SHOW' },
// Tomorrow
{ id: 'a13', resourceId: 'r6', customerId: 'c12', customerName: 'Luke Skywalker', serviceId: 's7', startTime: setTimeOnDate(tomorrow, 10, 0), durationMinutes: 180, status: 'CONFIRMED' },
// Past appointment for Alice Smith (CUSTOMER_USER)
{ id: 'a14', resourceId: 'r1', customerId: 'c1', customerName: 'Alice Smith', serviceId: 's1', startTime: setTimeOnDate(lastWeek, 10, 0), durationMinutes: 60, status: 'COMPLETED' }
];
export const BLOCKERS: Blocker[] = [
{ id: 'b1', resourceId: 'r6', startTime: setTimeOnDate(today, 12, 0), durationMinutes: 60, title: 'Lunch Break' },
{ id: 'b2', resourceId: 'r6', startTime: setTimeOnDate(tomorrow, 16, 0), durationMinutes: 30, title: 'Inventory Check' },
];
export const DASHBOARD_METRICS: Metric[] = [
{ label: 'Total Revenue', value: '$12,450', change: '+12%', trend: 'up' },
{ label: 'Appointments', value: '145', change: '+5%', trend: 'up' },
{ label: 'New Customers', value: '24', change: '-2%', trend: 'down' },
{ label: 'Avg. Ticket', value: '$85.90', change: '+8%', trend: 'up' },
];
const customerUserBob: User = { id: 'u_cust_bob', name: 'Bob Jones', email: 'bob@example.com', role: 'customer', avatarUrl: 'https://randomuser.me/api/portraits/men/12.jpg' };
const customerUserCharlie: User = { id: 'u_cust_charlie', name: 'Charlie Day', email: 'charlie@paddys.com', role: 'customer', avatarUrl: 'https://randomuser.me/api/portraits/men/22.jpg' };
export const CUSTOMERS: Customer[] = [
{
id: 'c1',
userId: CUSTOMER_USER.id,
name: 'Alice Smith',
email: 'alice@example.com',
phone: '(555) 123-4567',
city: 'New York',
state: 'NY',
zip: '10001',
totalSpend: 1250.50,
lastVisit: new Date('2023-10-15'),
status: 'Active',
avatarUrl: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
tags: ['VIP', 'Regular'],
paymentMethods: [
{ id: 'pm_1', brand: 'Visa', last4: '4242', isDefault: true },
{ id: 'pm_2', brand: 'Mastercard', last4: '5555', isDefault: false },
]
},
{
id: 'c2',
userId: customerUserBob.id,
name: 'Bob Jones',
email: 'bob.j@example.com',
phone: '(555) 987-6543',
city: 'Austin',
state: 'TX',
zip: '78701',
totalSpend: 450.00,
lastVisit: new Date('2023-09-20'),
status: 'Active',
avatarUrl: 'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
paymentMethods: [],
},
{
id: 'c3',
userId: customerUserCharlie.id,
name: 'Charlie Day',
email: 'charlie@paddys.com',
phone: '(555) 444-3333',
city: 'Philadelphia',
state: 'PA',
zip: '19103',
totalSpend: 89.99,
lastVisit: new Date('2023-10-01'),
status: 'Inactive',
tags: ['New'],
paymentMethods: [
{ id: 'pm_3', brand: 'Amex', last4: '0005', isDefault: true },
],
},
{
id: 'c4',
name: 'Dana White',
email: 'dana@ufc.fake',
phone: '(555) 777-8888',
city: 'Las Vegas',
state: 'NV',
zip: '89109',
totalSpend: 3200.00,
lastVisit: new Date('2023-10-25'),
status: 'Active',
avatarUrl: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
tags: ['Fleet'],
paymentMethods: [],
},
{
id: 'c5',
name: 'Evan Stone',
email: 'evan@stone.com',
phone: '(555) 222-1111',
totalSpend: 150.00,
lastVisit: null,
status: 'Active',
tags: ['Referral'],
paymentMethods: [],
},
{
id: 'c6',
name: 'Fiona Gallagher',
email: 'fiona@chicago.net',
phone: '(555) 666-9999',
city: 'Chicago',
state: 'IL',
zip: '60601',
totalSpend: 0.00,
lastVisit: null,
status: 'Blocked',
paymentMethods: [],
},
{
id: 'c7',
name: 'George Costanza',
email: 'george@vandelay.com',
phone: '(555) 555-5555',
city: 'New York',
state: 'NY',
zip: '10001',
totalSpend: 12.50,
lastVisit: new Date('2023-01-10'),
status: 'Inactive',
paymentMethods: [],
}
];
// --- Platform Mock Data ---
const createBiz = (overrides: Partial<Business>): Business => ({
...CURRENT_BUSINESS,
...overrides,
});
export const PLATFORM_METRICS: PlatformMetric[] = [
{ label: 'Monthly Recurring Revenue', value: '$425,900', change: '+15.2%', trend: 'up', color: 'blue' },
{ label: 'Active Businesses', value: '1,240', change: '+8.1%', trend: 'up', color: 'green' },
{ label: 'Avg. Revenue Per User', value: '$343', change: '+4.5%', trend: 'up', color: 'purple' },
{ label: 'Churn Rate', value: '2.4%', change: '-0.5%', trend: 'down', color: 'orange' },
];
export const ALL_BUSINESSES: Business[] = [
createBiz({ ...CURRENT_BUSINESS, plan: 'Business', status: 'Active' }),
createBiz({ id: 'b2', name: 'Prestige Worldwide', subdomain: 'prestige', primaryColor: '#000000', secondaryColor: '#ffffff', whitelabelEnabled: true, plan: 'Enterprise', status: 'Active', joinedAt: new Date('2022-11-10'), requirePaymentMethodToBook: false, cancellationWindowHours: 48, lateCancellationFeePercent: 100 }),
createBiz({ id: 'b3', name: 'Mom & Pop Shop', subdomain: 'mompop', primaryColor: '#e11d48', secondaryColor: '#fbbf24', whitelabelEnabled: false, plan: 'Free', status: 'Active', joinedAt: new Date('2023-03-05'), requirePaymentMethodToBook: false, cancellationWindowHours: 0, lateCancellationFeePercent: 0 }),
createBiz({ id: 'b4', name: 'Tech Solutions', subdomain: 'techsol', primaryColor: '#6366f1', secondaryColor: '#8b5cf6', whitelabelEnabled: true, plan: 'Professional', status: 'Trial', joinedAt: new Date('2023-10-20'), requirePaymentMethodToBook: true, cancellationWindowHours: 12, lateCancellationFeePercent: 25 }),
createBiz({ id: 'b5', name: 'Inactive Biz', subdomain: 'inactive', primaryColor: '#9ca3af', secondaryColor: '#d1d5db', whitelabelEnabled: false, plan: 'Free', status: 'Suspended', joinedAt: new Date('2021-05-15'), requirePaymentMethodToBook: false, cancellationWindowHours: 24, lateCancellationFeePercent: 0 }),
];
export const SUPPORT_TICKETS: Ticket[] = [
{ id: 't101', subject: 'Cannot connect custom domain', businessName: 'Prestige Worldwide', priority: 'High', status: 'Open', createdAt: new Date('2023-10-26T09:00:00') },
{ id: 't102', subject: 'Question about invoice #4022', businessName: 'Acme Auto Repair', priority: 'Low', status: 'In Progress', createdAt: new Date('2023-10-25T14:30:00') },
{ id: 't103', subject: 'Feature request: Group bookings', businessName: 'Tech Solutions', priority: 'Medium', status: 'Open', createdAt: new Date('2023-10-26T11:15:00') },
{ id: 't104', subject: 'Login issues for staff member', businessName: 'Mom & Pop Shop', priority: 'High', status: 'Resolved', createdAt: new Date('2023-10-24T16:45:00') },
];
export const ALL_USERS: User[] = [
SUPERUSER_USER,
PLATFORM_MANAGER_USER,
PLATFORM_SUPPORT_USER,
CURRENT_USER, // Owner of Acme
MANAGER_USER, // Manager of Acme
STAFF_USER, // Staff of Acme
RESOURCE_USER, // Resource of Acme
{ id: 'u_owner_prestige', name: 'Brennan Huff', email: 'brennan@prestige.com', role: 'owner', avatarUrl: 'https://randomuser.me/api/portraits/men/32.jpg' },
staffUserAcme,
staffUserTech,
CUSTOMER_USER, // Alice
customerUserBob,
customerUserCharlie,
];

View File

@@ -0,0 +1,282 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Customer, User } from '../types';
import { useCustomers, useCreateCustomer } from '../hooks/useCustomers';
import {
Search,
Plus,
MoreHorizontal,
Filter,
ArrowUpDown,
Mail,
Phone,
X,
Eye
} from 'lucide-react';
import Portal from '../components/Portal';
interface CustomersProps {
onMasquerade: (user: User) => void;
effectiveUser: User;
}
const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState<{ key: keyof Customer; direction: 'asc' | 'desc' }>({
key: 'name',
direction: 'asc'
});
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
tags: '',
city: '',
state: '',
zip: ''
});
const { data: customers = [], isLoading, error } = useCustomers();
const createCustomerMutation = useCreateCustomer();
const handleSort = (key: keyof Customer) => {
setSortConfig(current => ({
key,
direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc',
}));
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleAddCustomer = (e: React.FormEvent) => {
e.preventDefault();
const newCustomer: Partial<Customer> = {
phone: formData.phone,
city: formData.city,
state: formData.state,
zip: formData.zip,
status: 'Active',
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
};
createCustomerMutation.mutate(newCustomer);
setIsAddModalOpen(false);
setFormData({ name: '', email: '', phone: '', tags: '', city: '', state: '', zip: '' });
};
const filteredCustomers = useMemo(() => {
let sorted = [...customers];
if (searchTerm) {
const lowerTerm = searchTerm.toLowerCase();
sorted = sorted.filter(c =>
c.name.toLowerCase().includes(lowerTerm) ||
c.email.toLowerCase().includes(lowerTerm) ||
c.phone.includes(searchTerm)
);
}
sorted.sort((a, b) => {
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}, [customers, searchTerm, sortConfig]);
const canMasquerade = ['owner', 'manager', 'staff'].includes(effectiveUser.role);
if (isLoading) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-300">{t('customers.errorLoading')}: {(error as Error).message}</p>
</div>
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('customers.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('customers.description')}</p>
</div>
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Plus size={18} />
{t('customers.addCustomer')}
</button>
</div>
<div className="flex items-center justify-between gap-4 bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm transition-colors duration-200">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder={t('customers.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-colors duration-200 placeholder-gray-400 dark:placeholder-gray-500"
/>
</div>
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors duration-200">
<Filter size={16} />
{t('customers.filters')}
</button>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden transition-colors duration-200">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('name')}>
<div className="flex items-center gap-1">{t('customers.customer')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium">{t('customers.contactInfo')}</th>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors" onClick={() => handleSort('status')}>
<div className="flex items-center gap-1">{t('customers.status')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors text-right" onClick={() => handleSort('totalSpend')}>
<div className="flex items-center justify-end gap-1">{t('customers.totalSpend')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors text-right" onClick={() => handleSort('lastVisit')}>
<div className="flex items-center justify-end gap-1">{t('customers.lastVisit')} <ArrowUpDown size={14} className="text-gray-400" /></div>
</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{filteredCustomers.map((customer: any) => {
const customerUser = customer.user_data;
return (
<tr key={customer.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden border border-gray-200 dark:border-gray-600">
{customer.avatarUrl ? <img src={customer.avatarUrl} alt={customer.name} className="w-full h-full object-cover" /> : <span className="font-semibold text-gray-500 dark:text-gray-400">{customer.name.substring(0, 2).toUpperCase()}</span>}
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">{customer.name}</div>
{customer.tags && (<div className="flex gap-1 mt-1">{customer.tags.map(tag => (<span key={tag} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">{tag}</span>))}</div>)}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400"><Mail size={14} className="text-gray-400" />{customer.email}</div>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400"><Phone size={14} className="text-gray-400" />{customer.phone}</div>
</div>
</td>
<td className="px-6 py-4"><span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${customer.status === 'Active' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : customer.status === 'Inactive' ? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}`}>{customer.status}</span></td>
<td className="px-6 py-4 text-right font-medium text-gray-900 dark:text-white">${customer.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="px-6 py-4 text-right text-gray-600 dark:text-gray-400">{customer.lastVisit ? customer.lastVisit.toLocaleDateString() : <span className="text-gray-400 italic">{t('customers.never')}</span>}</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{canMasquerade && customerUser && (
<button
onClick={() => onMasquerade(customerUser)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
title={t('common.masqueradeAsUser')}
>
<Eye size={14} /> {t('common.masquerade')}
</button>
)}
<button 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 transition-colors">
<MoreHorizontal size={18} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{filteredCustomers.length === 0 && (<div className="p-12 text-center"><p className="text-gray-500 dark:text-gray-400">{t('customers.noCustomersFound')}</p></div>)}
</div>
</div>
{isAddModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-xl bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="flex items-center justify-between 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('customers.addNewCustomer')}</h3>
<button onClick={() => setIsAddModalOpen(false)} className="p-1 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"><X size={20} /></button>
</div>
<form onSubmit={handleAddCustomer} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.fullName')} <span className="text-red-500">*</span></label>
<input type="text" name="name" required value={formData.name} onChange={handleInputChange} className="w-full px-3 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 outline-none transition-colors" placeholder="e.g. John Doe" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.emailAddress')} <span className="text-red-500">*</span></label>
<input type="email" name="email" required value={formData.email} onChange={handleInputChange} className="w-full px-3 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 outline-none transition-colors" placeholder="e.g. john@example.com" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.phoneNumber')} <span className="text-red-500">*</span></label>
<input type="tel" name="phone" required value={formData.phone} onChange={handleInputChange} className="w-full px-3 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 outline-none transition-colors" placeholder="e.g. (555) 123-4567" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.city')}</label>
<input type="text" name="city" value={formData.city} onChange={handleInputChange} className="w-full px-3 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 outline-none transition-colors" placeholder={t('customers.city')} />
</div>
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.state')}</label>
<input type="text" name="state" value={formData.state} onChange={handleInputChange} className="w-full px-3 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 outline-none transition-colors" placeholder={t('customers.state')} />
</div>
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.zipCode')}</label>
<input type="text" name="zip" value={formData.zip} onChange={handleInputChange} className="w-full px-3 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 outline-none transition-colors" placeholder={t('customers.zipCode')} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.tagsCommaSeparated')}</label>
<input type="text" name="tags" value={formData.tags} onChange={handleInputChange} className="w-full px-3 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 outline-none transition-colors" placeholder={t('customers.tagsPlaceholder')} />
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={() => setIsAddModalOpen(false)} className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">{t('common.cancel')}</button>
<button type="submit" className="flex-1 px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors shadow-sm">{t('customers.createCustomer')}</button>
</div>
</form>
</div>
</div>
</Portal>
)}
</div>
);
};
export default Customers;

View File

@@ -0,0 +1,209 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line
} from 'recharts';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { useServices } from '../hooks/useServices';
import { useResources } from '../hooks/useResources';
import { useAppointments } from '../hooks/useAppointments';
import { useCustomers } from '../hooks/useCustomers';
import QuickAddAppointment from '../components/QuickAddAppointment';
interface Metric {
label: string;
value: string;
trend: 'up' | 'down' | 'neutral';
change: string;
}
const Dashboard: React.FC = () => {
const { t } = useTranslation();
const { data: services, isLoading: servicesLoading } = useServices();
const { data: resources, isLoading: resourcesLoading } = useResources();
const { data: appointments, isLoading: appointmentsLoading } = useAppointments();
const { data: customers, isLoading: customersLoading } = useCustomers();
const isLoading = servicesLoading || resourcesLoading || appointmentsLoading || customersLoading;
// Calculate metrics from real data
const metrics: Metric[] = useMemo(() => {
if (!appointments || !customers || !services || !resources) {
return [
{ label: t('dashboard.totalAppointments'), value: '0', trend: 'neutral', change: '0%' },
{ label: t('customers.title'), value: '0', trend: 'neutral', change: '0%' },
{ label: t('services.title'), value: '0', trend: 'neutral', change: '0%' },
{ label: t('resources.title'), value: '0', trend: 'neutral', change: '0%' },
];
}
const activeCustomers = customers.filter(c => c.status === 'Active').length;
return [
{ label: t('dashboard.totalAppointments'), value: appointments.length.toString(), trend: 'up', change: '+12%' },
{ label: t('customers.title'), value: activeCustomers.toString(), trend: 'up', change: '+8%' },
{ label: t('services.title'), value: services.length.toString(), trend: 'neutral', change: '0%' },
{ label: t('resources.title'), value: resources.length.toString(), trend: 'up', change: '+3%' },
];
}, [appointments, customers, services, resources, t]);
// Calculate weekly data from appointments
const weeklyData = useMemo(() => {
if (!appointments) {
return [
{ name: 'Mon', revenue: 0, appointments: 0 },
{ name: 'Tue', revenue: 0, appointments: 0 },
{ name: 'Wed', revenue: 0, appointments: 0 },
{ name: 'Thu', revenue: 0, appointments: 0 },
{ name: 'Fri', revenue: 0, appointments: 0 },
{ name: 'Sat', revenue: 0, appointments: 0 },
{ name: 'Sun', revenue: 0, appointments: 0 },
];
}
// Group appointments by day of week
const dayMap: { [key: string]: { revenue: number; count: number } } = {
'Mon': { revenue: 0, count: 0 },
'Tue': { revenue: 0, count: 0 },
'Wed': { revenue: 0, count: 0 },
'Thu': { revenue: 0, count: 0 },
'Fri': { revenue: 0, count: 0 },
'Sat': { revenue: 0, count: 0 },
'Sun': { revenue: 0, count: 0 },
};
appointments.forEach(appt => {
const date = new Date(appt.startTime);
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const dayName = dayNames[date.getDay()];
dayMap[dayName].count++;
// Use price from appointment or default to 0
dayMap[dayName].revenue += appt.price || 0;
});
return [
{ name: 'Mon', revenue: dayMap['Mon'].revenue, appointments: dayMap['Mon'].count },
{ name: 'Tue', revenue: dayMap['Tue'].revenue, appointments: dayMap['Tue'].count },
{ name: 'Wed', revenue: dayMap['Wed'].revenue, appointments: dayMap['Wed'].count },
{ name: 'Thu', revenue: dayMap['Thu'].revenue, appointments: dayMap['Thu'].count },
{ name: 'Fri', revenue: dayMap['Fri'].revenue, appointments: dayMap['Fri'].count },
{ name: 'Sat', revenue: dayMap['Sat'].revenue, appointments: dayMap['Sat'].count },
{ name: 'Sun', revenue: dayMap['Sun'].revenue, appointments: dayMap['Sun'].count },
];
}, [appointments]);
if (isLoading) {
return (
<div className="p-8 space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('dashboard.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('common.loading')}</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm animate-pulse">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
))}
</div>
</div>
);
}
return (
<div className="p-8 space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('dashboard.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('dashboard.todayOverview')}</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric, index) => (
<div key={index} className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{metric.label}</p>
<div className="flex items-baseline gap-2 mt-2">
<span className="text-2xl font-bold text-gray-900 dark:text-white">{metric.value}</span>
<span className={`flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${
metric.trend === 'up' ? 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400' :
metric.trend === 'down' ? 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400' : 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300'
}`}>
{metric.trend === 'up' && <TrendingUp size={12} className="mr-1" />}
{metric.trend === 'down' && <TrendingDown size={12} className="mr-1" />}
{metric.trend === 'neutral' && <Minus size={12} className="mr-1" />}
{metric.change}
</span>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Quick Add Appointment */}
<div className="lg:col-span-1">
<QuickAddAppointment />
</div>
{/* Revenue Chart */}
<div className="lg:col-span-2 p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
<h3 className="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{t('dashboard.totalRevenue')}</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
<YAxis axisLine={false} tickLine={false} tickFormatter={(value) => `$${value}`} tick={{ fill: '#9CA3AF' }} />
<Tooltip
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6'
}}
/>
<Bar dataKey="revenue" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Appointments Chart - Full Width */}
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
<h3 className="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{t('dashboard.upcomingAppointments')}</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={weeklyData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF' }} />
<Tooltip
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6'
}}
/>
<Line type="monotone" dataKey="appointments" stroke="#10b981" strokeWidth={3} dot={{ r: 4, fill: '#10b981' }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,201 @@
/**
* Email Verification Required Page
*
* Displayed when a user needs to verify their email address before accessing the application.
* Provides options to resend verification email and log out.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCurrentUser } from '../hooks/useAuth';
import apiClient from '../api/client';
import { useLogout } from '../hooks/useAuth';
const EmailVerificationRequired: React.FC = () => {
const { t } = useTranslation();
const { data: user } = useCurrentUser();
const logoutMutation = useLogout();
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleResendEmail = async () => {
setSending(true);
setError(null);
setSent(false);
try {
await apiClient.post('/api/auth/email/verify/send/');
setSent(true);
setTimeout(() => setSent(false), 5000); // Hide success message after 5 seconds
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to send verification email');
} finally {
setSending(false);
}
};
const handleLogout = () => {
logoutMutation.mutate();
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 px-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8">
{/* Icon */}
<div className="flex justify-center mb-6">
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center">
<svg
className="w-10 h-10 text-amber-600 dark:text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-white mb-2">
Email Verification Required
</h1>
{/* Message */}
<p className="text-center text-gray-600 dark:text-gray-400 mb-6">
Please verify your email address to access your account.
</p>
{/* Email Display */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
Verification email sent to:
</p>
<p className="text-base font-medium text-gray-900 dark:text-white break-all">
{user?.email}
</p>
</div>
{/* Instructions */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-800 dark:text-blue-300">
Check your inbox for a verification email and click the link to verify your account.
Don't forget to check your spam folder if you don't see it.
</p>
</div>
{/* Success Message */}
{sent && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-4">
<p className="text-sm text-green-800 dark:text-green-300 text-center">
Verification email sent successfully! Check your inbox.
</p>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<p className="text-sm text-red-800 dark:text-red-300 text-center">
{error}
</p>
</div>
)}
{/* Actions */}
<div className="space-y-3">
{/* Resend Email Button */}
<button
onClick={handleResendEmail}
disabled={sending || sent}
className="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
>
{sending ? (
<>
<svg
className="animate-spin h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Sending...
</>
) : sent ? (
<>
<svg
className="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
Email Sent
</>
) : (
<>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Resend Verification Email
</>
)}
</button>
{/* Logout Button */}
<button
onClick={handleLogout}
className="w-full px-4 py-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors duration-200"
>
Log Out
</button>
</div>
{/* Help Text */}
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
Need help? Contact support at{' '}
<a
href="mailto:support@smoothschedule.com"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
support@smoothschedule.com
</a>
</p>
</div>
</div>
);
};
export default EmailVerificationRequired;

View File

@@ -0,0 +1,249 @@
/**
* Login Page Component
* Professional login form connected to the API with visual improvements
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLogin } from '../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
import OAuthButtons from '../components/OAuthButtons';
import LanguageSelector from '../components/LanguageSelector';
import { AlertCircle, Loader2, User, Lock, ArrowRight } from 'lucide-react';
const LoginPage: React.FC = () => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const loginMutation = useLogin();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
loginMutation.mutate(
{ username, password },
{
onSuccess: (data) => {
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
// Check if we're on the root domain (no subdomain)
const isRootDomain = currentHostname === 'lvh.me' || currentHostname === 'localhost';
// Roles allowed to login at the root domain
const rootAllowedRoles = ['superuser', 'platform_manager', 'platform_support', 'owner'];
// If on root domain, only allow specific roles
if (isRootDomain && !rootAllowedRoles.includes(user.role)) {
setError(t('auth.loginAtSubdomain'));
return;
}
// Determine the correct subdomain based on user role
let targetSubdomain: string | null = null;
// Platform users (superuser, platform_manager, platform_support)
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform';
}
// Business users - redirect to their business subdomain
else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
// Check if we need to redirect to a different subdomain
// Need to redirect if we have a target subdomain AND we're not already on it
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
if (needsRedirect) {
// Pass tokens in URL to ensure they're available immediately on the new subdomain
// This avoids race conditions where cookies might not be set before the page loads
const portStr = currentPort ? `:${currentPort}` : '';
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`;
return;
}
// Already on correct subdomain - navigate to dashboard
navigate('/');
},
onError: (err: any) => {
setError(err.response?.data?.error || t('auth.invalidCredentials'));
},
}
);
};
return (
<div className="min-h-screen flex bg-white dark:bg-gray-900 transition-colors duration-200">
{/* Left Side - Image & Branding (Hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 relative bg-gray-900 text-white overflow-hidden">
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1497215728101-856f4ea42174?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1950&q=80')] bg-cover bg-center opacity-40"></div>
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-transparent to-gray-900/50"></div>
<div className="relative z-10 flex flex-col justify-between w-full p-12">
<div>
<div className="flex items-center gap-3 text-white/90">
<SmoothScheduleLogo className="w-8 h-8 text-brand-500" />
<span className="font-bold text-xl tracking-tight">Smooth Schedule</span>
</div>
</div>
<div className="space-y-6 max-w-md">
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
{t('marketing.tagline')}
</h1>
<p className="text-lg text-gray-300">
{t('marketing.description')}
</p>
<div className="flex gap-2 pt-4">
<div className="h-1 w-12 bg-brand-500 rounded-full"></div>
<div className="h-1 w-4 bg-gray-600 rounded-full"></div>
<div className="h-1 w-4 bg-gray-600 rounded-full"></div>
</div>
</div>
<div className="text-sm text-gray-500">
© {new Date().getFullYear()} {t('marketing.copyright')}
</div>
</div>
</div>
{/* Right Side - Login Form */}
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8 lg:w-1/2 xl:px-24 bg-gray-50 dark:bg-gray-900">
<div className="mx-auto w-full max-w-sm lg:max-w-md">
<div className="text-center lg:text-left mb-10">
<div className="lg:hidden flex justify-center mb-6">
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
</div>
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
{t('auth.welcomeBack')}
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t('auth.pleaseEnterDetails')}
</p>
</div>
{error && (
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50 animate-in fade-in slide-in-from-top-2">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
{t('auth.authError')}
</h3>
<div className="mt-1 text-sm text-red-700 dark:text-red-300">
{error}
</div>
</div>
</div>
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
{/* Username */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('auth.username')}
</label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
placeholder={t('auth.enterUsername')}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('auth.password')}
</label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70 disabled:cursor-not-allowed transition-all duration-200 ease-in-out transform active:scale-[0.98]"
>
{loginMutation.isPending ? (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-5 w-5" />
{t('auth.signingIn')}
</span>
) : (
<span className="flex items-center gap-2">
{t('auth.signIn')}
<ArrowRight className="h-4 w-4" />
</span>
)}
</button>
</form>
{/* OAuth Divider and Buttons */}
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">
{t('auth.orContinueWith')}
</span>
</div>
</div>
<div className="mt-6">
<OAuthButtons
disabled={loginMutation.isPending}
/>
</div>
</div>
{/* Language Selector */}
<div className="mt-8 flex justify-center">
<LanguageSelector />
</div>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,258 @@
/**
* OAuth Callback Page
* Handles OAuth provider redirects and completes authentication
*/
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { Loader2, AlertCircle, CheckCircle } from 'lucide-react';
import { handleOAuthCallback } from '../api/oauth';
import { setCookie } from '../utils/cookies';
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
const OAuthCallback: React.FC = () => {
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing');
const [errorMessage, setErrorMessage] = useState('');
const navigate = useNavigate();
const { provider } = useParams<{ provider: string }>();
const location = useLocation();
useEffect(() => {
const processCallback = async () => {
try {
// Check if we're in a popup window
const isPopup = window.opener && window.opener !== window;
// Extract OAuth callback parameters
// Try both query params and hash params (some providers use hash)
const searchParams = new URLSearchParams(location.search);
const hashParams = new URLSearchParams(location.hash.substring(1));
const code = searchParams.get('code') || hashParams.get('code');
const state = searchParams.get('state') || hashParams.get('state');
const error = searchParams.get('error') || hashParams.get('error');
const errorDescription = searchParams.get('error_description') || hashParams.get('error_description');
// Check for OAuth errors
if (error) {
const message = errorDescription || error || 'Authentication failed';
throw new Error(message);
}
// Validate required parameters
if (!code || !state) {
throw new Error('Missing required OAuth parameters');
}
if (!provider) {
throw new Error('Missing OAuth provider');
}
// Exchange code for tokens
const response = await handleOAuthCallback(provider, code, state);
// Store tokens in cookies (accessible across subdomains)
setCookie('access_token', response.access, 7);
setCookie('refresh_token', response.refresh, 7);
// Clear session cookie to prevent interference with JWT
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
setStatus('success');
// Determine redirect URL based on user role
const user = response.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
let targetUrl = '/';
let needsRedirect = false;
// Platform users (superuser, platform_manager, platform_support)
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
const targetHostname = 'platform.lvh.me';
needsRedirect = currentHostname !== targetHostname;
if (needsRedirect) {
const portStr = currentPort ? `:${currentPort}` : '';
targetUrl = `http://${targetHostname}${portStr}/`;
}
}
// Business users - redirect to their business subdomain
else if (user.business_subdomain) {
const targetHostname = `${user.business_subdomain}.lvh.me`;
needsRedirect = currentHostname !== targetHostname;
if (needsRedirect) {
const portStr = currentPort ? `:${currentPort}` : '';
targetUrl = `http://${targetHostname}${portStr}/`;
}
}
// Handle popup vs redirect flows
if (isPopup) {
// Post message to parent window
window.opener.postMessage(
{
type: 'oauth-success',
provider,
user: response.user,
needsRedirect,
targetUrl,
},
window.location.origin
);
// Close popup after short delay
setTimeout(() => {
window.close();
}, 1000);
} else {
// Standard redirect flow
setTimeout(() => {
if (needsRedirect) {
// Redirect to different subdomain
window.location.href = targetUrl;
} else {
// Navigate to dashboard on same subdomain
navigate(targetUrl);
}
}, 1500);
}
} catch (err: any) {
console.error('OAuth callback error:', err);
setStatus('error');
setErrorMessage(err.message || 'Authentication failed. Please try again.');
// If in popup, post error to parent
if (window.opener && window.opener !== window) {
window.opener.postMessage(
{
type: 'oauth-error',
provider,
error: err.message || 'Authentication failed',
},
window.location.origin
);
// Close popup after delay
setTimeout(() => {
window.close();
}, 3000);
}
}
};
processCallback();
}, [provider, location, navigate]);
const handleTryAgain = () => {
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
const portStr = currentPort ? `:${currentPort}` : '';
// Redirect to login page
if (currentHostname.includes('platform.lvh.me')) {
window.location.href = `http://platform.lvh.me${portStr}/login`;
} else if (currentHostname.includes('.lvh.me')) {
// On business subdomain - go to their login
window.location.href = `http://${currentHostname}${portStr}/login`;
} else {
// Fallback
navigate('/login');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full px-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
{/* Logo */}
<div className="flex justify-center mb-6">
<div className="flex items-center gap-3">
<SmoothScheduleLogo className="w-10 h-10 text-brand-500" />
<span className="font-bold text-xl tracking-tight text-gray-900 dark:text-white">
Smooth Schedule
</span>
</div>
</div>
{/* Processing State */}
{status === 'processing' && (
<div className="text-center">
<div className="flex justify-center mb-4">
<Loader2 className="w-12 h-12 text-blue-600 animate-spin" />
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Completing Sign In...
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please wait while we authenticate your account
</p>
</div>
)}
{/* Success State */}
{status === 'success' && (
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-green-100 dark:bg-green-900/30 p-3">
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Authentication Successful!
</h2>
<p className="text-gray-600 dark:text-gray-400">
Redirecting to your dashboard...
</p>
</div>
)}
{/* Error State */}
{status === 'error' && (
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-red-100 dark:bg-red-900/30 p-3">
<AlertCircle className="w-12 h-12 text-red-600 dark:text-red-400" />
</div>
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Authentication Failed
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{errorMessage}
</p>
<button
onClick={handleTryAgain}
className="w-full px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Try Again
</button>
</div>
)}
{/* Provider Info */}
{provider && status === 'processing' && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
Authenticating with{' '}
<span className="font-medium capitalize">{provider}</span>
</p>
</div>
)}
</div>
{/* Additional Help Text */}
{status === 'error' && (
<div className="mt-4 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
If the problem persists, please contact support
</p>
</div>
)}
</div>
</div>
);
};
export default OAuthCallback;

View File

@@ -0,0 +1,990 @@
/**
* Owner Scheduler - Horizontal timeline view for owner/manager/staff users
*/
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Appointment, User, Business } from '../types';
import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight } from 'lucide-react';
import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments';
import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket';
import Portal from '../components/Portal';
// Time settings
const START_HOUR = 0; // Midnight
const END_HOUR = 24; // Midnight next day
const PIXELS_PER_MINUTE = 2.5;
const HEADER_HEIGHT = 48;
const SIDEBAR_WIDTH = 250;
// Format duration as hours and minutes when >= 60 min
const formatDuration = (minutes: number): string => {
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
return `${minutes} min`;
};
// Layout settings
const MIN_ROW_HEIGHT = 104;
const EVENT_HEIGHT = 88;
const EVENT_GAP = 8;
interface OwnerSchedulerProps {
user: User;
business: Business;
}
const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
type ViewMode = 'day' | 'week' | 'month';
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [viewDate, setViewDate] = useState(new Date());
// Calculate date range for fetching appointments based on current view
const dateRange = useMemo(() => {
const getStartOfWeek = (date: Date): Date => {
const d = new Date(date);
const day = d.getDay();
d.setDate(d.getDate() - day);
d.setHours(0, 0, 0, 0);
return d;
};
if (viewMode === 'day') {
const start = new Date(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { startDate: start, endDate: end };
} else if (viewMode === 'week') {
const start = getStartOfWeek(viewDate);
const end = new Date(start);
end.setDate(end.getDate() + 7);
return { startDate: start, endDate: end };
} else {
// Month view
const start = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
const end = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1);
return { startDate: start, endDate: end };
}
}, [viewMode, viewDate]);
// Fetch only appointments in the visible date range (plus pending ones)
const { data: appointments = [] } = useAppointments(dateRange);
const { data: resources = [] } = useResources();
const { data: services = [] } = useServices();
const updateMutation = useUpdateAppointment();
const deleteMutation = useDeleteAppointment();
// Connect to WebSocket for real-time updates
useAppointmentWebSocket();
const [zoomLevel, setZoomLevel] = useState(1);
const [draggedAppointmentId, setDraggedAppointmentId] = useState<string | null>(null);
const [dragOffsetMinutes, setDragOffsetMinutes] = useState<number>(0); // Track where on appointment drag started
const [previewState, setPreviewState] = useState<{ resourceId: string; startTime: Date; } | null>(null);
const [resizeState, setResizeState] = useState<{ appointmentId: string; direction: 'start' | 'end'; startX: number; originalStart: Date; originalDuration: number; newStart?: Date; newDuration?: number; } | null>(null);
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
// State for editing appointments
const [editDateTime, setEditDateTime] = useState('');
const [editResource, setEditResource] = useState('');
const [editDuration, setEditDuration] = useState(0);
// Update edit state when selected appointment changes
useEffect(() => {
if (selectedAppointment) {
setEditDateTime(new Date(selectedAppointment.startTime).toISOString().slice(0, 16));
setEditResource(selectedAppointment.resourceId || '');
setEditDuration(selectedAppointment.durationMinutes);
}
}, [selectedAppointment]);
// Undo/Redo history
type HistoryAction = {
type: 'move' | 'resize';
appointmentId: string;
before: { startTime: Date; resourceId: string | null; durationMinutes?: number };
after: { startTime: Date; resourceId: string | null; durationMinutes?: number };
};
const [history, setHistory] = useState<HistoryAction[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Keyboard shortcuts for undo/redo
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
redo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [historyIndex, history]);
// Scroll to current time on mount (centered in view)
useEffect(() => {
if (!scrollContainerRef.current) return;
const now = new Date();
const today = new Date(viewDate);
today.setHours(0, 0, 0, 0);
const nowDay = new Date(now);
nowDay.setHours(0, 0, 0, 0);
// Only scroll if today is in the current view
if (viewMode === 'day' && nowDay.getTime() !== today.getTime()) return;
const container = scrollContainerRef.current;
const containerWidth = container.clientWidth;
// Calculate current time offset in pixels
const startOfDay = new Date(now);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60);
const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE * zoomLevel;
// Scroll so current time is centered
const scrollPosition = currentTimeOffset - (containerWidth / 2);
container.scrollLeft = Math.max(0, scrollPosition);
}, []);
const addToHistory = (action: HistoryAction) => {
// Remove any history after current index (when doing new action after undo)
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(action);
// Limit history to 50 actions
if (newHistory.length > 50) {
newHistory.shift();
} else {
setHistoryIndex(historyIndex + 1);
}
setHistory(newHistory);
};
const undo = () => {
if (historyIndex < 0) return;
const action = history[historyIndex];
const appointment = appointments.find(a => a.id === action.appointmentId);
if (!appointment) return;
// Revert to "before" state
updateMutation.mutate({
id: action.appointmentId,
updates: {
startTime: action.before.startTime,
resourceId: action.before.resourceId,
...(action.before.durationMinutes !== undefined && { durationMinutes: action.before.durationMinutes })
}
});
setHistoryIndex(historyIndex - 1);
};
const redo = () => {
if (historyIndex >= history.length - 1) return;
const action = history[historyIndex + 1];
const appointment = appointments.find(a => a.id === action.appointmentId);
if (!appointment) return;
// Apply "after" state
updateMutation.mutate({
id: action.appointmentId,
updates: {
startTime: action.after.startTime,
resourceId: action.after.resourceId,
...(action.after.durationMinutes !== undefined && { durationMinutes: action.after.durationMinutes })
}
});
setHistoryIndex(historyIndex + 1);
};
// Date navigation helpers
const getStartOfWeek = (date: Date): Date => {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day; // Sunday as start of week
return new Date(d.setDate(diff));
};
const getStartOfMonth = (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), 1);
};
const getEndOfMonth = (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
};
const navigateDate = (direction: 'prev' | 'next') => {
const newDate = new Date(viewDate);
if (viewMode === 'day') {
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
} else if (viewMode === 'week') {
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
} else if (viewMode === 'month') {
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
}
setViewDate(newDate);
};
const getDateRangeLabel = (): string => {
if (viewMode === 'day') {
return viewDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
} else if (viewMode === 'week') {
const weekStart = getStartOfWeek(viewDate);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
return `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
} else {
return viewDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
};
// Get the date range for filtering appointments
const getDateRange = (): { start: Date; end: Date; days: Date[] } => {
if (viewMode === 'day') {
const start = new Date(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end, days: [start] };
} else if (viewMode === 'week') {
const start = getStartOfWeek(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 7);
const days = Array.from({ length: 7 }, (_, i) => {
const day = new Date(start);
day.setDate(day.getDate() + i);
return day;
});
return { start, end, days };
} else {
const start = getStartOfMonth(viewDate);
start.setHours(0, 0, 0, 0);
const end = new Date(getEndOfMonth(viewDate));
end.setDate(end.getDate() + 1);
end.setHours(0, 0, 0, 0);
const daysInMonth = end.getDate() - start.getDate();
const days = Array.from({ length: daysInMonth }, (_, i) => {
const day = new Date(start);
day.setDate(day.getDate() + i);
return day;
});
return { start, end, days };
}
};
const handleResizeStart = (
e: React.MouseEvent,
appointment: Appointment,
direction: 'start' | 'end'
) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeState({
appointmentId: appointment.id,
direction,
startX: e.clientX,
originalStart: new Date(appointment.startTime),
originalDuration: appointment.durationMinutes,
});
};
useEffect(() => {
if (!resizeState) return;
const handleMouseMove = (e: MouseEvent) => {
const pixelDelta = e.clientX - resizeState.startX;
const minuteDelta = pixelDelta / (PIXELS_PER_MINUTE * zoomLevel);
const snappedMinutes = Math.round(minuteDelta / 15) * 15;
if (snappedMinutes === 0 && resizeState.direction === 'end') return;
const appointment = appointments.find(apt => apt.id === resizeState.appointmentId);
if (!appointment) return;
let newStart = new Date(resizeState.originalStart);
let newDuration = resizeState.originalDuration;
if (resizeState.direction === 'end') {
newDuration = Math.max(15, resizeState.originalDuration + snappedMinutes);
} else {
if (resizeState.originalDuration - snappedMinutes >= 15) {
newStart = new Date(resizeState.originalStart.getTime() + snappedMinutes * 60000);
newDuration = resizeState.originalDuration - snappedMinutes;
}
}
setResizeState(prev => prev ? { ...prev, newStart, newDuration } : null);
};
const handleMouseUp = () => {
if (resizeState && 'newStart' in resizeState && 'newDuration' in resizeState) {
const appointment = appointments.find(a => a.id === resizeState.appointmentId);
if (appointment) {
// Add to history
addToHistory({
type: 'resize',
appointmentId: resizeState.appointmentId,
before: {
startTime: resizeState.originalStart,
resourceId: appointment.resourceId,
durationMinutes: resizeState.originalDuration
},
after: {
startTime: resizeState.newStart as Date,
resourceId: appointment.resourceId,
durationMinutes: resizeState.newDuration as number
}
});
updateMutation.mutate({
id: resizeState.appointmentId,
updates: {
startTime: resizeState.newStart as Date,
durationMinutes: resizeState.newDuration as number
}
});
}
}
setResizeState(null);
// Reset isResizing after a brief delay to prevent click handler from firing
setTimeout(() => setIsResizing(false), 100);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [resizeState, zoomLevel, appointments, updateMutation]);
const getOffset = (date: Date) => {
const { days } = getDateRange();
// Find which day this appointment belongs to
const appointmentDate = new Date(date);
appointmentDate.setHours(0, 0, 0, 0);
let dayIndex = 0;
for (let i = 0; i < days.length; i++) {
const day = new Date(days[i]);
day.setHours(0, 0, 0, 0);
if (day.getTime() === appointmentDate.getTime()) {
dayIndex = i;
break;
}
}
// Calculate offset within the day
const startOfDay = new Date(date);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const diffMinutes = (date.getTime() - startOfDay.getTime()) / (1000 * 60);
const offsetWithinDay = Math.max(0, diffMinutes * (PIXELS_PER_MINUTE * zoomLevel));
// Add the day offset
const dayOffset = dayIndex * dayWidth;
return dayOffset + offsetWithinDay;
};
const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel);
const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
if (status === 'COMPLETED' || status === 'NO_SHOW') return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
if (status === 'CANCELLED') return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
const now = new Date();
if (now > endTime) return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200';
if (now >= startTime && now <= endTime) return 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200';
return 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200';
};
// Filter appointments by date range (but include all pending requests regardless of date)
const { start: rangeStart, end: rangeEnd } = getDateRange();
const filteredAppointments = useMemo(() => {
return appointments.filter(apt => {
// Always include pending requests (no resourceId)
if (!apt.resourceId) return true;
// Filter scheduled appointments by date range
const aptDate = new Date(apt.startTime);
return aptDate >= rangeStart && aptDate < rangeEnd;
});
}, [appointments, rangeStart, rangeEnd]);
const resourceLayouts = useMemo(() => {
return resources.map(resource => {
const allResourceApps = filteredAppointments.filter(a => a.resourceId === resource.id);
const layoutApps = allResourceApps.filter(a => a.id !== draggedAppointmentId);
// Add preview for dragged appointment
if (previewState && previewState.resourceId === resource.id && draggedAppointmentId) {
const original = filteredAppointments.find(a => a.id === draggedAppointmentId);
if (original) {
layoutApps.push({ ...original, startTime: previewState.startTime, id: 'PREVIEW' });
}
}
// Apply resize state to appointments for live preview
const layoutAppsWithResize = layoutApps.map(apt => {
if (resizeState && apt.id === resizeState.appointmentId && resizeState.newStart && resizeState.newDuration) {
return { ...apt, startTime: resizeState.newStart, durationMinutes: resizeState.newDuration };
}
return apt;
});
layoutAppsWithResize.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes);
const lanes: number[] = [];
const visibleAppointments = layoutAppsWithResize.map(apt => {
const start = new Date(apt.startTime).getTime();
const end = start + apt.durationMinutes * 60000;
let laneIndex = -1;
for (let i = 0; i < lanes.length; i++) {
if (lanes[i] <= start) {
laneIndex = i;
lanes[i] = end;
break;
}
}
if (laneIndex === -1) {
lanes.push(end);
laneIndex = lanes.length - 1;
}
return { ...apt, laneIndex };
});
const laneCount = Math.max(1, lanes.length);
const requiredHeight = Math.max(MIN_ROW_HEIGHT, (laneCount * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP);
const finalAppointments = [...visibleAppointments, ...allResourceApps.filter(a => a.id === draggedAppointmentId).map(a => ({ ...a, laneIndex: 0 }))];
return { resource, height: requiredHeight, appointments: finalAppointments, laneCount };
});
}, [filteredAppointments, draggedAppointmentId, previewState, resources, resizeState]);
const handleDragStart = (e: React.DragEvent, appointmentId: string) => {
if (resizeState) return e.preventDefault();
setIsDragging(true);
e.dataTransfer.setData('appointmentId', appointmentId);
e.dataTransfer.effectAllowed = 'move';
// Calculate where on the appointment the drag started (relative to appointment, not timeline)
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const offsetX = e.clientX - rect.left; // Just the offset within the appointment itself
const offsetMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
setDragOffsetMinutes(offsetMinutes);
setTimeout(() => setDraggedAppointmentId(appointmentId), 0);
};
const handleDragEnd = () => {
setDraggedAppointmentId(null);
setPreviewState(null);
// Reset isDragging after a short delay to allow click detection
setTimeout(() => setIsDragging(false), 100);
};
const handleAppointmentClick = (appointment: Appointment) => {
// Only open modal if we didn't actually drag or resize
if (!isDragging && !isResizing) {
setSelectedAppointment(appointment);
}
};
const handleSaveAppointment = () => {
if (!selectedAppointment) return;
// Validate duration is at least 15 minutes
const validDuration = editDuration >= 15 ? editDuration : 15;
const updates: any = {
startTime: new Date(editDateTime),
durationMinutes: validDuration,
};
if (editResource) {
updates.resourceId = editResource;
}
updateMutation.mutate({
id: selectedAppointment.id,
updates
});
setSelectedAppointment(null);
};
const handleTimelineDragOver = (e: React.DragEvent) => {
if (resizeState) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
if (!scrollContainerRef.current || !draggedAppointmentId) return;
const container = scrollContainerRef.current;
const rect = container.getBoundingClientRect();
const offsetX = e.clientX - rect.left + container.scrollLeft;
const offsetY = e.clientY - rect.top + container.scrollTop - HEADER_HEIGHT;
if (offsetY < 0) return;
let targetResourceId: string | null = null;
for (let i = 0, currentTop = 0; i < resourceLayouts.length; i++) {
if (offsetY >= currentTop && offsetY < currentTop + resourceLayouts[i].height) {
targetResourceId = resourceLayouts[i].resource.id; break;
}
currentTop += resourceLayouts[i].height;
}
if (!targetResourceId) return;
// Calculate new start time, accounting for where on the appointment the drag started
const mouseMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
const newStartMinutes = mouseMinutes - dragOffsetMinutes;
const newStartTime = new Date(viewDate);
newStartTime.setHours(START_HOUR, 0, 0, 0);
newStartTime.setTime(newStartTime.getTime() + newStartMinutes * 60000);
if (!previewState || previewState.resourceId !== targetResourceId || previewState.startTime.getTime() !== newStartTime.getTime()) {
setPreviewState({ resourceId: targetResourceId, startTime: newStartTime });
}
};
const handleTimelineDrop = (e: React.DragEvent) => {
e.preventDefault(); if (resizeState) return;
const appointmentId = e.dataTransfer.getData('appointmentId');
if (appointmentId && previewState) {
const appointment = appointments.find(a => a.id === appointmentId);
if (appointment) {
// Add to history
addToHistory({
type: 'move',
appointmentId,
before: {
startTime: new Date(appointment.startTime),
resourceId: appointment.resourceId,
durationMinutes: appointment.durationMinutes
},
after: {
startTime: previewState.startTime,
resourceId: previewState.resourceId,
durationMinutes: appointment.durationMinutes
}
});
updateMutation.mutate({
id: appointmentId,
updates: {
startTime: previewState.startTime,
durationMinutes: appointment.durationMinutes,
resourceId: previewState.resourceId,
status: appointment.status === 'PENDING' ? 'CONFIRMED' : appointment.status
}
});
}
}
setDraggedAppointmentId(null); setPreviewState(null);
};
const handleDropToPending = (e: React.DragEvent) => {
e.preventDefault();
const appointmentId = e.dataTransfer.getData('appointmentId');
if (appointmentId) {
updateMutation.mutate({
id: appointmentId,
updates: { resourceId: null, status: 'PENDING' }
});
}
setDraggedAppointmentId(null); setPreviewState(null);
};
const handleDropToArchive = (e: React.DragEvent) => {
e.preventDefault();
const appointmentId = e.dataTransfer.getData('appointmentId');
if (appointmentId) {
deleteMutation.mutate(appointmentId);
}
setDraggedAppointmentId(null); setPreviewState(null);
};
const handleSidebarDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
if (previewState) setPreviewState(null);
};
const { days } = getDateRange();
const dayWidth = (END_HOUR - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel);
const timelineWidth = dayWidth * days.length;
const timeMarkers = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const pendingAppointments = filteredAppointments.filter(a => !a.resourceId);
return (
<div className="flex flex-col h-full overflow-hidden select-none bg-white dark:bg-gray-900 transition-colors duration-200">
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm shrink-0 z-10 transition-colors duration-200">
<div className="flex items-center gap-4">
{/* Date Navigation */}
<div className="flex items-center gap-2">
<button
onClick={() => navigateDate('prev')}
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Previous"
>
<ChevronLeft size={20} />
</button>
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-md text-gray-700 dark:text-gray-200 font-medium transition-colors duration-200 w-[320px] justify-center">
<CalendarIcon size={16} />
<span className="text-center">{getDateRangeLabel()}</span>
</div>
<button
onClick={() => navigateDate('next')}
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Next"
>
<ChevronRight size={20} />
</button>
</div>
{/* View Mode Switcher */}
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
<button
onClick={() => setViewMode('day')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'day'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Day
</button>
<button
onClick={() => setViewMode('week')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'week'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Week
</button>
<button
onClick={() => setViewMode('month')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'month'
? 'bg-blue-500 text-white'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Month
</button>
</div>
<div className="flex items-center gap-2">
<button className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" onClick={() => setZoomLevel(Math.max(0.5, zoomLevel - 0.25))}>-</button>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Zoom</span>
<button className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" onClick={() => setZoomLevel(Math.min(2, zoomLevel + 0.25))}>+</button>
</div>
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
<button
onClick={undo}
disabled={historyIndex < 0}
className={`p-2 rounded transition-colors ${
historyIndex < 0
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Undo (Ctrl+Z)"
>
<Undo size={18} />
</button>
<button
onClick={redo}
disabled={historyIndex >= history.length - 1}
className={`p-2 rounded transition-colors ${
historyIndex >= history.length - 1
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Redo (Ctrl+Y)"
>
<Redo size={18} />
</button>
</div>
</div>
<div className="flex items-center gap-3">
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
+ New Appointment
</button>
<button className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 transition-colors">
<Filter size={18} />
</button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: SIDEBAR_WIDTH }}>
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: HEADER_HEIGHT }}>Resources</div>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="overflow-y-auto flex-1">
{resourceLayouts.map(layout => (
<div key={layout.resource.id} className="flex items-center px-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group" style={{ height: layout.height }}>
<div className="flex items-center gap-3 w-full">
<div className="flex items-center justify-center w-8 h-8 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0"><GripVertical size={16} /></div>
<div>
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resource.name}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">{layout.resource.type.toLowerCase()} {layout.laneCount > 1 && <span className="text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/50 px-1 rounded text-[10px]">{layout.laneCount} lanes</span>}</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className={`border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToPending}>
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0"><Clock size={12} /> Pending Requests ({pendingAppointments.length})</h3>
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
{pendingAppointments.length === 0 && !draggedAppointmentId && (<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>)}
{draggedAppointmentId && (<div className="border-2 border-dashed border-blue-300 dark:border-blue-700 rounded-lg p-4 text-center mb-2 bg-blue-50 dark:bg-blue-900/30"><span className="text-sm text-blue-600 dark:text-blue-400 font-medium">Drop here to unassign</span></div>)}
{pendingAppointments.map(apt => {
const service = services.find(s => s.id === apt.serviceId);
return (
<div
key={apt.id}
className={`p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-all ${draggedAppointmentId === apt.id ? 'opacity-50' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, apt.id)}
onDragEnd={handleDragEnd}
onClick={() => handleAppointmentClick(apt)}
>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{apt.customerName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{service?.name}</p>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} /> {formatDuration(apt.durationMinutes)}
</div>
</div>
)
})}
</div>
<div className={`shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 transition-all duration-200 ${draggedAppointmentId ? 'opacity-100 translate-y-0' : 'opacity-50 translate-y-0'}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToArchive}><div className={`flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed transition-colors ${draggedAppointmentId ? 'border-red-300 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400' : 'border-gray-200 dark:border-gray-700 bg-transparent text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-500'}`}><Trash2 size={16} /><span className="text-xs font-medium">Drop here to archive</span></div></div>
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-gray-900 relative transition-colors duration-200">
<div className="flex-1 overflow-auto timeline-scroll" ref={scrollContainerRef} onDragOver={handleTimelineDragOver} onDrop={handleTimelineDrop}>
<div style={{ width: timelineWidth, minWidth: '100%' }} className="relative min-h-full">
{/* Timeline Header */}
<div className="sticky top-0 z-10 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
{viewMode !== 'day' && (
<div className="flex border-b border-gray-200 dark:border-gray-700">
{days.map((day, dayIndex) => (
<div
key={dayIndex}
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-center bg-gray-100 dark:bg-gray-700/50"
style={{ width: dayWidth }}
>
{day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
</div>
))}
</div>
)}
<div className="flex" style={{ height: HEADER_HEIGHT }}>
{days.map((day, dayIndex) => (
<div key={dayIndex} className="flex flex-shrink-0" style={{ width: dayWidth }}>
{timeMarkers.map(hour => (
<div
key={`${dayIndex}-${hour}`}
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-xs font-medium text-gray-400 select-none"
style={{ width: 60 * (PIXELS_PER_MINUTE * zoomLevel) }}
>
{hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
</div>
))}
</div>
))}
</div>
</div>
{/* Current time indicator - only show if current day is in view */}
{days.some(day => {
const today = new Date();
const dayDate = new Date(day);
return today.toDateString() === dayDate.toDateString();
}) && (
<div
className="absolute top-0 bottom-0 border-l-2 border-red-500 z-30 pointer-events-none"
style={{ left: getOffset(new Date()), marginTop: viewMode === 'day' ? HEADER_HEIGHT : HEADER_HEIGHT * 2 }}
>
<div className="absolute -top-1 -left-1.5 w-3 h-3 bg-red-500 rounded-full"></div>
</div>
)}
<div className="relative">
{/* Vertical grid lines for each day */}
<div className="absolute inset-0 pointer-events-none">
{days.map((day, dayIndex) => (
<React.Fragment key={dayIndex}>
{timeMarkers.map(hour => (
<div
key={`${dayIndex}-${hour}`}
className="absolute top-0 bottom-0 border-r border-dashed border-gray-100 dark:border-gray-800"
style={{ left: (dayIndex * dayWidth) + ((hour - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel)) }}
></div>
))}
</React.Fragment>
))}
</div>
{resourceLayouts.map(layout => (<div key={layout.resource.id} className="relative border-b border-gray-100 dark:border-gray-800 transition-colors" style={{ height: layout.height }}>{layout.appointments.map(apt => {
const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP;
const service = services.find(s => s.id === apt.serviceId);
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}>
{!isPreview && (<><div className="absolute left-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginLeft: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'start')} /><div className="absolute right-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginRight: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'end')} /></>)}
<div className="font-semibold text-sm truncate pointer-events-none">{apt.customerName}</div><div className="text-xs truncate opacity-80 pointer-events-none">{service?.name}</div><div className="mt-2 flex items-center gap-1 text-xs opacity-75 pointer-events-none truncate">{apt.status === 'COMPLETED' ? <CheckCircle2 size={12} className="flex-shrink-0" /> : <Clock size={12} className="flex-shrink-0" />}<span className="truncate">{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span className="mx-1 flex-shrink-0"></span><span className="truncate">{formatDuration(apt.durationMinutes)}</span></div>
</div>);
})}</div>))}
</div>
</div>
</div>
</div>
</div>
{/* Appointment Detail/Edit Modal */}
{selectedAppointment && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setSelectedAppointment(null)}>
<div className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{!selectedAppointment.resourceId ? 'Schedule Appointment' : 'Edit Appointment'}
</h3>
<button onClick={() => setSelectedAppointment(null)} className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
{/* Customer Info */}
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center">
<UserIcon size={20} className="text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Customer</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">{selectedAppointment.customerName}</p>
{selectedAppointment.customerEmail && (
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
<Mail size={14} />
<span>{selectedAppointment.customerEmail}</span>
</div>
)}
{selectedAppointment.customerPhone && (
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
<Phone size={14} />
<span>{selectedAppointment.customerPhone}</span>
</div>
)}
</div>
</div>
{/* Service & Status */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Service</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{services.find(s => s.id === selectedAppointment.serviceId)?.name}</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Status</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white capitalize">{selectedAppointment.status.toLowerCase().replace('_', ' ')}</p>
</div>
</div>
{/* Editable Fields */}
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Schedule Details</h4>
{/* Date & Time Picker */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Date & Time
</label>
<input
type="datetime-local"
value={editDateTime}
onChange={(e) => setEditDateTime(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
{/* Resource Selector */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Assign to Resource {!selectedAppointment.resourceId && <span className="text-red-500">*</span>}
</label>
<select
value={editResource}
onChange={(e) => setEditResource(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">Unassigned</option>
{resources.map(resource => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
</div>
{/* Duration Input */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Duration (minutes) {!selectedAppointment.resourceId && <span className="text-red-500">*</span>}
</label>
<input
type="number"
min="15"
step="15"
value={editDuration || 15}
onChange={(e) => {
const value = parseInt(e.target.value);
setEditDuration(value >= 15 ? value : 15);
}}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
</div>
{/* Notes */}
{selectedAppointment.notes && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Notes</p>
<p className="text-sm text-gray-700 dark:text-gray-200">{selectedAppointment.notes}</p>
</div>
)}
{/* Action Buttons */}
<div className="pt-4 flex justify-end gap-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setSelectedAppointment(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveAppointment}
disabled={!selectedAppointment.resourceId && (!editResource || !editDuration || editDuration < 15)}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{!selectedAppointment.resourceId ? 'Schedule Appointment' : 'Save Changes'}
</button>
</div>
</div>
</div>
</div>
</Portal>
)}
</div>
);
};
export default OwnerScheduler;

View File

@@ -0,0 +1,870 @@
import React, { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
CreditCard,
Plus,
Trash2,
Star,
X,
TrendingUp,
DollarSign,
ArrowUpRight,
ArrowDownRight,
Download,
Filter,
Calendar,
Wallet,
BarChart3,
RefreshCcw,
FileSpreadsheet,
FileText,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
CheckCircle,
Clock,
XCircle,
ExternalLink,
Eye,
} from 'lucide-react';
import { User, Business, PaymentMethod, Customer } from '../types';
import { CUSTOMERS } from '../mockData';
import PaymentSettingsSection from '../components/PaymentSettingsSection';
import TransactionDetailModal from '../components/TransactionDetailModal';
import Portal from '../components/Portal';
import {
useTransactions,
useTransactionSummary,
useStripeBalance,
useStripePayouts,
useStripeCharges,
useExportTransactions,
} from '../hooks/useTransactionAnalytics';
import { usePaymentConfig } from '../hooks/usePayments';
import { TransactionFilters } from '../api/payments';
type TabType = 'overview' | 'transactions' | 'payouts' | 'settings';
const Payments: React.FC = () => {
const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>();
const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'manager';
const isCustomer = effectiveUser.role === 'customer';
// Tab state
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Filter state
const [filters, setFilters] = useState<TransactionFilters>({
status: 'all',
transaction_type: 'all',
page: 1,
page_size: 20,
});
const [showFilters, setShowFilters] = useState(false);
const [dateRange, setDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' });
// Export modal state
const [showExportModal, setShowExportModal] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'xlsx' | 'pdf' | 'quickbooks'>('csv');
// Transaction detail modal state
const [selectedTransactionId, setSelectedTransactionId] = useState<number | null>(null);
// Data hooks
const { data: paymentConfig } = usePaymentConfig();
const canAcceptPayments = paymentConfig?.can_accept_payments || false;
const activeFilters: TransactionFilters = {
...filters,
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
};
const { data: transactions, isLoading: transactionsLoading, refetch: refetchTransactions } = useTransactions(activeFilters);
const { data: summary, isLoading: summaryLoading } = useTransactionSummary({
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
});
const { data: balance, isLoading: balanceLoading } = useStripeBalance();
const { data: payoutsData, isLoading: payoutsLoading } = useStripePayouts(20);
const { data: chargesData } = useStripeCharges(10);
const exportMutation = useExportTransactions();
// Customer view state (for customer-facing)
const [customerProfile, setCustomerProfile] = useState<Customer | undefined>(
CUSTOMERS.find(c => c.userId === effectiveUser.id)
);
const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false);
// Customer handlers
const handleSetDefault = (pmId: string) => {
if (!customerProfile) return;
const updatedMethods = customerProfile.paymentMethods.map(pm => ({
...pm,
isDefault: pm.id === pmId
}));
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
};
const handleDeleteMethod = (pmId: string) => {
if (!customerProfile) return;
if (window.confirm("Are you sure you want to delete this payment method?")) {
const updatedMethods = customerProfile.paymentMethods.filter(pm => pm.id !== pmId);
if (updatedMethods.length > 0 && !updatedMethods.some(pm => pm.isDefault)) {
updatedMethods[0].isDefault = true;
}
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
}
};
const handleAddCard = (e: React.FormEvent) => {
e.preventDefault();
if (!customerProfile) return;
const newCard: PaymentMethod = {
id: `pm_${Date.now()}`,
brand: 'Visa',
last4: String(Math.floor(1000 + Math.random() * 9000)),
isDefault: customerProfile.paymentMethods.length === 0
};
const updatedMethods = [...customerProfile.paymentMethods, newCard];
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
setIsAddCardModalOpen(false);
};
// Export handler
const handleExport = () => {
exportMutation.mutate({
format: exportFormat,
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
});
setShowExportModal(false);
};
// Status badge helper
const getStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
succeeded: { bg: 'bg-green-100', text: 'text-green-800', icon: <CheckCircle size={12} /> },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: <Clock size={12} /> },
failed: { bg: 'bg-red-100', text: 'text-red-800', icon: <XCircle size={12} /> },
refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: <RefreshCcw size={12} /> },
partially_refunded: { bg: 'bg-orange-100', text: 'text-orange-800', icon: <RefreshCcw size={12} /> },
};
const style = styles[status] || styles.pending;
const displayStatus = status.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap ${style.bg} ${style.text}`}>
{style.icon}
{displayStatus}
</span>
);
};
// Format date helper
const formatDate = (dateStr: string | number) => {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
// Format time helper
const formatDateTime = (dateStr: string | number) => {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Business Owner/Manager View
if (isBusiness) {
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Payments & Analytics</h2>
<p className="text-gray-500 dark:text-gray-400">Manage payments and view transaction analytics</p>
</div>
{canAcceptPayments && (
<button
onClick={() => setShowExportModal(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
<Download size={16} />
Export Data
</button>
)}
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'transactions', label: 'Transactions', icon: CreditCard },
{ id: 'payouts', label: 'Payouts', icon: Wallet },
{ id: 'settings', label: 'Settings', icon: CreditCard },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-brand-500 text-brand-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<tab.icon size={18} />
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="space-y-6">
{!canAcceptPayments ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={24} />
<div>
<h3 className="font-semibold text-yellow-800">Payment Setup Required</h3>
<p className="text-yellow-700 mt-1">
Complete your payment setup in the Settings tab to start accepting payments and see analytics.
</p>
<button
onClick={() => setActiveTab('settings')}
className="mt-3 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-lg hover:bg-yellow-200"
>
Go to Settings
</button>
</div>
</div>
</div>
) : (
<>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Revenue */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Revenue</p>
<div className="p-2 bg-green-100 rounded-lg">
<DollarSign className="text-green-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.net_revenue_display || '$0.00'}
</p>
<p className="text-sm text-gray-500 mt-1">
{summary?.total_transactions || 0} transactions
</p>
</>
)}
</div>
{/* Available Balance */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Available Balance</p>
<div className="p-2 bg-blue-100 rounded-lg">
<Wallet className="text-blue-600" size={20} />
</div>
</div>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
${((balance?.available_total || 0) / 100).toFixed(2)}
</p>
<p className="text-sm text-gray-500 mt-1">
${((balance?.pending_total || 0) / 100).toFixed(2)} pending
</p>
</>
)}
</div>
{/* Success Rate */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Success Rate</p>
<div className="p-2 bg-purple-100 rounded-lg">
<TrendingUp className="text-purple-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.total_transactions
? ((summary.successful_transactions / summary.total_transactions) * 100).toFixed(1)
: '0'}%
</p>
<p className="text-sm text-green-600 mt-1 flex items-center gap-1">
<ArrowUpRight size={14} />
{summary?.successful_transactions || 0} successful
</p>
</>
)}
</div>
{/* Average Transaction */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Avg Transaction</p>
<div className="p-2 bg-orange-100 rounded-lg">
<BarChart3 className="text-orange-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.average_transaction_display || '$0.00'}
</p>
<p className="text-sm text-gray-500 mt-1">
Platform fees: {summary?.total_fees_display || '$0.00'}
</p>
</>
)}
</div>
</div>
{/* Recent Transactions */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Recent Transactions</h3>
<button
onClick={() => setActiveTab('transactions')}
className="text-sm text-brand-600 hover:text-brand-700 font-medium"
>
View All
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{transactionsLoading ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : transactions?.results?.length ? (
transactions.results.slice(0, 5).map((txn) => (
<tr
key={txn.id}
onClick={() => setSelectedTransactionId(txn.id)}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">
{txn.customer_name || 'Unknown'}
</p>
<p className="text-sm text-gray-500">{txn.customer_email}</p>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateTime(txn.created_at)}
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{txn.amount_display}</p>
<p className="text-xs text-gray-500">Fee: {txn.fee_display}</p>
</td>
<td className="px-6 py-4">
{getStatusBadge(txn.status)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
No transactions yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
)}
{activeTab === 'transactions' && (
<div className="space-y-4">
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Calendar size={16} className="text-gray-400" />
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Start date"
/>
<span className="text-gray-400">to</span>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="End date"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value as any, page: 1 })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Statuses</option>
<option value="succeeded">Succeeded</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="refunded">Refunded</option>
</select>
<select
value={filters.transaction_type}
onChange={(e) => setFilters({ ...filters, transaction_type: e.target.value as any, page: 1 })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Types</option>
<option value="payment">Payment</option>
<option value="refund">Refund</option>
</select>
<button
onClick={() => refetchTransactions()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
>
<RefreshCcw size={14} />
Refresh
</button>
</div>
</div>
{/* Transactions Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Transaction</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Net</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{transactionsLoading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : transactions?.results?.length ? (
transactions.results.map((txn) => (
<tr
key={txn.id}
onClick={() => setSelectedTransactionId(txn.id)}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
>
<td className="px-6 py-4">
<p className="text-sm font-mono text-gray-600">{txn.stripe_payment_intent_id.slice(0, 18)}...</p>
<p className="text-xs text-gray-400 capitalize">{txn.transaction_type}</p>
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">
{txn.customer_name || 'Unknown'}
</p>
<p className="text-sm text-gray-500">{txn.customer_email}</p>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateTime(txn.created_at)}
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{txn.amount_display}</p>
</td>
<td className="px-6 py-4">
<p className={`font-medium ${txn.transaction_type === 'refund' ? 'text-red-600' : 'text-green-600'}`}>
{txn.transaction_type === 'refund' ? '-' : ''}${(txn.net_amount / 100).toFixed(2)}
</p>
{txn.application_fee_amount > 0 && (
<p className="text-xs text-gray-400">-{txn.fee_display} fee</p>
)}
</td>
<td className="px-6 py-4">
{getStatusBadge(txn.status)}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedTransactionId(txn.id);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-brand-600 hover:text-brand-700 hover:bg-brand-50 rounded-lg transition-colors"
>
<Eye size={14} />
View
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
No transactions found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{transactions && transactions.total_pages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500">
Showing {(filters.page! - 1) * filters.page_size! + 1} to{' '}
{Math.min(filters.page! * filters.page_size!, transactions.count)} of {transactions.count}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setFilters({ ...filters, page: filters.page! - 1 })}
disabled={filters.page === 1}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={20} />
</button>
<span className="text-sm text-gray-600">
Page {filters.page} of {transactions.total_pages}
</span>
<button
onClick={() => setFilters({ ...filters, page: filters.page! + 1 })}
disabled={filters.page === transactions.total_pages}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={20} />
</button>
</div>
</div>
)}
</div>
</div>
)}
{activeTab === 'payouts' && (
<div className="space-y-6">
{/* Balance Summary */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-green-100 rounded-lg">
<Wallet className="text-green-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-500">Available for Payout</p>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400" size={20} />
) : (
<p className="text-2xl font-bold text-gray-900">
${((balance?.available_total || 0) / 100).toFixed(2)}
</p>
)}
</div>
</div>
{balance?.available?.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-t border-gray-100">
<span className="text-sm text-gray-500">{item.currency.toUpperCase()}</span>
<span className="font-medium">{item.amount_display}</span>
</div>
))}
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-yellow-100 rounded-lg">
<Clock className="text-yellow-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-500">Pending</p>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400" size={20} />
) : (
<p className="text-2xl font-bold text-gray-900">
${((balance?.pending_total || 0) / 100).toFixed(2)}
</p>
)}
</div>
</div>
{balance?.pending?.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-t border-gray-100">
<span className="text-sm text-gray-500">{item.currency.toUpperCase()}</span>
<span className="font-medium">{item.amount_display}</span>
</div>
))}
</div>
</div>
{/* Payouts List */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Payout History</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Payout ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Arrival Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{payoutsLoading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : payoutsData?.payouts?.length ? (
payoutsData.payouts.map((payout) => (
<tr key={payout.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-6 py-4">
<p className="text-sm font-mono text-gray-600">{payout.id}</p>
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{payout.amount_display}</p>
</td>
<td className="px-6 py-4">
{getStatusBadge(payout.status)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{payout.arrival_date ? formatDate(payout.arrival_date) : '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-500 capitalize">
{payout.method}
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
No payouts yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{activeTab === 'settings' && (
<PaymentSettingsSection business={business} />
)}
{/* Transaction Detail Modal */}
{selectedTransactionId && (
<TransactionDetailModal
transactionId={selectedTransactionId}
onClose={() => setSelectedTransactionId(null)}
/>
)}
{/* Export Modal */}
{showExportModal && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold">Export Transactions</h3>
<button onClick={() => setShowExportModal(false)} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Export Format</label>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'csv', label: 'CSV', icon: FileText },
{ id: 'xlsx', label: 'Excel', icon: FileSpreadsheet },
{ id: 'pdf', label: 'PDF', icon: FileText },
{ id: 'quickbooks', label: 'QuickBooks', icon: FileSpreadsheet },
].map((format) => (
<button
key={format.id}
onClick={() => setExportFormat(format.id as any)}
className={`flex items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
exportFormat === format.id
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<format.icon size={18} />
{format.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Date Range (Optional)</label>
<div className="flex items-center gap-2">
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
<span className="text-gray-400">to</span>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
</div>
<button
onClick={handleExport}
disabled={exportMutation.isPending}
className="w-full flex items-center justify-center gap-2 py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{exportMutation.isPending ? (
<>
<Loader2 className="animate-spin" size={18} />
Exporting...
</>
) : (
<>
<Download size={18} />
Export
</>
)}
</button>
</div>
</div>
</div>
</Portal>
)}
</div>
);
}
// Customer View
if (isCustomer && customerProfile) {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Billing</h2>
<p className="text-gray-500 dark:text-gray-400">Manage your payment methods and view invoice history.</p>
</div>
{/* Payment Methods */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Payment Methods</h3>
<button onClick={() => setIsAddCardModalOpen(true)} className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
<Plus size={16} /> Add Card
</button>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{customerProfile.paymentMethods.length > 0 ? customerProfile.paymentMethods.map((pm) => (
<div key={pm.id} className="p-6 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div className="flex items-center gap-4">
<CreditCard className="text-gray-400" size={24} />
<div>
<p className="font-medium text-gray-900 dark:text-white">{pm.brand} ending in {pm.last4}</p>
{pm.isDefault && <span className="text-xs font-medium text-green-600 dark:text-green-400">Default</span>}
</div>
</div>
<div className="flex items-center gap-2">
{!pm.isDefault && (
<button onClick={() => handleSetDefault(pm.id)} className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 font-medium">
<Star size={14} /> Set as Default
</button>
)}
<button onClick={() => handleDeleteMethod(pm.id)} className="p-2 text-gray-400 hover:text-red-500 dark:hover:text-red-400">
<Trash2 size={16} />
</button>
</div>
</div>
)) : <div className="p-8 text-center text-gray-500 dark:text-gray-400">No payment methods on file.</div>}
</div>
</div>
{/* Invoice History */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Invoice History</h3>
</div>
<div className="p-8 text-center text-gray-500">
No invoices yet.
</div>
</div>
{/* Add Card Modal */}
{isAddCardModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setIsAddCardModalOpen(false)}>
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold">Add New Card</h3>
<button onClick={() => setIsAddCardModalOpen(false)}><X size={20} /></button>
</div>
<form onSubmit={handleAddCard} className="p-6 space-y-4">
<div><label className="text-sm font-medium">Card Number</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"> 4242</div></div>
<div><label className="text-sm font-medium">Cardholder Name</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">{effectiveUser.name}</div></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="text-sm font-medium">Expiry</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">12 / 2028</div></div>
<div><label className="text-sm font-medium">CVV</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"></div></div>
</div>
<p className="text-xs text-gray-400 text-center">This is a simulated form. No real card data is required.</p>
<button type="submit" className="w-full py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700">Add Card</button>
</form>
</div>
</div>
</Portal>
)}
</div>
);
}
return <div>Access Denied or User not found.</div>;
};
export default Payments;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Link } from 'react-router-dom';
// FIX: PageComponent will be imported from types after the type definition is added.
import { Business, PageComponent } from '../types';
import { SERVICES } from '../mockData';
const RenderComponent: React.FC<{ component: PageComponent }> = ({ component }) => {
switch (component.type) {
case 'HEADING': {
// FIX: Replaced dynamic JSX tag with React.createElement to fix parsing errors.
const tag = `h${component.content?.level || 1}`;
const className = `font-bold text-gray-900 dark:text-white my-4 ${
component.content?.level === 1 ? 'text-4xl' :
component.content?.level === 2 ? 'text-2xl' : 'text-xl'
}`;
return React.createElement(tag, { className }, component.content?.text);
}
case 'TEXT':
return <p className="text-gray-600 dark:text-gray-300 my-4 leading-relaxed">{component.content?.text}</p>;
case 'IMAGE':
return <img src={component.content?.src} alt={component.content?.alt} className="rounded-lg my-4 max-w-full h-auto shadow-md" />;
case 'BUTTON':
return <a href={component.content?.href} className="inline-block px-6 py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700 my-4 shadow-sm transition-colors">{component.content?.buttonText}</a>;
case 'SERVICE':
const service = SERVICES.find(s => s.id === component.content?.serviceId);
if (!service) return <div className="text-red-500">Service not found</div>;
return (
<div className="p-6 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 my-4 shadow-sm">
<h4 className="text-xl font-bold">{service.name}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{service.description}</p>
<div className="flex items-center justify-between mt-4">
<span className="text-lg font-bold text-gray-900 dark:text-white">${service.price.toFixed(2)}</span>
<Link to="/portal/book" className="text-sm font-medium text-brand-600 hover:underline">Book Now &rarr;</Link>
</div>
</div>
);
case 'COLUMNS':
return (
<div className="flex flex-col md:flex-row gap-8 my-4">
{component.children?.map((col, colIndex) => (
<div key={colIndex} className="flex-1 space-y-4">
{col.map(child => <RenderComponent key={child.id} component={child} />)}
</div>
))}
</div>
);
default:
return null;
}
};
interface PublicSitePageProps {
business: Business;
path: string;
}
const PublicSitePage: React.FC<PublicSitePageProps> = ({ business, path }) => {
// FIX: Property 'websitePages' is optional. Added optional chaining.
const page = business.websitePages?.[path] || business.websitePages?.['/'];
if (!page) {
return <div>Page not found</div>;
}
return (
<div>
{page.content.map(component => (
<RenderComponent key={component.id} component={component} />
))}
</div>
);
};
export default PublicSitePage;

View File

@@ -0,0 +1,336 @@
/**
* Resource Scheduler - Vertical agenda view for resource users
*/
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Appointment, User, Business, Blocker } from '../types';
import { Clock, CheckCircle2, Lock, Plus, X, ChevronLeft, ChevronRight, Ban } from 'lucide-react';
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
import Portal from '../components/Portal';
// Time settings
const START_HOUR = 8;
const END_HOUR = 18;
const PIXELS_PER_MINUTE_VERTICAL = 2;
interface ResourceSchedulerProps {
user: User;
business: Business;
}
const ResourceScheduler: React.FC<ResourceSchedulerProps> = ({ user, business }) => {
const { data: appointments = [] } = useAppointments();
const { data: resources = [] } = useResources();
const { data: services = [] } = useServices();
const updateMutation = useUpdateAppointment();
const [blockers, setBlockers] = useState<Blocker[]>([]);
const [viewDate, setViewDate] = useState(new Date());
const [isBlockTimeModalOpen, setIsBlockTimeModalOpen] = useState(false);
const [newBlocker, setNewBlocker] = useState({ title: 'Break', startTime: '12:00', durationMinutes: 60 });
const agendaContainerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Scroll to current time on mount (centered in view)
useEffect(() => {
if (!scrollContainerRef.current) return;
const now = new Date();
const today = new Date();
today.setHours(0, 0, 0, 0);
const viewDay = new Date(viewDate);
viewDay.setHours(0, 0, 0, 0);
// Only scroll if viewing today
if (viewDay.getTime() !== today.getTime()) return;
const container = scrollContainerRef.current;
const containerHeight = container.clientHeight;
// Calculate current time offset in pixels (vertical)
const startOfDay = new Date(now);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60);
const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE_VERTICAL;
// Scroll so current time is centered
const scrollPosition = currentTimeOffset - (containerHeight / 2);
container.scrollTop = Math.max(0, scrollPosition);
}, []);
const isSameDay = (d1: Date, d2: Date) =>
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
const myResource = useMemo(() => resources.find(r => r.userId === user.id), [user.id, resources]);
const myAppointments = useMemo(
() => appointments
.filter(a => a.resourceId === myResource?.id && isSameDay(new Date(a.startTime), viewDate))
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()),
[appointments, myResource, viewDate]
);
const myBlockers = useMemo(
() => blockers
.filter(b => b.resourceId === myResource?.id && isSameDay(new Date(b.startTime), viewDate))
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()),
[blockers, myResource, viewDate]
);
const timeMarkersVertical = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i)
.flatMap(h => [`${h}:00`, `${h}:30`]);
const handleVerticalDragStart = (e: React.DragEvent, appointment: Appointment) => {
if (!business.resourcesCanReschedule || appointment.status === 'COMPLETED') {
return e.preventDefault();
}
e.dataTransfer.setData('appointmentId', appointment.id);
};
const handleVerticalDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (!business.resourcesCanReschedule || !agendaContainerRef.current) return;
const appointmentId = e.dataTransfer.getData('appointmentId');
const appointment = myAppointments.find(a => a.id === appointmentId);
if (!appointment || appointment.status === 'COMPLETED') return;
const rect = agendaContainerRef.current.getBoundingClientRect();
const dropY = e.clientY - rect.top;
const minutesFromStart = dropY / PIXELS_PER_MINUTE_VERTICAL;
const snappedMinutes = Math.round(minutesFromStart / 15) * 15;
const newStartTime = new Date(viewDate);
newStartTime.setHours(START_HOUR, snappedMinutes, 0, 0);
updateMutation.mutate({
id: appointmentId,
updates: { startTime: newStartTime }
});
};
const handleAddBlocker = () => {
const [hours, minutes] = newBlocker.startTime.split(':').map(Number);
const startTime = new Date(viewDate);
startTime.setHours(hours, minutes, 0, 0);
const newBlock: Blocker = {
id: `block_${Date.now()}`,
resourceId: myResource!.id,
title: newBlocker.title,
startTime,
durationMinutes: newBlocker.durationMinutes
};
setBlockers(prev => [...prev, newBlock]);
setIsBlockTimeModalOpen(false);
};
const getVerticalOffset = (date: Date) => {
const startOfDay = new Date(date);
startOfDay.setHours(START_HOUR, 0, 0, 0);
const diffMinutes = (date.getTime() - startOfDay.getTime()) / (1000 * 60);
return diffMinutes * PIXELS_PER_MINUTE_VERTICAL;
};
const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
if (status === 'COMPLETED' || status === 'NO_SHOW')
return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
if (status === 'CANCELLED')
return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
const now = new Date();
if (now > endTime)
return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200';
if (now >= startTime && now <= endTime)
return 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200';
return 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200';
};
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Schedule: {myResource?.name}</h2>
<p className="text-gray-500 dark:text-gray-400">
Viewing appointments for {viewDate.toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
})}.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsBlockTimeModalOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm"
>
<Plus size={16} /> Block Time
</button>
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
<button
onClick={() => setViewDate(d => new Date(d.setDate(d.getDate() - 1)))}
className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-l-md"
>
<ChevronLeft size={18} />
</button>
<button
onClick={() => setViewDate(new Date())}
className="px-3 py-1.5 text-sm font-semibold text-gray-700 dark:text-gray-200 border-x border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Today
</button>
<button
onClick={() => setViewDate(d => new Date(d.setDate(d.getDate() + 1)))}
className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-r-md"
>
<ChevronRight size={18} />
</button>
</div>
</div>
</div>
<div ref={scrollContainerRef} className="h-[70vh] overflow-y-auto timeline-scroll bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm flex">
{/* Time Gutter */}
<div className="w-20 text-right pr-4 border-r border-gray-100 dark:border-gray-700 shrink-0">
{timeMarkersVertical.map((time, i) => (
<div key={i} className="text-xs text-gray-400 relative" style={{ height: 30 * PIXELS_PER_MINUTE_VERTICAL }}>
{time.endsWith(':00') && <span className="absolute -top-1.5">{time}</span>}
</div>
))}
</div>
{/* Agenda */}
<div
ref={agendaContainerRef}
className="flex-1 relative"
onDragOver={(e) => { if (business.resourcesCanReschedule) e.preventDefault(); }}
onDrop={handleVerticalDrop}
>
<div style={{ height: (END_HOUR - START_HOUR) * 60 * PIXELS_PER_MINUTE_VERTICAL }} className="relative">
{timeMarkersVertical.map((_, i) => (
<div
key={i}
className={`absolute w-full ${i % 2 === 0 ? 'border-t border-gray-100 dark:border-gray-700' : 'border-t border-dashed border-gray-100 dark:border-gray-800'}`}
style={{ top: i * 30 * PIXELS_PER_MINUTE_VERTICAL }}
/>
))}
{[...myAppointments, ...myBlockers].map(item => {
const isAppointment = 'customerName' in item;
const startTime = new Date(item.startTime);
const endTime = new Date(startTime.getTime() + item.durationMinutes * 60000);
const isCompleted = isAppointment && item.status === 'COMPLETED';
const canDrag = business.resourcesCanReschedule && !isCompleted && isAppointment;
const colorClass = isAppointment
? getStatusColor(item.status, startTime, endTime)
: 'bg-gray-100 border-gray-300 text-gray-500 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-400';
const cursorClass = canDrag ? 'cursor-grab active:cursor-grabbing' : 'cursor-default';
const service = isAppointment ? services.find(s => s.id === (item as Appointment).serviceId) : null;
return (
<div
key={item.id}
draggable={canDrag}
onDragStart={(e) => isAppointment && handleVerticalDragStart(e, item as Appointment)}
className={`absolute left-2 right-2 rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${cursorClass}`}
style={{
top: getVerticalOffset(startTime),
height: item.durationMinutes * PIXELS_PER_MINUTE_VERTICAL,
zIndex: 10,
backgroundImage: !isAppointment ? `linear-gradient(45deg, rgba(0,0,0,0.05) 25%, transparent 25%, transparent 50%, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.05) 75%, transparent 75%, transparent)` : undefined,
backgroundSize: !isAppointment ? '20px 20px' : undefined
}}
>
<div className="font-semibold text-sm truncate flex items-center justify-between">
<span>{isAppointment ? (item as Appointment).customerName : item.title}</span>
{isCompleted && <Lock size={12} className="text-gray-400 shrink-0" />}
</div>
{isAppointment && <div className="text-xs truncate opacity-80">{service?.name}</div>}
<div className="mt-2 flex items-center gap-1 text-xs opacity-75">
{isAppointment && (item as Appointment).status === 'COMPLETED' ? (
<CheckCircle2 size={12} />
) : isAppointment ? (
<Clock size={12} />
) : (
<Ban size={12} />
)}
<span>
{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
{endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
{isBlockTimeModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setIsBlockTimeModalOpen(false)}>
<div className="w-full max-w-sm bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between 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">Add Time Off</h3>
<button onClick={() => setIsBlockTimeModalOpen(false)} className="p-1 text-gray-400 hover:bg-gray-100 rounded-full">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
<input
type="text"
value={newBlocker.title}
onChange={e => setNewBlocker(s => ({ ...s, title: e.target.value }))}
className="w-full px-3 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 outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
<input
type="time"
value={newBlocker.startTime}
onChange={e => setNewBlocker(s => ({ ...s, startTime: e.target.value }))}
className="w-full px-3 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 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duration (min)</label>
<input
type="number"
step="15"
min="15"
value={newBlocker.durationMinutes}
onChange={e => setNewBlocker(s => ({ ...s, durationMinutes: parseInt(e.target.value, 10) }))}
className="w-full px-3 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 outline-none"
/>
</div>
</div>
<div className="pt-2 flex justify-end gap-3">
<button
onClick={() => setIsBlockTimeModalOpen(false)}
className="px-4 py-2 text-sm font-medium rounded-lg"
>
Cancel
</button>
<button
onClick={handleAddBlocker}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700"
>
Add Block
</button>
</div>
</div>
</div>
</div>
</Portal>
)}
</div>
);
};
export default ResourceScheduler;

View File

@@ -0,0 +1,266 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ResourceType, User } from '../types';
import { useResources, useCreateResource } from '../hooks/useBusiness';
import { useAppointments } from '../hooks/useAppointments';
import ResourceCalendar from '../components/ResourceCalendar';
import Portal from '../components/Portal';
import {
Plus,
MoreHorizontal,
User as UserIcon,
Home,
Wrench,
Eye,
Calendar
} from 'lucide-react';
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
switch (type) {
case 'STAFF':
return <UserIcon size={16} className="text-blue-500" />;
case 'ROOM':
return <Home size={16} className="text-green-500" />;
case 'EQUIPMENT':
return <Wrench size={16} className="text-purple-500" />;
default:
return null;
}
};
interface ResourcesProps {
onMasquerade: (user: User) => void;
effectiveUser: User;
}
const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) => {
const { t } = useTranslation();
// All hooks must be called at the top, before any conditional returns
const { data: resources = [], isLoading, error } = useResources();
const [isAddModalOpen, setIsAddModalOpen] = React.useState(false);
const [newResourceType, setNewResourceType] = React.useState<ResourceType>('STAFF');
const [newResourceName, setNewResourceName] = React.useState('');
const [selectedResource, setSelectedResource] = React.useState<{ id: string; name: string } | null>(null);
const createResourceMutation = useCreateResource();
// Fetch ALL appointments (not filtered by date) to count per resource
// We filter client-side for future appointments
const { data: allAppointments = [] } = useAppointments();
// Count future appointments per resource
const appointmentCountByResource = useMemo(() => {
const counts: Record<string, number> = {};
const now = new Date();
allAppointments.forEach(apt => {
// Only count future appointments that have a resource assigned
if (apt.resourceId && new Date(apt.startTime) >= now) {
counts[apt.resourceId] = (counts[apt.resourceId] || 0) + 1;
}
});
return counts;
}, [allAppointments]);
const handleAddResource = (e: React.FormEvent) => {
e.preventDefault();
createResourceMutation.mutate({
name: newResourceName,
type: newResourceType
}, {
onSuccess: () => {
setIsAddModalOpen(false);
setNewResourceName('');
setNewResourceType('STAFF');
}
});
};
if (isLoading) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-8 max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-300">{t('resources.errorLoading')}: {(error as Error).message}</p>
</div>
</div>
);
}
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('resources.title')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('resources.description')}</p>
</div>
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Plus size={18} />
{t('resources.addResource')}
</button>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden transition-colors duration-200">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-4 font-medium">{t('resources.resourceName')}</th>
<th className="px-6 py-4 font-medium">{t('resources.type')}</th>
<th className="px-6 py-4 font-medium">{t('resources.upcoming')}</th>
<th className="px-6 py-4 font-medium">{t('scheduler.status')}</th>
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{resources.map((resource: any) => {
return (
<tr key={resource.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden border border-gray-200 dark:border-gray-600">
<ResourceIcon type={resource.type} />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">{resource.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${resource.type === 'STAFF' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' :
resource.type === 'ROOM' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
}`}>
{resource.type.toLowerCase()}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
<Calendar size={14} className="text-brand-500" />
<span className="font-medium">{appointmentCountByResource[String(resource.id)] || 0}</span>
<span className="text-xs text-gray-400">{t('resources.appointments')}</span>
</div>
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{t('resources.active')}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setSelectedResource({ id: resource.id, name: resource.name })}
className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"
title={`${t('resources.viewCalendar')} - ${resource.name}`}
>
<Eye size={14} /> {t('resources.viewCalendar')}
</button>
<button 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 transition-colors">
<MoreHorizontal size={18} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{resources.length === 0 && (
<div className="p-12 text-center">
<p className="text-gray-500 dark:text-gray-400">{t('resources.noResourcesFound')}</p>
</div>
)}
</div>
</div>
{/* Add Resource Modal */}
{isAddModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{t('resources.addNewResource')}</h3>
<button onClick={() => setIsAddModalOpen(false)} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
<span className="sr-only">{t('common.close')}</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleAddResource} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('resources.resourceType')}</label>
<select
value={newResourceType}
onChange={(e) => setNewResourceType(e.target.value as ResourceType)}
className="w-full rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-brand-500 focus:border-brand-500"
>
<option value="STAFF">{t('resources.staffMember')}</option>
<option value="ROOM">{t('resources.room')}</option>
<option value="EQUIPMENT">{t('resources.equipment')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('resources.resourceName')}</label>
<input
type="text"
value={newResourceName}
onChange={(e) => setNewResourceName(e.target.value)}
className="w-full rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-brand-500 focus:border-brand-500"
placeholder={newResourceType === 'STAFF' ? 'e.g. Sarah (Stylist)' : newResourceType === 'ROOM' ? 'e.g. Massage Room 1' : 'e.g. Laser Machine'}
required
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('resources.resourceNote')}
</p>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setIsAddModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700"
>
{t('resources.createResource')}
</button>
</div>
</form>
</div>
</div>
</Portal>
)}
{/* Resource Calendar Modal */}
{selectedResource && (
<ResourceCalendar
resourceId={selectedResource.id}
resourceName={selectedResource.name}
onClose={() => setSelectedResource(null)}
/>
)}
</div>
);
};
export default Resources;

Some files were not shown because too many files have changed in this diff Show More