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:
89
frontend/src/components/AppointmentConfirmation.css
Normal file
89
frontend/src/components/AppointmentConfirmation.css
Normal 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;
|
||||
}
|
||||
39
frontend/src/components/AppointmentConfirmation.jsx
Normal file
39
frontend/src/components/AppointmentConfirmation.jsx
Normal 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;
|
||||
137
frontend/src/components/BookingForm.css
Normal file
137
frontend/src/components/BookingForm.css
Normal 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;
|
||||
}
|
||||
133
frontend/src/components/BookingForm.jsx
Normal file
133
frontend/src/components/BookingForm.jsx
Normal 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;
|
||||
269
frontend/src/components/ConnectOnboarding.tsx
Normal file
269
frontend/src/components/ConnectOnboarding.tsx
Normal 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;
|
||||
290
frontend/src/components/ConnectOnboardingEmbed.tsx
Normal file
290
frontend/src/components/ConnectOnboardingEmbed.tsx
Normal 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;
|
||||
180
frontend/src/components/DevQuickLogin.tsx
Normal file
180
frontend/src/components/DevQuickLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
636
frontend/src/components/DomainPurchase.tsx
Normal file
636
frontend/src/components/DomainPurchase.tsx
Normal 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;
|
||||
111
frontend/src/components/LanguageSelector.tsx
Normal file
111
frontend/src/components/LanguageSelector.tsx
Normal 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;
|
||||
40
frontend/src/components/MasqueradeBanner.tsx
Normal file
40
frontend/src/components/MasqueradeBanner.tsx
Normal 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;
|
||||
156
frontend/src/components/OAuthButtons.tsx
Normal file
156
frontend/src/components/OAuthButtons.tsx
Normal 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;
|
||||
329
frontend/src/components/OnboardingWizard.tsx
Normal file
329
frontend/src/components/OnboardingWizard.tsx
Normal 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;
|
||||
220
frontend/src/components/PaymentSettingsSection.tsx
Normal file
220
frontend/src/components/PaymentSettingsSection.tsx
Normal 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;
|
||||
85
frontend/src/components/PlatformSidebar.tsx
Normal file
85
frontend/src/components/PlatformSidebar.tsx
Normal 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;
|
||||
26
frontend/src/components/Portal.tsx
Normal file
26
frontend/src/components/Portal.tsx
Normal 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;
|
||||
252
frontend/src/components/QuickAddAppointment.tsx
Normal file
252
frontend/src/components/QuickAddAppointment.tsx
Normal 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;
|
||||
729
frontend/src/components/ResourceCalendar.tsx
Normal file
729
frontend/src/components/ResourceCalendar.tsx
Normal 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;
|
||||
162
frontend/src/components/Schedule/DraggableEvent.tsx
Normal file
162
frontend/src/components/Schedule/DraggableEvent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
frontend/src/components/Schedule/PendingSidebar.tsx
Normal file
77
frontend/src/components/Schedule/PendingSidebar.tsx
Normal 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;
|
||||
133
frontend/src/components/Schedule/Sidebar.tsx
Normal file
133
frontend/src/components/Schedule/Sidebar.tsx
Normal 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;
|
||||
443
frontend/src/components/Schedule/Timeline.tsx
Normal file
443
frontend/src/components/Schedule/Timeline.tsx
Normal 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;
|
||||
89
frontend/src/components/ServiceList.css
Normal file
89
frontend/src/components/ServiceList.css
Normal 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;
|
||||
}
|
||||
39
frontend/src/components/ServiceList.jsx
Normal file
39
frontend/src/components/ServiceList.jsx
Normal 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;
|
||||
174
frontend/src/components/Sidebar.tsx
Normal file
174
frontend/src/components/Sidebar.tsx
Normal 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;
|
||||
24
frontend/src/components/SmoothScheduleLogo.tsx
Normal file
24
frontend/src/components/SmoothScheduleLogo.tsx
Normal file
File diff suppressed because one or more lines are too long
441
frontend/src/components/StripeApiKeysForm.tsx
Normal file
441
frontend/src/components/StripeApiKeysForm.tsx
Normal 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;
|
||||
38
frontend/src/components/Timeline/CurrentTimeIndicator.tsx
Normal file
38
frontend/src/components/Timeline/CurrentTimeIndicator.tsx
Normal 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;
|
||||
117
frontend/src/components/Timeline/DraggableEvent.tsx
Normal file
117
frontend/src/components/Timeline/DraggableEvent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
99
frontend/src/components/Timeline/ResourceRow.tsx
Normal file
99
frontend/src/components/Timeline/ResourceRow.tsx
Normal 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;
|
||||
88
frontend/src/components/Timeline/TimelineRow.tsx
Normal file
88
frontend/src/components/Timeline/TimelineRow.tsx
Normal 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;
|
||||
61
frontend/src/components/TopBar.tsx
Normal file
61
frontend/src/components/TopBar.tsx
Normal 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;
|
||||
549
frontend/src/components/TransactionDetailModal.tsx
Normal file
549
frontend/src/components/TransactionDetailModal.tsx
Normal 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;
|
||||
92
frontend/src/components/TrialBanner.tsx
Normal file
92
frontend/src/components/TrialBanner.tsx
Normal 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;
|
||||
150
frontend/src/components/UserProfileDropdown.tsx
Normal file
150
frontend/src/components/UserProfileDropdown.tsx
Normal 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;
|
||||
75
frontend/src/components/marketing/CTASection.tsx
Normal file
75
frontend/src/components/marketing/CTASection.tsx
Normal 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;
|
||||
56
frontend/src/components/marketing/FAQAccordion.tsx
Normal file
56
frontend/src/components/marketing/FAQAccordion.tsx
Normal 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;
|
||||
41
frontend/src/components/marketing/FeatureCard.tsx
Normal file
41
frontend/src/components/marketing/FeatureCard.tsx
Normal 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;
|
||||
136
frontend/src/components/marketing/Footer.tsx
Normal file
136
frontend/src/components/marketing/Footer.tsx
Normal 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">
|
||||
© {currentYear} {t('marketing.footer.copyright')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
166
frontend/src/components/marketing/Hero.tsx
Normal file
166
frontend/src/components/marketing/Hero.tsx
Normal 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;
|
||||
102
frontend/src/components/marketing/HowItWorks.tsx
Normal file
102
frontend/src/components/marketing/HowItWorks.tsx
Normal 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;
|
||||
164
frontend/src/components/marketing/Navbar.tsx
Normal file
164
frontend/src/components/marketing/Navbar.tsx
Normal 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;
|
||||
185
frontend/src/components/marketing/PricingCard.tsx
Normal file
185
frontend/src/components/marketing/PricingCard.tsx
Normal 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;
|
||||
65
frontend/src/components/marketing/StatsSection.tsx
Normal file
65
frontend/src/components/marketing/StatsSection.tsx
Normal 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;
|
||||
68
frontend/src/components/marketing/TestimonialCard.tsx
Normal file
68
frontend/src/components/marketing/TestimonialCard.tsx
Normal 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;
|
||||
463
frontend/src/components/profile/TwoFactorSetup.tsx
Normal file
463
frontend/src/components/profile/TwoFactorSetup.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user