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,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,180 @@
import { useState } from 'react';
import apiClient from '../api/client';
import { setCookie } from '../utils/cookies';
import { useQueryClient } from '@tanstack/react-query';
interface TestUser {
username: string;
password: string;
role: string;
label: string;
color: string;
}
const testUsers: TestUser[] = [
{
username: 'superuser',
password: 'test123',
role: 'SUPERUSER',
label: 'Platform Superuser',
color: 'bg-purple-600 hover:bg-purple-700',
},
{
username: 'platform_manager',
password: 'test123',
role: 'PLATFORM_MANAGER',
label: 'Platform Manager',
color: 'bg-blue-600 hover:bg-blue-700',
},
{
username: 'platform_sales',
password: 'test123',
role: 'PLATFORM_SALES',
label: 'Platform Sales',
color: 'bg-green-600 hover:bg-green-700',
},
{
username: 'platform_support',
password: 'test123',
role: 'PLATFORM_SUPPORT',
label: 'Platform Support',
color: 'bg-yellow-600 hover:bg-yellow-700',
},
{
username: 'tenant_owner',
password: 'test123',
role: 'TENANT_OWNER',
label: 'Business Owner',
color: 'bg-indigo-600 hover:bg-indigo-700',
},
{
username: 'tenant_manager',
password: 'test123',
role: 'TENANT_MANAGER',
label: 'Business Manager',
color: 'bg-pink-600 hover:bg-pink-700',
},
{
username: 'tenant_staff',
password: 'test123',
role: 'TENANT_STAFF',
label: 'Staff Member',
color: 'bg-teal-600 hover:bg-teal-700',
},
{
username: 'customer',
password: 'test123',
role: 'CUSTOMER',
label: 'Customer',
color: 'bg-orange-600 hover:bg-orange-700',
},
];
export function DevQuickLogin() {
const queryClient = useQueryClient();
const [loading, setLoading] = useState<string | null>(null);
const [isMinimized, setIsMinimized] = useState(false);
// Only show in development
if (import.meta.env.PROD) {
return null;
}
const handleQuickLogin = async (user: TestUser) => {
setLoading(user.username);
try {
// Call token auth API
const response = await apiClient.post('/api/auth-token/', {
username: user.username,
password: user.password,
});
// Store token in cookie (use 'access_token' to match what client.ts expects)
setCookie('access_token', response.data.token, 7);
// Invalidate queries to refetch user data
await queryClient.invalidateQueries({ queryKey: ['currentUser'] });
await queryClient.invalidateQueries({ queryKey: ['currentBusiness'] });
// Reload page to trigger auth flow
window.location.reload();
} catch (error) {
console.error('Quick login failed:', error);
alert(`Failed to login as ${user.label}: ${error.message}`);
} finally {
setLoading(null);
}
};
if (isMinimized) {
return (
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => setIsMinimized(false)}
className="bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg hover:bg-gray-700 transition-colors"
>
🔓 Quick Login
</button>
</div>
);
}
return (
<div className="fixed bottom-4 right-4 z-50 bg-white rounded-lg shadow-2xl border-2 border-gray-300 p-4 max-w-md">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-800 flex items-center gap-2">
<span>🔓</span>
<span>Quick Login (Dev Only)</span>
</h3>
<button
onClick={() => setIsMinimized(true)}
className="text-gray-500 hover:text-gray-700 text-xl leading-none"
>
×
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{testUsers.map((user) => (
<button
key={user.username}
onClick={() => handleQuickLogin(user)}
disabled={loading !== null}
className={`${user.color} text-white px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
>
{loading === user.username ? (
<span className="flex items-center justify-center">
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<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"
/>
</svg>
Logging in...
</span>
) : (
<div className="text-left">
<div className="font-semibold">{user.label}</div>
<div className="text-xs opacity-90">{user.role}</div>
</div>
)}
</button>
))}
</div>
<div className="mt-3 text-xs text-gray-500 text-center">
Password for all: <code className="bg-gray-100 px-1 rounded">test123</code>
</div>
</div>
);
}

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,162 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { clsx } from 'clsx';
import { Clock, DollarSign } from 'lucide-react';
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
export interface DraggableEventProps {
id: number;
title: string;
serviceName?: string;
start: Date;
end: Date;
status?: AppointmentStatus;
isPaid?: boolean;
height: number;
left: number;
width: number;
top: number;
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
}
export const DraggableEvent: React.FC<DraggableEventProps> = ({
id,
title,
serviceName,
start,
end,
status = 'CONFIRMED',
isPaid = false,
height,
left,
width,
top,
onResizeStart,
}) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `event-${id}`,
data: {
type: 'event',
title,
duration: (end.getTime() - start.getTime()) / 60000
},
});
const style: React.CSSProperties = {
transform: CSS.Translate.toString(transform),
left,
width,
top,
height,
position: 'absolute',
zIndex: isDragging ? 50 : 10,
};
// Status Logic matching legacy OwnerScheduler.tsx exactly
const getStatusStyles = () => {
const now = new Date();
// Legacy: if (status === 'COMPLETED' || status === 'NO_SHOW')
if (status === 'COMPLETED' || status === 'NO_SHOW') {
return {
container: 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400',
accent: 'bg-gray-400'
};
}
// Legacy: if (status === 'CANCELLED')
if (status === 'CANCELLED') {
return {
container: 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400',
accent: 'bg-gray-400'
};
}
// Legacy: if (now > endTime) (Overdue)
if (now > end) {
return {
container: 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200',
accent: 'bg-red-500'
};
}
// Legacy: if (now >= startTime && now <= endTime) (In Progress)
if (now >= start && now <= end) {
return {
container: 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200',
accent: 'bg-yellow-500 animate-pulse'
};
}
// Legacy: Default (Future)
return {
container: 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200',
accent: 'bg-blue-500'
};
};
const styles = getStatusStyles();
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={clsx(
"rounded-md border shadow-sm text-xs overflow-hidden cursor-pointer group transition-all select-none flex",
styles.container,
isDragging ? "opacity-50 ring-2 ring-blue-500 ring-offset-2 z-50 shadow-xl" : "hover:shadow-md"
)}
>
{/* Colored Status Strip */}
<div className={clsx("w-1.5 shrink-0", styles.accent)} />
{/* Content */}
<div className="flex-1 p-1.5 min-w-0 flex flex-col justify-center">
<div className="flex items-center justify-between gap-1">
<span className="font-semibold truncate">
{title}
</span>
{isPaid && (
<DollarSign size={10} className="text-emerald-600 dark:text-emerald-400 shrink-0" />
)}
</div>
{serviceName && width > 100 && (
<div className="text-[10px] opacity-80 truncate">
{serviceName}
</div>
)}
{/* Time (only show if wide enough) */}
{width > 60 && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] opacity-70">
<Clock size={8} />
<span className="truncate">
{start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
</div>
)}
</div>
{/* Resize Handles */}
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-400/50 z-20 opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
onResizeStart(e, 'left', id);
}}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-400/50 z-20 opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={(e) => {
e.stopPropagation();
onResizeStart(e, 'right', id);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Clock, GripVertical } from 'lucide-react';
import { clsx } from 'clsx';
export interface PendingAppointment {
id: number;
customerName: string;
serviceName: string;
durationMinutes: number;
}
interface PendingItemProps {
appointment: PendingAppointment;
}
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `pending-${appointment.id}`,
data: {
type: 'pending',
duration: appointment.durationMinutes,
title: appointment.customerName // Pass title for the new event
},
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={clsx(
"p-3 bg-white border border-l-4 border-gray-200 border-l-orange-400 rounded shadow-sm cursor-grab hover:shadow-md transition-all mb-2",
isDragging ? "opacity-50" : ""
)}
>
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-sm text-gray-900">{appointment.customerName}</p>
<p className="text-xs text-gray-500">{appointment.serviceName}</p>
</div>
<GripVertical size={14} className="text-gray-400" />
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400">
<Clock size={10} />
<span>{appointment.durationMinutes} min</span>
</div>
</div>
);
};
interface PendingSidebarProps {
appointments: PendingAppointment[];
}
const PendingSidebar: React.FC<PendingSidebarProps> = ({ appointments }) => {
return (
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full shrink-0">
<div className="p-4 border-b border-gray-200 bg-gray-100">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-2">
<Clock size={12} /> Pending Requests ({appointments.length})
</h3>
</div>
<div className="p-4 overflow-y-auto flex-1">
{appointments.length === 0 ? (
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
) : (
appointments.map(apt => (
<PendingItem key={apt.id} appointment={apt} />
))
)}
</div>
</div>
);
};
export default PendingSidebar;

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Clock, GripVertical, Trash2 } from 'lucide-react';
import { clsx } from 'clsx';
export interface PendingAppointment {
id: number;
customerName: string;
serviceName: string;
durationMinutes: number;
}
export interface ResourceLayout {
resourceId: number;
resourceName: string;
height: number;
laneCount: number;
}
interface PendingItemProps {
appointment: PendingAppointment;
}
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `pending-${appointment.id}`,
data: {
type: 'pending',
duration: appointment.durationMinutes,
title: appointment.customerName
},
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={clsx(
"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 mb-2",
isDragging ? "opacity-50" : ""
)}
>
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{appointment.customerName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{appointment.serviceName}</p>
</div>
<GripVertical size={14} className="text-gray-400" />
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} />
<span>{appointment.durationMinutes} min</span>
</div>
</div>
);
};
interface SidebarProps {
resourceLayouts: ResourceLayout[];
pendingAppointments: PendingAppointment[];
scrollRef: React.RefObject<HTMLDivElement>;
}
const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments, scrollRef }) => {
return (
<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: 250 }}>
{/* Resources Header */}
<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: 48 }}>
Resources
</div>
{/* Resources List (Synced Scroll) */}
<div className="flex-1 overflow-hidden flex flex-col">
<div
ref={scrollRef}
className="overflow-hidden flex-1" // Hidden scrollbar, controlled by main timeline
>
{resourceLayouts.map(layout => (
<div
key={layout.resourceId}
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.resourceName}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">
Resource
{layout.laneCount > 1 && (
<span className="text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/50 px-1 rounded text-[10px]">
{layout.laneCount} lanes
</span>
)}
</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* Pending Requests (Fixed Bottom) */}
<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">
<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 ? (
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
) : (
pendingAppointments.map(apt => (
<PendingItem key={apt.id} appointment={apt} />
))
)}
</div>
{/* Archive Drop Zone (Visual) */}
<div className="shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 opacity-50">
<div className="flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700 bg-transparent text-gray-400">
<Trash2 size={16} />
<span className="text-xs font-medium">Drop here to archive</span>
</div>
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,443 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import {
DndContext,
DragEndEvent,
useSensor,
useSensors,
PointerSensor,
DragOverlay
} from '@dnd-kit/core';
import {
addMinutes,
startOfDay,
endOfDay,
startOfWeek,
endOfWeek,
startOfMonth,
endOfMonth,
eachDayOfInterval,
format,
isSameDay
} from 'date-fns';
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Filter, Calendar as CalendarIcon, Undo, Redo, Clock, GripVertical } from 'lucide-react';
import clsx from 'clsx';
import TimelineRow from '../Timeline/TimelineRow';
import CurrentTimeIndicator from '../Timeline/CurrentTimeIndicator';
import Sidebar from './Sidebar';
import { Event, Resource, PendingAppointment } from '../../types';
import { calculateLayout } from '../../lib/layoutAlgorithm';
import { DEFAULT_PIXELS_PER_HOUR, SNAP_MINUTES } from '../../lib/timelineUtils';
import { useQuery } from '@tanstack/react-query';
import { adaptResources, adaptEvents, adaptPending } from '../../lib/uiAdapter';
import axios from 'axios';
type ViewMode = 'day' | 'week' | 'month';
export const Timeline: React.FC = () => {
// Data Fetching
const { data: resources = [] } = useQuery({
queryKey: ['resources'],
queryFn: async () => {
const response = await axios.get('http://lvh.me:8000/api/resources/');
return adaptResources(response.data);
}
});
const { data: backendAppointments = [] } = useQuery({ // Renamed to backendAppointments to avoid conflict with localEvents
queryKey: ['appointments'],
queryFn: async () => {
const response = await axios.get('http://lvh.me:8000/api/appointments/');
return response.data; // Still return raw data, adapt in useEffect
}
});
// State
const [localEvents, setLocalEvents] = useState<Event[]>([]);
const [localPending, setLocalPending] = useState<PendingAppointment[]>([]);
// Sync remote data to local state (for optimistic UI updates later)
useEffect(() => {
if (backendAppointments.length > 0) {
setLocalEvents(adaptEvents(backendAppointments));
setLocalPending(adaptPending(backendAppointments));
}
}, [backendAppointments]);
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [currentDate, setCurrentDate] = useState(new Date());
const [pixelsPerHour, setPixelsPerHour] = useState(DEFAULT_PIXELS_PER_HOUR);
const [activeDragItem, setActiveDragItem] = useState<any>(null);
const timelineScrollRef = useRef<HTMLDivElement>(null);
const sidebarScrollRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false);
// Sensors for drag detection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
// Calculate view range
const { startTime, endTime, days } = useMemo(() => {
let start, end;
if (viewMode === 'day') {
start = startOfDay(currentDate);
end = endOfDay(currentDate);
} else if (viewMode === 'week') {
start = startOfWeek(currentDate, { weekStartsOn: 1 });
end = endOfWeek(currentDate, { weekStartsOn: 1 });
} else {
start = startOfMonth(currentDate);
end = endOfMonth(currentDate);
}
const days = eachDayOfInterval({ start, end });
return { startTime: start, endTime: end, days };
}, [viewMode, currentDate]);
// Calculate Layouts for Sidebar Sync
const resourceLayouts = useMemo<ResourceLayout[]>(() => {
return resources.map(resource => {
const resourceEvents = localEvents.filter(e => e.resourceId === resource.id);
const eventsWithLanes = calculateLayout(resourceEvents);
const maxLane = Math.max(0, ...eventsWithLanes.map(e => e.laneIndex || 0));
const height = (maxLane + 1) * 40 + 20; // 40 is eventHeight, 20 is padding
return {
resourceId: resource.id,
resourceName: resource.name,
height,
laneCount: maxLane + 1
};
});
}, [resources, localEvents]);
// Scroll Sync Logic
const handleTimelineScroll = () => {
if (timelineScrollRef.current && sidebarScrollRef.current) {
sidebarScrollRef.current.scrollTop = timelineScrollRef.current.scrollTop;
}
};
// Date Range Label
const getDateRangeLabel = () => {
if (viewMode === 'day') {
return format(currentDate, 'EEEE, MMMM d, yyyy');
} else if (viewMode === 'week') {
const start = startOfWeek(currentDate, { weekStartsOn: 1 });
const end = endOfWeek(currentDate, { weekStartsOn: 1 });
return `${format(start, 'MMM d')} - ${format(end, 'MMM d, yyyy')}`;
} else {
return format(currentDate, 'MMMM yyyy');
}
};
// Auto-scroll
useEffect(() => {
if (timelineScrollRef.current && !hasScrolledRef.current) {
const indicator = document.getElementById('current-time-indicator');
if (indicator) {
indicator.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
hasScrolledRef.current = true;
}
}
}, [startTime, viewMode]);
useEffect(() => {
hasScrolledRef.current = false;
}, [viewMode]);
const handleDragStart = (event: any) => {
setActiveDragItem(event.active.data.current);
};
// Handle Drag End
const handleDragEnd = (event: DragEndEvent) => {
const { active, delta, over } = event;
setActiveDragItem(null);
if (!active) return;
let newResourceId: number | undefined;
if (over && over.id.toString().startsWith('resource-')) {
newResourceId = Number(over.id.toString().replace('resource-', ''));
}
// Handle Pending Event Drop
if (active.data.current?.type === 'pending') {
if (newResourceId) {
const pendingId = Number(active.id.toString().replace('pending-', ''));
const pendingItem = localPending.find(p => p.id === pendingId);
if (pendingItem) {
const dropRect = active.rect.current.translated;
const containerRect = timelineScrollRef.current?.getBoundingClientRect();
if (dropRect && containerRect) {
// Calculate relative X position in the timeline content
const relativeX = dropRect.left - containerRect.left + (timelineScrollRef.current?.scrollLeft || 0);
const pixelsPerMinute = pixelsPerHour / 60;
const minutesFromStart = Math.max(0, relativeX / pixelsPerMinute);
const snappedMinutes = Math.round(minutesFromStart / SNAP_MINUTES) * SNAP_MINUTES;
const newStart = addMinutes(startTime, snappedMinutes);
const newEnd = addMinutes(newStart, pendingItem.durationMinutes);
const newEvent: Event = {
id: Date.now(),
resourceId: newResourceId,
title: pendingItem.customerName,
start: newStart,
end: newEnd,
status: 'CONFIRMED'
};
setLocalEvents(prev => [...prev, newEvent]);
setLocalPending(prev => prev.filter(p => p.id !== pendingId));
}
}
}
return;
}
// Handle Existing Event Drag
const eventId = Number(active.id.toString().replace('event-', ''));
setLocalEvents(prev => prev.map(e => {
if (e.id === eventId) {
const minutesShift = Math.round(delta.x / (pixelsPerHour / 60));
const snappedShift = Math.round(minutesShift / SNAP_MINUTES) * SNAP_MINUTES;
const updates: Partial<Event> = {};
if (snappedShift !== 0) {
updates.start = addMinutes(e.start, snappedShift);
updates.end = addMinutes(e.end, snappedShift);
}
if (newResourceId !== undefined && newResourceId !== e.resourceId) {
updates.resourceId = newResourceId;
}
return { ...e, ...updates };
}
return e;
}));
};
const handleResizeStart = (_e: React.MouseEvent, direction: 'left' | 'right', id: number) => {
console.log('Resize started', direction, id);
};
const handleZoomIn = () => setPixelsPerHour(prev => Math.min(prev + 20, 300));
const handleZoomOut = () => setPixelsPerHour(prev => Math.max(prev - 20, 40));
return (
<div className="flex flex-col h-full overflow-hidden select-none bg-white dark:bg-gray-900 transition-colors duration-200">
{/* Header Bar */}
<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={() => setCurrentDate(d => addMinutes(d, viewMode === 'day' ? -1440 : -10080))}
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={() => setCurrentDate(d => addMinutes(d, viewMode === 'day' ? 1440 : 10080))}
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">
{(['day', 'week', 'month'] as const).map((mode) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={clsx(
"px-3 py-1.5 text-sm font-medium rounded transition-colors capitalize",
viewMode === mode
? "bg-blue-500 text-white"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
)}
>
{mode}
</button>
))}
</div>
{/* Zoom Controls */}
<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={handleZoomOut}
>
<ZoomOut size={16} />
</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={handleZoomIn}
>
<ZoomIn size={16} />
</button>
</div>
{/* Undo/Redo */}
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
<button className="p-2 text-gray-300 dark:text-gray-600 cursor-not-allowed rounded" disabled>
<Undo size={18} />
</button>
<button className="p-2 text-gray-300 dark:text-gray-600 cursor-not-allowed rounded" disabled>
<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-blue-600 rounded-lg hover:bg-blue-700 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>
{/* Main Layout */}
<div className="flex flex-1 overflow-hidden">
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* Sidebar (Resources + Pending) */}
<Sidebar
resourceLayouts={resourceLayouts}
pendingAppointments={localPending}
scrollRef={sidebarScrollRef}
/>
{/* Timeline Grid */}
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-gray-900 relative transition-colors duration-200">
<div
ref={timelineScrollRef}
onScroll={handleTimelineScroll}
className="flex-1 overflow-auto timeline-scroll"
>
<div className="min-w-max relative min-h-full">
{/* Current Time Indicator */}
<div className="absolute inset-y-0 left-0 right-0 pointer-events-none z-40">
<CurrentTimeIndicator startTime={startTime} hourWidth={pixelsPerHour} />
</div>
{/* Header Row */}
<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">
<div className="flex" style={{ height: 48 }}>
{viewMode === 'day' ? (
Array.from({ length: 24 }).map((_, i) => (
<div
key={i}
className="flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-2 text-sm text-gray-500 font-medium box-border"
style={{ width: pixelsPerHour }}
>
{format(new Date().setHours(i, 0, 0, 0), 'h a')}
</div>
))
) : viewMode === 'week' ? (
days.map((day, i) => (
<div
key={i}
className="flex-shrink-0 border-r border-gray-300 dark:border-gray-600"
style={{ width: pixelsPerHour * 24 }}
>
<div className={clsx(
"p-2 text-sm font-bold text-center border-b border-gray-100 dark:border-gray-700",
isSameDay(day, new Date()) ? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400" : "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
)}>
{format(day, 'EEEE, MMM d')}
</div>
<div className="flex">
{Array.from({ length: 24 }).map((_, h) => (
<div
key={h}
className="flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-1 text-xs text-gray-400 text-center"
style={{ width: pixelsPerHour }}
>
{h % 6 === 0 ? format(new Date().setHours(h, 0, 0, 0), 'h a') : ''}
</div>
))}
</div>
</div>
))
) : (
days.map((day, i) => (
<div
key={i}
className={clsx(
"flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-2 text-sm font-medium text-center",
isSameDay(day, new Date()) ? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400" : "text-gray-500"
)}
style={{ width: 100 }}
>
{format(day, 'd')}
</div>
))
)}
</div>
</div>
{/* Resource Rows (Grid Only) */}
{resourceLayouts.map(layout => (
<TimelineRow
key={layout.resourceId}
resourceId={layout.resourceId}
events={localEvents.filter(e => e.resourceId === layout.resourceId)}
startTime={startTime}
endTime={endTime}
hourWidth={pixelsPerHour}
eventHeight={40}
height={layout.height}
onResizeStart={handleResizeStart}
/>
))}
</div>
</div>
</div>
{/* Drag Overlay for Visual Feedback */}
<DragOverlay>
{activeDragItem ? (
<div 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-lg opacity-80 w-64">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{activeDragItem.title}</p>
</div>
<GripVertical size={14} className="text-gray-400" />
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} />
<span>{activeDragItem.duration} min</span>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
);
};
export default Timeline;

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,38 @@
import React, { useEffect, useState } from 'react';
import { differenceInMinutes } from 'date-fns';
import { getPosition } from '../../lib/timelineUtils';
interface CurrentTimeIndicatorProps {
startTime: Date;
hourWidth: number;
}
const CurrentTimeIndicator: React.FC<CurrentTimeIndicatorProps> = ({ startTime, hourWidth }) => {
const [now, setNow] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => setNow(new Date()), 60000); // Update every minute
return () => clearInterval(interval);
}, []);
// Calculate position
const left = getPosition(now, startTime, hourWidth);
// Only render if within visible range (roughly)
if (differenceInMinutes(now, startTime) < 0) return null;
return (
<div
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none"
style={{ left }}
id="current-time-indicator"
>
<div className="absolute -top-1 -left-1 w-2 h-2 bg-red-500 rounded-full" />
<div className="absolute top-0 left-2 text-xs font-bold text-red-500 bg-white/80 px-1 rounded">
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
);
};
export default CurrentTimeIndicator;

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { format } from 'date-fns';
import { clsx } from 'clsx';
import { GripVertical } from 'lucide-react';
interface DraggableEventProps {
id: number;
title: string;
serviceName?: string;
status?: 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW' | 'SCHEDULED';
isPaid?: boolean;
start: Date;
end: Date;
laneIndex: number;
height: number;
left: number;
width: number;
top: number;
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
}
export const DraggableEvent: React.FC<DraggableEventProps> = ({
id,
title,
serviceName,
status = 'SCHEDULED',
isPaid = false,
start,
end,
height,
left,
width,
top,
onResizeStart,
}) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `event-${id}`,
data: { id, type: 'event', originalStart: start, originalEnd: end },
});
const style = {
transform: CSS.Translate.toString(transform),
left,
width,
top,
height,
};
// Status-based color scheme matching reference UI
const getBorderColor = () => {
if (isPaid) return 'border-green-500';
switch (status) {
case 'CONFIRMED': return 'border-blue-500';
case 'COMPLETED': return 'border-green-500';
case 'CANCELLED': return 'border-red-500';
case 'NO_SHOW': return 'border-gray-500';
default: return 'border-brand-500';
}
};
return (
<div
ref={setNodeRef}
style={style}
className={clsx(
"absolute rounded-b overflow-hidden group transition-shadow",
"bg-brand-100 dark:bg-brand-900/50 border-t-4",
getBorderColor(),
isDragging ? "shadow-lg ring-2 ring-brand-500 opacity-80 z-50" : "hover:shadow-md z-10"
)}
>
{/* 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) => {
e.stopPropagation();
onResizeStart(e, 'left', id);
}}
/>
{/* Content */}
<div
{...listeners}
{...attributes}
className="h-full w-full px-2 py-1 cursor-move select-none"
>
<div className="flex items-start justify-between mt-1">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{title}
</div>
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<GripVertical size={14} className="text-gray-400" />
</div>
</div>
{serviceName && (
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{serviceName}
</div>
)}
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{format(start, 'h:mm a')}
</div>
</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) => {
e.stopPropagation();
onResizeStart(e, 'right', id);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,99 @@
import React, { useMemo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { clsx } from 'clsx';
import { differenceInHours } from 'date-fns';
import { calculateLayout, Event } from '../../lib/layoutAlgorithm';
import { DraggableEvent } from './DraggableEvent';
import { getPosition } from '../../lib/timelineUtils';
interface ResourceRowProps {
resourceId: number;
resourceName: string;
events: Event[];
startTime: Date;
endTime: Date;
hourWidth: number;
eventHeight: number;
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
}
const ResourceRow: React.FC<ResourceRowProps> = ({
resourceId,
resourceName,
events,
startTime,
endTime,
hourWidth,
eventHeight,
onResizeStart,
}) => {
const { setNodeRef, isOver } = useDroppable({
id: `resource-${resourceId}`,
data: { resourceId },
});
const eventsWithLanes = useMemo(() => calculateLayout(events), [events]);
const maxLane = Math.max(0, ...eventsWithLanes.map(e => e.laneIndex || 0));
const rowHeight = (maxLane + 1) * eventHeight + 20;
const totalWidth = getPosition(endTime, startTime, hourWidth);
// Calculate total hours for grid lines
const totalHours = Math.ceil(differenceInHours(endTime, startTime));
return (
<div className="flex border-b border-gray-200 group">
<div
className="w-48 flex-shrink-0 p-4 border-r border-gray-200 bg-gray-50 font-medium flex items-center sticky left-0 z-10 group-hover:bg-gray-100 transition-colors"
style={{ height: rowHeight }}
>
{resourceName}
</div>
<div
ref={setNodeRef}
className={clsx(
"relative flex-grow transition-colors",
isOver ? "bg-blue-50" : ""
)}
style={{ height: rowHeight, width: totalWidth }}
>
{/* Grid Lines */}
<div className="absolute inset-0 pointer-events-none flex">
{Array.from({ length: totalHours }).map((_, i) => (
<div
key={i}
className="border-r border-gray-100 h-full"
style={{ width: hourWidth }}
/>
))}
</div>
{/* Events */}
{eventsWithLanes.map((event) => {
const left = getPosition(event.start, startTime, hourWidth);
const width = getPosition(event.end, startTime, hourWidth) - left;
const top = (event.laneIndex || 0) * eventHeight + 10;
return (
<DraggableEvent
key={event.id}
id={event.id}
title={event.title}
start={event.start}
end={event.end}
laneIndex={event.laneIndex || 0}
height={eventHeight - 4}
left={left}
width={width}
top={top}
onResizeStart={onResizeStart}
/>
);
})}
</div>
</div>
);
};
export default ResourceRow;

View File

@@ -0,0 +1,88 @@
import React, { useMemo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { clsx } from 'clsx';
import { differenceInHours } from 'date-fns';
import { calculateLayout, Event } from '../../lib/layoutAlgorithm';
import { DraggableEvent } from './DraggableEvent';
import { getPosition } from '../../lib/timelineUtils';
interface TimelineRowProps {
resourceId: number;
events: Event[];
startTime: Date;
endTime: Date;
hourWidth: number;
eventHeight: number;
height: number; // Passed from parent to match sidebar
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
}
const TimelineRow: React.FC<TimelineRowProps> = ({
resourceId,
events,
startTime,
endTime,
hourWidth,
eventHeight,
height,
onResizeStart,
}) => {
const { setNodeRef, isOver } = useDroppable({
id: `resource-${resourceId}`,
data: { resourceId },
});
const eventsWithLanes = useMemo(() => calculateLayout(events), [events]);
const totalWidth = getPosition(endTime, startTime, hourWidth);
const totalHours = Math.ceil(differenceInHours(endTime, startTime));
return (
<div
ref={setNodeRef}
className={clsx(
"relative border-b border-gray-200 dark:border-gray-700 transition-colors group",
isOver ? "bg-blue-50 dark:bg-blue-900/20" : ""
)}
style={{ height, width: totalWidth }}
>
{/* Grid Lines */}
<div className="absolute inset-0 pointer-events-none flex">
{Array.from({ length: totalHours }).map((_, i) => (
<div
key={i}
className="border-r border-gray-100 dark:border-gray-700/50 h-full"
style={{ width: hourWidth }}
/>
))}
</div>
{/* Events */}
{eventsWithLanes.map((event) => {
const left = getPosition(event.start, startTime, hourWidth);
const width = getPosition(event.end, startTime, hourWidth) - left;
const top = (event.laneIndex || 0) * eventHeight + 10;
return (
<DraggableEvent
key={event.id}
id={event.id}
title={event.title}
serviceName={event.serviceName}
status={event.status}
isPaid={event.isPaid}
start={event.start}
end={event.end}
laneIndex={event.laneIndex || 0}
height={eventHeight - 4}
left={left}
width={width}
top={top}
onResizeStart={onResizeStart}
/>
);
})}
</div>
);
};
export default TimelineRow;

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;