feat(i18n): Comprehensive internationalization of frontend components and pages

Translate all hardcoded English strings to use i18n translation keys:

Components:
- TransactionDetailModal: payment details, refunds, technical info
- ConnectOnboarding/ConnectOnboardingEmbed: Stripe Connect setup
- StripeApiKeysForm: API key management
- DomainPurchase: domain registration flow
- Sidebar: navigation labels
- Schedule/Sidebar, PendingSidebar: scheduler UI
- MasqueradeBanner: masquerade status
- Dashboard widgets: metrics, capacity, customers, tickets
- Marketing: PricingTable, PluginShowcase, BenefitsSection
- ConfirmationModal, ServiceList: common UI

Pages:
- Staff: invitation flow, role management
- Customers: form placeholders
- Payments: transactions, payouts, billing
- BookingSettings: URL and redirect configuration
- TrialExpired: upgrade prompts and features
- PlatformSettings, PlatformBusinesses: admin UI
- HelpApiDocs: API documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-03 21:40:54 -05:00
parent 902582f4ba
commit c7f241b30a
34 changed files with 1313 additions and 592 deletions

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { X, AlertTriangle, CheckCircle, Info, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type ModalVariant = 'info' | 'warning' | 'danger' | 'success';
@@ -48,11 +49,13 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText,
cancelText,
variant = 'info',
isLoading = false,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const config = variantConfig[variant];
@@ -95,7 +98,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{cancelText}
{cancelText || t('common.cancel')}
</button>
<button
onClick={handleConfirm}
@@ -120,7 +123,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
/>
</svg>
)}
{confirmText}
{confirmText || t('common.confirm')}
</button>
</div>
</div>

View File

@@ -4,6 +4,7 @@
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ExternalLink,
CheckCircle,
@@ -27,6 +28,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
tier,
onSuccess,
}) => {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const onboardingMutation = useConnectOnboarding();
@@ -53,7 +55,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
// Redirect to Stripe onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to start onboarding');
setError(err.response?.data?.error || t('payments.failedToStartOnboarding'));
}
};
@@ -65,7 +67,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
// Redirect to continue onboarding
window.location.href = result.url;
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to refresh onboarding link');
setError(err.response?.data?.error || t('payments.failedToRefreshLink'));
}
};
@@ -73,13 +75,13 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
return t('payments.standardConnect');
case 'express':
return 'Express Connect';
return t('payments.expressConnect');
case 'custom':
return 'Custom Connect';
return t('payments.customConnect');
default:
return 'Connect';
return t('payments.connect');
}
};
@@ -91,9 +93,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
<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>
<h4 className="font-medium text-green-800">{t('payments.stripeConnected')}</h4>
<p className="text-sm text-green-700 mt-1">
Your Stripe account is connected and ready to accept payments.
{t('payments.stripeConnectedDesc')}
</p>
</div>
</div>
@@ -103,14 +105,14 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
{/* Account Details */}
{connectAccount && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<h4 className="font-medium text-gray-900 mb-3">{t('payments.accountDetails')}</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-600">{t('payments.accountType')}:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="text-gray-600">{t('payments.status')}:</span>
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
connectAccount.status === 'active'
@@ -126,40 +128,40 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="text-gray-600">{t('payments.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>
<span className="text-green-600">{t('payments.enabled')}</span>
</>
) : (
<>
<CreditCard size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
<span className="text-gray-500">{t('payments.disabled')}</span>
</>
)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="text-gray-600">{t('payments.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>
<span className="text-green-600">{t('payments.enabled')}</span>
</>
) : (
<>
<Wallet size={14} className="text-gray-400" />
<span className="text-gray-500">Disabled</span>
<span className="text-gray-500">{t('payments.disabled')}</span>
</>
)}
</span>
</div>
{connectAccount.stripe_account_id && (
<div className="flex justify-between">
<span className="text-gray-600">Account ID:</span>
<span className="text-gray-600">{t('payments.accountId')}:</span>
<code className="font-mono text-gray-900 text-xs">
{connectAccount.stripe_account_id}
</code>
@@ -175,10 +177,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
<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>
<h4 className="font-medium text-yellow-800">{t('payments.completeOnboarding')}</h4>
<p className="text-sm text-yellow-700 mt-1">
Your Stripe Connect account setup is incomplete.
Click below to continue the onboarding process.
{t('payments.onboardingIncomplete')}
</p>
<button
onClick={handleRefreshLink}
@@ -190,7 +191,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
) : (
<RefreshCw size={16} />
)}
Continue Onboarding
{t('payments.continueOnboarding')}
</button>
</div>
</div>
@@ -201,24 +202,22 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
{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>
<h4 className="font-medium text-blue-800 mb-2">{t('payments.connectWithStripe')}</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.
{t('payments.tierPaymentDescription', { tier })}
</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
{t('payments.securePaymentProcessing')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
{t('payments.automaticPayouts')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
{t('payments.pciCompliance')}
</li>
</ul>
</div>
@@ -233,7 +232,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
) : (
<>
<ExternalLink size={18} />
Connect with Stripe
{t('payments.connectWithStripe')}
</>
)}
</button>
@@ -259,7 +258,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
>
<ExternalLink size={14} />
Open Stripe Dashboard
{t('payments.openStripeDashboard')}
</a>
)}
</div>

View File

@@ -20,6 +20,7 @@ import {
Wallet,
Building2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
interface ConnectOnboardingEmbedProps {
@@ -37,6 +38,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
onComplete,
onError,
}) => {
const { t } = useTranslation();
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -78,12 +80,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
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';
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}
}, [loadingState, onError]);
}, [loadingState, onError, t]);
// Handle onboarding completion
const handleOnboardingExit = useCallback(async () => {
@@ -100,23 +102,23 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
// 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';
const message = loadError.error.message || t('payments.failedToLoadPaymentComponent');
setErrorMessage(message);
setLoadingState('error');
onError?.(message);
}, [onError]);
}, [onError, t]);
// Account type display
const getAccountTypeLabel = () => {
switch (connectAccount?.account_type) {
case 'standard':
return 'Standard Connect';
return t('payments.standardConnect');
case 'express':
return 'Express Connect';
return t('payments.expressConnect');
case 'custom':
return 'Custom Connect';
return t('payments.customConnect');
default:
return 'Connect';
return t('payments.connect');
}
};
@@ -128,39 +130,39 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
<div className="flex items-start gap-3">
<CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-green-800 dark:text-green-300">Stripe Connected</h4>
<h4 className="font-medium text-green-800 dark:text-green-300">{t('payments.stripeConnected')}</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
Your Stripe account is connected and ready to accept payments.
{t('payments.stripeConnectedDesc')}
</p>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Account Details</h4>
<h4 className="font-medium text-gray-900 dark:text-white mb-3">{t('payments.accountDetails')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Account Type:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.accountType')}:</span>
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.status')}:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Charges:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.charges')}:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CreditCard size={14} />
Enabled
{t('payments.enabled')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Payouts:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.payouts')}:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Wallet size={14} />
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
{connectAccount.payouts_enabled ? t('payments.enabled') : t('payments.pending')}
</span>
</div>
</div>
@@ -174,9 +176,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 dark:text-green-400 mb-3" size={48} />
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">Onboarding Complete!</h4>
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">{t('payments.onboardingComplete')}</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-2">
Your Stripe account has been set up. You can now accept payments.
{t('payments.stripeSetupComplete')}
</p>
</div>
);
@@ -190,7 +192,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
<div className="flex items-start gap-3">
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-red-800 dark:text-red-300">Setup Failed</h4>
<h4 className="font-medium text-red-800 dark:text-red-300">{t('payments.setupFailed')}</h4>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
</div>
</div>
@@ -202,7 +204,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
}}
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Try Again
{t('payments.tryAgain')}
</button>
</div>
);
@@ -216,23 +218,22 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
<div className="flex items-start gap-3">
<Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<h4 className="font-medium text-blue-800 dark:text-blue-300">Set Up Payments</h4>
<h4 className="font-medium text-blue-800 dark:text-blue-300">{t('payments.setUpPayments')}</h4>
<p className="text-sm text-blue-700 dark:text-blue-400 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.
{t('payments.tierPaymentDescriptionWithOnboarding', { tier })}
</p>
<ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Secure payment processing
{t('payments.securePaymentProcessing')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
Automatic payouts to your bank account
{t('payments.automaticPayouts')}
</li>
<li className="flex items-center gap-2">
<CheckCircle size={14} />
PCI compliance handled for you
{t('payments.pciCompliance')}
</li>
</ul>
</div>
@@ -244,7 +245,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
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
{t('payments.startPaymentSetup')}
</button>
</div>
);
@@ -255,7 +256,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
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 dark:text-gray-400">Initializing payment setup...</p>
<p className="text-gray-600 dark:text-gray-400">{t('payments.initializingPaymentSetup')}</p>
</div>
);
}
@@ -265,10 +266,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Complete Your Account Setup</h4>
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('payments.completeAccountSetup')}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Fill out the information below to finish setting up your payment account.
Your information is securely handled by Stripe.
{t('payments.fillOutInfoForPayment')}
</p>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Search,
Globe,
@@ -26,6 +27,7 @@ interface DomainPurchaseProps {
type Step = 'search' | 'details' | 'confirm';
const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>('search');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DomainAvailability[]>([]);
@@ -138,7 +140,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
>
1
</div>
<span className="text-sm font-medium">Search</span>
<span className="text-sm font-medium">{t('common.search')}</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div
@@ -155,7 +157,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
>
2
</div>
<span className="text-sm font-medium">Details</span>
<span className="text-sm font-medium">{t('settings.domain.details')}</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div
@@ -172,7 +174,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
>
3
</div>
<span className="text-sm font-medium">Confirm</span>
<span className="text-sm font-medium">{t('common.confirm')}</span>
</div>
</div>
@@ -186,7 +188,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Enter domain name or keyword..."
placeholder={t('settings.domain.searchPlaceholder')}
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>
@@ -200,14 +202,14 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
) : (
<Search className="h-5 w-5" />
)}
Search
{t('common.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>
<h4 className="font-medium text-gray-900 dark:text-white">{t('payments.searchResults')}</h4>
<div className="space-y-2">
{searchResults.map((result) => (
<div
@@ -230,7 +232,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</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
{t('settings.domain.premium')}
</span>
)}
</div>
@@ -246,12 +248,12 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
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
{t('settings.domain.select')}
</button>
</>
)}
{!result.available && (
<span className="text-sm text-gray-500 dark:text-gray-400">Unavailable</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{t('settings.domain.unavailable')}</span>
)}
</div>
</div>
@@ -264,7 +266,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{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
{t('settings.domain.yourRegisteredDomains')}
</h4>
<div className="space-y-2">
{registeredDomains.map((domain) => (
@@ -289,7 +291,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
{domain.expires_at && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Expires: {new Date(domain.expires_at).toLocaleDateString()}
{t('settings.domain.expires')}: {new Date(domain.expires_at).toLocaleDateString()}
</span>
)}
</div>
@@ -316,7 +318,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
onClick={() => setStep('search')}
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
Change
{t('settings.domain.change')}
</button>
</div>
</div>
@@ -325,7 +327,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
<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
{t('payments.registrationPeriod')}
</label>
<select
value={years}
@@ -334,7 +336,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
>
{[1, 2, 3, 5, 10].map((y) => (
<option key={y} value={y}>
{y} {y === 1 ? 'year' : 'years'} - $
{y} {y === 1 ? t('settings.domain.year') : t('settings.domain.years')} - $
{((selectedDomain.premium_price || selectedDomain.price || 0) * y).toFixed(2)}
</option>
))}
@@ -355,10 +357,10 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
<Shield className="h-5 w-5 text-gray-400" />
<div>
<span className="text-gray-900 dark:text-white font-medium">
WHOIS Privacy Protection
{t('settings.domain.whoisPrivacy')}
</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Hide your personal information from public WHOIS lookups
{t('settings.domain.whoisPrivacyDesc')}
</p>
</div>
</div>
@@ -374,9 +376,9 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
<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>
<span className="text-gray-900 dark:text-white font-medium">{t('settings.domain.autoRenewal')}</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically renew this domain before it expires
{t('settings.domain.autoRenewalDesc')}
</p>
</div>
</div>
@@ -393,10 +395,10 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
<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
{t('settings.domain.autoConfigure')}
</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically set up this domain for your business
{t('settings.domain.autoConfigureDesc')}
</p>
</div>
</div>
@@ -406,12 +408,12 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{/* 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
{t('settings.domain.registrantInfo')}
</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 *
{t('settings.domain.firstName')} *
</label>
<input
type="text"
@@ -423,7 +425,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name *
{t('settings.domain.lastName')} *
</label>
<input
type="text"
@@ -435,7 +437,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
{t('customers.email')} *
</label>
<input
type="email"
@@ -447,7 +449,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone *
{t('customers.phone')} *
</label>
<input
type="tel"
@@ -460,7 +462,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Address *
{t('customers.address')} *
</label>
<input
type="text"
@@ -472,7 +474,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City *
{t('customers.city')} *
</label>
<input
type="text"
@@ -484,7 +486,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
State/Province *
{t('settings.domain.stateProvince')} *
</label>
<input
type="text"
@@ -496,7 +498,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
ZIP/Postal Code *
{t('settings.domain.zipPostalCode')} *
</label>
<input
type="text"
@@ -508,19 +510,19 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country *
{t('settings.domain.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>
<option value="US">{t('settings.domain.countries.us')}</option>
<option value="CA">{t('settings.domain.countries.ca')}</option>
<option value="GB">{t('settings.domain.countries.gb')}</option>
<option value="AU">{t('settings.domain.countries.au')}</option>
<option value="DE">{t('settings.domain.countries.de')}</option>
<option value="FR">{t('settings.domain.countries.fr')}</option>
</select>
</div>
</div>
@@ -532,14 +534,14 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
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
{t('common.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
{t('settings.domain.continue')}
</button>
</div>
</div>
@@ -548,36 +550,36 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{/* Step 3: Confirm */}
{step === 'confirm' && selectedDomain && (
<div className="space-y-6">
<h4 className="font-medium text-gray-900 dark:text-white">Order Summary</h4>
<h4 className="font-medium text-gray-900 dark:text-white">{t('payments.orderSummary')}</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="text-gray-600 dark:text-gray-400">{t('settings.domain.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="text-gray-600 dark:text-gray-400">{t('payments.registrationPeriod')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{years} {years === 1 ? 'year' : 'years'}
{years} {years === 1 ? t('settings.domain.year') : t('settings.domain.years')}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">WHOIS Privacy</span>
<span className="text-gray-600 dark:text-gray-400">{t('settings.domain.whoisPrivacy')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{whoisPrivacy ? 'Enabled' : 'Disabled'}
{whoisPrivacy ? t('platform.settings.enabled') : t('platform.settings.none')}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Auto-Renewal</span>
<span className="text-gray-600 dark:text-gray-400">{t('settings.domain.autoRenewal')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{autoRenew ? 'Enabled' : 'Disabled'}
{autoRenew ? t('platform.settings.enabled') : t('platform.settings.none')}
</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-semibold text-gray-900 dark:text-white">{t('settings.domain.total')}</span>
<span className="font-bold text-xl text-brand-600 dark:text-brand-400">
${getPrice().toFixed(2)}
</span>
@@ -587,7 +589,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{/* 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>
<h5 className="font-medium text-gray-900 dark:text-white mb-2">{t('settings.domain.registrant')}</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{contact.first_name} {contact.last_name}
<br />
@@ -602,7 +604,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
{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>
<span>{t('payments.registrationFailed')}</span>
</div>
)}
@@ -612,7 +614,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
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
{t('common.back')}
</button>
<button
onClick={handlePurchase}
@@ -624,7 +626,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
) : (
<ShoppingCart className="h-5 w-5" />
)}
Complete Purchase
{t('settings.domain.completePurchase')}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Eye, XCircle } from 'lucide-react';
import { User } from '../types';
@@ -11,8 +12,9 @@ interface MasqueradeBannerProps {
}
const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, originalUser, previousUser, onStop }) => {
const buttonText = previousUser ? `Return to ${previousUser.name}` : 'Stop Masquerading';
const { t } = useTranslation();
const buttonText = previousUser ? t('platform.masquerade.returnTo', { name: previousUser.name }) : t('platform.masquerade.stopMasquerading');
return (
<div className="bg-orange-600 text-white px-4 py-2 shadow-md flex items-center justify-between z-50 relative">
@@ -21,9 +23,9 @@ const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, orig
<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}
{t('platform.masquerade.masqueradingAs')} <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
<span className="opacity-75 mx-2 text-xs">|</span>
{t('platform.masquerade.loggedInAs', { name: originalUser.name })}
</span>
</div>
<button

View File

@@ -1,4 +1,5 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
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';
@@ -28,6 +29,7 @@ interface ResourceCalendarProps {
}
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('day');
const [currentDate, setCurrentDate] = useState(new Date());
const timelineRef = useRef<HTMLDivElement>(null);
@@ -712,12 +714,12 @@ const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourc
{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>
<p className="text-gray-400 dark:text-gray-500">{t('scheduler.loadingAppointments')}</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>
<p className="text-gray-400 dark:text-gray-500">{t('scheduler.noAppointmentsScheduled')}</p>
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Clock, GripVertical } from 'lucide-react';
import { clsx } from 'clsx';
import { useTranslation } from 'react-i18next';
export interface PendingAppointment {
id: number;
@@ -15,6 +16,7 @@ interface PendingItemProps {
}
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `pending-${appointment.id}`,
data: {
@@ -43,7 +45,7 @@ const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400">
<Clock size={10} />
<span>{appointment.durationMinutes} min</span>
<span>{appointment.durationMinutes} {t('scheduler.min')}</span>
</div>
</div>
);
@@ -54,16 +56,18 @@ interface PendingSidebarProps {
}
const PendingSidebar: React.FC<PendingSidebarProps> = ({ appointments }) => {
const { t } = useTranslation();
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})
<Clock size={12} /> {t('scheduler.pendingRequests')} ({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>
<div className="text-xs text-gray-400 italic text-center py-4">{t('scheduler.noPendingRequests')}</div>
) : (
appointments.map(apt => (
<PendingItem key={apt.id} appointment={apt} />

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Clock, GripVertical, Trash2 } from 'lucide-react';
import { clsx } from 'clsx';
import { useTranslation } from 'react-i18next';
export interface PendingAppointment {
id: number;
@@ -22,6 +23,7 @@ interface PendingItemProps {
}
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `pending-${appointment.id}`,
data: {
@@ -50,7 +52,7 @@ const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
</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>
<span>{appointment.durationMinutes} {t('scheduler.min')}</span>
</div>
</div>
);
@@ -63,11 +65,13 @@ interface SidebarProps {
}
const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments, scrollRef }) => {
const { t } = useTranslation();
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
{t('scheduler.resources')}
</div>
{/* Resources List (Synced Scroll) */}
@@ -89,10 +93,10 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
<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
{t('scheduler.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
{layout.laneCount} {t('scheduler.lanes')}
</span>
)}
</p>
@@ -106,11 +110,11 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
{/* 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})
<Clock size={12} /> {t('scheduler.pendingRequests')} ({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>
<div className="text-xs text-gray-400 italic text-center py-4">{t('scheduler.noPendingRequests')}</div>
) : (
pendingAppointments.map(apt => (
<PendingItem key={apt.id} appointment={apt} />
@@ -122,7 +126,7 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
<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>
<span className="text-xs font-medium">{t('scheduler.dropToArchive')}</span>
</div>
</div>
</div>

View File

@@ -1,18 +1,20 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import './ServiceList.css';
const ServiceList = ({ services, onSelectService, loading }) => {
const { t } = useTranslation();
if (loading) {
return <div className="service-list-loading">Loading services...</div>;
return <div className="service-list-loading">{t('services.loadingServices')}</div>;
}
if (!services || services.length === 0) {
return <div className="service-list-empty">No services available</div>;
return <div className="service-list-empty">{t('services.noServicesAvailable')}</div>;
}
return (
<div className="service-list">
<h2>Available Services</h2>
<h2>{t('services.availableServices')}</h2>
<div className="service-grid">
{services.map((service) => (
<div
@@ -28,7 +30,7 @@ const ServiceList = ({ services, onSelectService, loading }) => {
{service.description && (
<p className="service-description">{service.description}</p>
)}
<button className="service-book-btn">Book Now</button>
<button className="service-book-btn">{t('services.bookNow')}</button>
</div>
))}
</div>

View File

@@ -59,7 +59,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<button
onClick={toggleCollapse}
className={`flex items-center gap-3 w-full text-left px-6 py-6 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-label={isCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')}
>
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
<div className="flex items-center justify-center w-full">
@@ -234,7 +234,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
>
<SmoothScheduleLogo className="w-5 h-5 text-white" />
{!isCollapsed && (
<span className="text-white/60">Smooth Schedule</span>
<span className="text-white/60">{t('nav.smoothSchedule')}</span>
)}
</a>
<button

View File

@@ -4,6 +4,7 @@
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Key,
Eye,
@@ -30,6 +31,7 @@ interface StripeApiKeysFormProps {
}
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
const { t } = useTranslation();
const [secretKey, setSecretKey] = useState('');
const [publishableKey, setPublishableKey] = useState('');
const [showSecretKey, setShowSecretKey] = useState(false);
@@ -72,7 +74,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
} catch (error: any) {
setValidationResult({
valid: false,
error: error.response?.data?.error || 'Validation failed',
error: error.response?.data?.error || t('payments.stripeApiKeys.validationFailed'),
});
}
};
@@ -87,7 +89,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
} catch (error: any) {
setValidationResult({
valid: false,
error: error.response?.data?.error || 'Failed to save keys',
error: error.response?.data?.error || t('payments.stripeApiKeys.failedToSaveKeys'),
});
}
};
@@ -121,7 +123,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
<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
{t('payments.stripeApiKeys.configured')}
</h4>
<div className="flex items-center gap-2">
{/* Environment Badge */}
@@ -136,12 +138,12 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{keyEnvironment === 'test' ? (
<>
<FlaskConical size={12} />
Test Mode
{t('payments.stripeApiKeys.testMode')}
</>
) : (
<>
<Zap size={12} />
Live Mode
{t('payments.stripeApiKeys.liveMode')}
</>
)}
</span>
@@ -163,22 +165,22 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Publishable Key:</span>
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.publishableKey')}:</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>
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.secretKey')}:</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-600 dark:text-gray-400">{t('payments.stripeApiKeys.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-600 dark:text-gray-400">{t('payments.stripeApiKeys.lastValidated')}:</span>
<span className="text-gray-900 dark:text-white">
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
</span>
@@ -190,10 +192,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{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>
<span dangerouslySetInnerHTML={{ __html: t('payments.stripeApiKeys.testKeysWarning') }} />
</div>
)}
@@ -214,14 +213,14 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
) : (
<RefreshCw size={16} />
)}
Re-validate
{t('payments.stripeApiKeys.revalidate')}
</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
{t('payments.stripeApiKeys.remove')}
</button>
</div>
</div>
@@ -233,10 +232,9 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
<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>
<h4 className="font-medium text-yellow-800">{t('payments.stripeApiKeys.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.
{t('payments.stripeApiKeys.deprecatedMessage')}
</p>
</div>
</div>
@@ -247,19 +245,18 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{(!isConfigured || isDeprecated) && (
<div className="space-y-4">
<h4 className="font-medium text-gray-900">
{isConfigured ? 'Update API Keys' : 'Add Stripe API Keys'}
{isConfigured ? t('payments.stripeApiKeys.updateApiKeys') : t('payments.stripeApiKeys.addApiKeys')}
</h4>
<p className="text-sm text-gray-600">
Enter your Stripe API keys to enable payment collection.
You can find these in your{' '}
{t('payments.stripeApiKeys.enterKeysDescription')}{' '}
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Stripe Dashboard
{t('payments.stripeApiKeys.stripeDashboard')}
</a>
.
</p>
@@ -267,7 +264,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{/* Publishable Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Publishable Key
{t('payments.stripeApiKeys.publishableKeyLabel')}
</label>
<div className="relative">
<Key
@@ -290,7 +287,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{/* Secret Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Secret Key
{t('payments.stripeApiKeys.secretKeyLabel')}
</label>
<div className="relative">
<Key
@@ -335,7 +332,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{validationResult.valid ? (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium">Keys are valid!</span>
<span className="font-medium">{t('payments.stripeApiKeys.keysAreValid')}</span>
{validationResult.environment && (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
@@ -347,23 +344,23 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
{validationResult.environment === 'test' ? (
<>
<FlaskConical size={10} />
Test Mode
{t('payments.stripeApiKeys.testMode')}
</>
) : (
<>
<Zap size={10} />
Live Mode
{t('payments.stripeApiKeys.liveMode')}
</>
)}
</span>
)}
</div>
{validationResult.accountName && (
<div>Connected to: {validationResult.accountName}</div>
<div>{t('payments.stripeApiKeys.connectedTo', { accountName: 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.
{t('payments.stripeApiKeys.testKeysNote')}
</div>
)}
</div>
@@ -386,7 +383,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
) : (
<CheckCircle size={16} />
)}
Validate
{t('payments.stripeApiKeys.validate')}
</button>
<button
onClick={handleSave}
@@ -398,7 +395,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
) : (
<Key size={16} />
)}
Save Keys
{t('payments.stripeApiKeys.saveKeys')}
</button>
</div>
</div>
@@ -409,18 +406,17 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
<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?
{t('payments.stripeApiKeys.removeApiKeys')}
</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.
{t('payments.stripeApiKeys.removeApiKeysMessage')}
</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
{t('payments.stripeApiKeys.cancel')}
</button>
<button
onClick={handleDelete}
@@ -428,7 +424,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
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
{t('payments.stripeApiKeys.remove')}
</button>
</div>
</div>

View File

@@ -6,6 +6,7 @@
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
X,
CreditCard,
@@ -37,6 +38,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
transactionId,
onClose,
}) => {
const { t } = useTranslation();
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
const refundMutation = useRefundTransaction();
@@ -62,11 +64,11 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
if (refundType === 'partial') {
const amountCents = Math.round(parseFloat(refundAmount) * 100);
if (isNaN(amountCents) || amountCents <= 0) {
setRefundError('Please enter a valid refund amount');
setRefundError(t('payments.enterValidRefundAmount'));
return;
}
if (amountCents > transaction.refundable_amount) {
setRefundError(`Amount exceeds refundable amount ($${(transaction.refundable_amount / 100).toFixed(2)})`);
setRefundError(t('payments.amountExceedsRefundable', { max: (transaction.refundable_amount / 100).toFixed(2) }));
return;
}
request.amount = amountCents;
@@ -80,7 +82,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
setShowRefundForm(false);
setRefundAmount('');
} catch (err: any) {
setRefundError(err.response?.data?.error || 'Failed to process refund');
setRefundError(err.response?.data?.error || t('payments.failedToProcessRefund'));
}
};
@@ -143,7 +145,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
</p>
{pm.exp_month && pm.exp_year && (
<p className="text-sm text-gray-500">
Expires {pm.exp_month}/{pm.exp_year}
{t('payments.expires')} {pm.exp_month}/{pm.exp_year}
{pm.funding && ` (${pm.funding})`}
</p>
)}
@@ -176,7 +178,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<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
{t('payments.transactionDetails')}
</h3>
{transaction && (
<p className="text-sm text-gray-500 font-mono">
@@ -204,7 +206,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<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>
<p className="font-medium">{t('payments.failedToLoadTransaction')}</p>
</div>
</div>
)}
@@ -228,7 +230,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
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
{t('payments.issueRefund')}
</button>
)}
</div>
@@ -238,7 +240,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<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>
<h4 className="font-semibold">{t('payments.issueRefund')}</h4>
</div>
{/* Refund Type */}
@@ -252,7 +254,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">
Full refund (${(transaction.refundable_amount / 100).toFixed(2)})
{t('payments.fullRefundAmount', { amount: (transaction.refundable_amount / 100).toFixed(2) })}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
@@ -263,7 +265,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
onChange={() => setRefundType('partial')}
className="text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Partial refund</span>
<span className="text-sm text-gray-700">{t('payments.partialRefund')}</span>
</label>
</div>
@@ -271,7 +273,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
{refundType === 'partial' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refund Amount (max ${(transaction.refundable_amount / 100).toFixed(2)})
{t('payments.refundAmountMax', { 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>
@@ -292,16 +294,16 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
{/* Reason */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Refund Reason
{t('payments.refundReason')}
</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>
<option value="requested_by_customer">{t('payments.requestedByCustomer')}</option>
<option value="duplicate">{t('payments.duplicate')}</option>
<option value="fraudulent">{t('payments.fraudulent')}</option>
</select>
</div>
@@ -322,12 +324,12 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
{refundMutation.isPending ? (
<>
<Loader2 className="animate-spin" size={16} />
Processing...
{t('payments.processing')}
</>
) : (
<>
<RefreshCcw size={16} />
Confirm Refund
{t('payments.processRefund')}
</>
)}
</button>
@@ -340,7 +342,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
disabled={refundMutation.isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
>
Cancel
{t('common.cancel')}
</button>
</div>
</div>
@@ -352,7 +354,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<User size={16} />
Customer
{t('payments.customer')}
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
{transaction.customer_name && (
@@ -378,27 +380,27 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<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
{t('payments.amountBreakdown')}
</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="text-gray-600">{t('payments.grossAmount')}</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-gray-600">{t('payments.platformFee')}</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-gray-600">{t('payments.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-medium text-gray-900 dark:text-white">{t('payments.netAmount')}</span>
<span className="font-bold text-green-600">
${(transaction.net_amount / 100).toFixed(2)}
</span>
@@ -412,7 +414,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<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
{t('payments.paymentMethod')}
</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
{getPaymentMethodDisplay()}
@@ -425,7 +427,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Receipt size={16} />
Description
{t('payments.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>
@@ -438,7 +440,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<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
{t('payments.refundHistory')}
</h4>
<div className="space-y-3">
{transaction.refunds.map((refund: RefundInfo) => (
@@ -451,7 +453,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<p className="text-sm text-orange-600">
{refund.reason
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
: 'No reason provided'}
: t('payments.noReasonProvided')}
</p>
<p className="text-xs text-orange-500 mt-1">
{formatRefundDate(refund.created)}
@@ -482,12 +484,12 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<div className="space-y-4">
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Calendar size={16} />
Timeline
{t('payments.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="text-gray-600">{t('payments.created')}</span>
<span className="ml-auto text-gray-900 dark:text-white">
{formatDate(transaction.created_at)}
</span>
@@ -495,7 +497,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
{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="text-gray-600">{t('payments.lastUpdated')}</span>
<span className="ml-auto text-gray-900 dark:text-white">
{formatDate(transaction.updated_at)}
</span>
@@ -508,29 +510,29 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
<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
{t('payments.technicalDetails')}
</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-500">{t('payments.paymentIntent')}</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-500">{t('payments.chargeId')}</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-500">{t('payments.transactionId')}</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-500">{t('payments.currency')}</span>
<span className="text-gray-700 dark:text-gray-300 uppercase">
{transaction.currency}
</span>

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, X, Users, User } from 'lucide-react';
import { Appointment, Resource } from '../../types';
import { startOfWeek, endOfWeek, isWithinInterval } from 'date-fns';
@@ -16,6 +17,7 @@ const CapacityWidget: React.FC<CapacityWidgetProps> = ({
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const capacityData = useMemo(() => {
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
@@ -103,7 +105,7 @@ const CapacityWidget: React.FC<CapacityWidgetProps> = ({
{capacityData.resources.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<Users size={32} className="mb-2 opacity-50" />
<p className="text-sm">No resources configured</p>
<p className="text-sm">{t('dashboard.noResourcesConfigured')}</p>
</div>
) : (
<div className="flex-1 grid grid-cols-2 gap-2 auto-rows-min">

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, X, Users, UserPlus, UserCheck } from 'lucide-react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { Customer } from '../../types';
@@ -14,6 +15,7 @@ const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const breakdownData = useMemo(() => {
// Customers with lastVisit are returning, without are new
const returning = customers.filter((c) => c.lastVisit !== null).length;
@@ -122,7 +124,7 @@ const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
<Users size={12} />
<span>Total Customers</span>
<span>{t('dashboard.totalCustomers')}</span>
</div>
<span className="font-semibold text-gray-900 dark:text-white">{breakdownData.total}</span>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { TrendingUp, TrendingDown, Minus, GripVertical, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface GrowthData {
weekly: { value: number; change: number };
@@ -23,6 +24,7 @@ const MetricWidget: React.FC<MetricWidgetProps> = ({
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const formatChange = (change: number) => {
if (change === 0) return '0%';
return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`;
@@ -68,14 +70,14 @@ const MetricWidget: React.FC<MetricWidgetProps> = ({
<div className="flex flex-wrap gap-2 text-xs">
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">Week:</span>
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.weekLabel')}</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(growth.weekly.change)}`}>
{getTrendIcon(growth.weekly.change)}
{formatChange(growth.weekly.change)}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">Month:</span>
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.monthLabel')}</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(growth.monthly.change)}`}>
{getTrendIcon(growth.monthly.change)}
{formatChange(growth.monthly.change)}

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, X, UserX, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { Appointment } from '../../types';
import { subDays, subMonths, isAfter } from 'date-fns';
@@ -14,6 +15,8 @@ const NoShowRateWidget: React.FC<NoShowRateWidgetProps> = ({
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const noShowData = useMemo(() => {
const now = new Date();
const oneWeekAgo = subDays(now, 7);
@@ -108,7 +111,7 @@ const NoShowRateWidget: React.FC<NoShowRateWidgetProps> = ({
<div className={isEditing ? 'pl-5' : ''}>
<div className="flex items-center gap-2 mb-2">
<UserX size={18} className="text-gray-400" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">No-Show Rate</p>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('dashboard.noShowRate')}</p>
</div>
<div className="flex items-baseline gap-2 mb-1">
@@ -116,20 +119,20 @@ const NoShowRateWidget: React.FC<NoShowRateWidgetProps> = ({
{noShowData.currentRate.toFixed(1)}%
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
({noShowData.noShowCount} this month)
({noShowData.noShowCount} {t('dashboard.thisMonth')})
</span>
</div>
<div className="flex flex-wrap gap-2 text-xs mt-2">
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">Week:</span>
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.week')}:</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(noShowData.weeklyChange)}`}>
{getTrendIcon(noShowData.weeklyChange)}
{formatChange(noShowData.weeklyChange)}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">Month:</span>
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.month')}:</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(noShowData.monthlyChange)}`}>
{getTrendIcon(noShowData.monthlyChange)}
{formatChange(noShowData.monthlyChange)}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { GripVertical, X, AlertCircle, Clock, ChevronRight } from 'lucide-react';
import { Ticket } from '../../types';
@@ -15,7 +16,8 @@ const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
isEditing,
onRemove,
}) => {
const openTickets = tickets.filter(t => t.status === 'open' || t.status === 'in_progress');
const { t } = useTranslation();
const openTickets = tickets.filter(ticket => ticket.status === 'open' || ticket.status === 'in_progress');
const urgentCount = openTickets.filter(t => t.priority === 'urgent' || t.isOverdue).length;
const getPriorityColor = (priority: string, isOverdue?: boolean) => {
@@ -75,7 +77,7 @@ const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
{openTickets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
<AlertCircle size={32} className="mb-2 opacity-50" />
<p className="text-sm">No open tickets</p>
<p className="text-sm">{t('dashboard.noOpenTickets')}</p>
</div>
) : (
openTickets.slice(0, 5).map((ticket) => (

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, X, Calendar, UserPlus, XCircle, CheckCircle, DollarSign } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Appointment, Customer } from '../../types';
@@ -26,6 +27,7 @@ const RecentActivityWidget: React.FC<RecentActivityWidgetProps> = ({
isEditing,
onRemove,
}) => {
const { t } = useTranslation();
const activities = useMemo(() => {
const items: ActivityItem[] = [];
@@ -112,7 +114,7 @@ const RecentActivityWidget: React.FC<RecentActivityWidgetProps> = ({
{activities.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
<Calendar size={32} className="mb-2 opacity-50" />
<p className="text-sm">No recent activity</p>
<p className="text-sm">{t('dashboard.noRecentActivity')}</p>
</div>
) : (
<div className="space-y-3">

View File

@@ -1,33 +1,36 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Rocket, Shield, Zap, Headphones } from 'lucide-react';
const BenefitsSection: React.FC = () => {
const { t } = useTranslation();
const benefits = [
{
icon: Rocket,
title: 'Rapid Deployment',
description: 'Launch your branded booking portal in minutes with our pre-configured industry templates.',
title: t('marketing.benefits.rapidDeployment.title'),
description: t('marketing.benefits.rapidDeployment.description'),
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
},
{
icon: Shield,
title: 'Enterprise Security',
description: 'Sleep soundly knowing your data is physically isolated in its own dedicated secure vault.',
title: t('marketing.benefits.enterpriseSecurity.title'),
description: t('marketing.benefits.enterpriseSecurity.description'),
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-100 dark:bg-green-900/30',
},
{
icon: Zap,
title: 'High Performance',
description: 'Built on a modern, edge-cached architecture to ensure instant loading times globally.',
title: t('marketing.benefits.highPerformance.title'),
description: t('marketing.benefits.highPerformance.description'),
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
},
{
icon: Headphones,
title: 'Expert Support',
description: 'Our team of scheduling experts is available to help you optimize your automation workflows.',
title: t('marketing.benefits.expertSupport.title'),
description: t('marketing.benefits.expertSupport.description'),
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-100 dark:bg-orange-900/30',
},

View File

@@ -1,9 +1,11 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Mail, Calendar, Bell, ArrowRight, Zap, CheckCircle2, Code, LayoutGrid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CodeBlock from './CodeBlock';
const PluginShowcase: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const [viewMode, setViewMode] = useState<'marketplace' | 'code'>('marketplace');
@@ -11,69 +13,29 @@ const PluginShowcase: React.FC = () => {
{
id: 'winback',
icon: Mail,
title: 'Client Win-Back',
description: 'Automatically re-engage customers who haven\'t visited in 60 days.',
stats: ['+15% Retention', '$4k/mo Revenue'],
title: t('marketing.plugins.examples.winback.title'),
description: t('marketing.plugins.examples.winback.description'),
stats: [t('marketing.plugins.examples.winback.stats.retention'), t('marketing.plugins.examples.winback.stats.revenue')],
marketplaceImage: 'bg-gradient-to-br from-pink-500 to-rose-500',
code: `# Win back lost customers
days_inactive = 60
discount = "20%"
# Find inactive customers
inactive = api.get_customers(
last_visit_lt=days_ago(days_inactive)
)
# Send personalized offer
for customer in inactive:
api.send_email(
to=customer.email,
subject="We miss you!",
body=f"Come back for {discount} off!"
)`,
code: t('marketing.plugins.examples.winback.code'),
},
{
id: 'noshow',
icon: Bell,
title: 'No-Show Prevention',
description: 'Send SMS reminders 2 hours before appointments to reduce no-shows.',
stats: ['-40% No-Shows', 'Better Utilization'],
title: t('marketing.plugins.examples.noshow.title'),
description: t('marketing.plugins.examples.noshow.description'),
stats: [t('marketing.plugins.examples.noshow.stats.reduction'), t('marketing.plugins.examples.noshow.stats.utilization')],
marketplaceImage: 'bg-gradient-to-br from-blue-500 to-cyan-500',
code: `# Prevent no-shows
hours_before = 2
# Find upcoming appointments
upcoming = api.get_appointments(
start_time__within=hours(hours_before)
)
# Send SMS reminder
for appt in upcoming:
api.send_sms(
to=appt.customer.phone,
body=f"Reminder: Appointment in 2h at {appt.time}"
)`,
code: t('marketing.plugins.examples.noshow.code'),
},
{
id: 'report',
icon: Calendar,
title: 'Daily Reports',
description: 'Get a summary of tomorrow\'s schedule sent to your inbox every evening.',
stats: ['Save 30min/day', 'Full Visibility'],
title: t('marketing.plugins.examples.report.title'),
description: t('marketing.plugins.examples.report.description'),
stats: [t('marketing.plugins.examples.report.stats.timeSaved'), t('marketing.plugins.examples.report.stats.visibility')],
marketplaceImage: 'bg-gradient-to-br from-purple-500 to-indigo-500',
code: `# Daily Manager Report
tomorrow = date.today() + timedelta(days=1)
# Get schedule stats
stats = api.get_schedule_stats(date=tomorrow)
revenue = api.forecast_revenue(date=tomorrow)
# Email manager
api.send_email(
to="manager@business.com",
subject=f"Schedule for {tomorrow}",
body=f"Bookings: {stats.count}, Est. Rev: \${revenue}"
)`,
code: t('marketing.plugins.examples.report.code'),
},
];
@@ -88,16 +50,15 @@ api.send_email(
<div>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 text-sm font-medium mb-6">
<Zap className="w-4 h-4" />
<span>Limitless Automation</span>
<span>{t('marketing.plugins.badge')}</span>
</div>
<h2 className="text-4xl font-bold text-gray-900 dark:text-white mb-6">
Choose from our Marketplace, or build your own.
{t('marketing.plugins.headline')}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-10">
Browse hundreds of pre-built plugins to automate your workflows instantly.
Need something custom? Developers can write Python scripts to extend the platform endlessly.
{t('marketing.plugins.subheadline')}
</p>
<div className="space-y-4">
@@ -147,7 +108,7 @@ api.send_email(
}`}
>
<LayoutGrid className="w-4 h-4" />
Marketplace
{t('marketing.plugins.viewToggle.marketplace')}
</button>
<button
onClick={() => setViewMode('code')}
@@ -157,7 +118,7 @@ api.send_email(
}`}
>
<Code className="w-4 h-4" />
Developer
{t('marketing.plugins.viewToggle.developer')}
</button>
</div>
@@ -190,10 +151,10 @@ api.send_email(
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{examples[activeTab].title}</h3>
<div className="text-sm text-gray-500">by SmoothSchedule Team</div>
<div className="text-sm text-gray-500">{t('marketing.plugins.marketplaceCard.author')}</div>
</div>
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg font-medium text-sm hover:bg-brand-700 transition-colors">
Install Plugin
{t('marketing.plugins.marketplaceCard.installButton')}
</button>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-6">
@@ -205,7 +166,7 @@ api.send_email(
<div key={i} className="w-6 h-6 rounded-full bg-gray-300 border-2 border-white dark:border-gray-800" />
))}
</div>
<span>Used by 1,200+ businesses</span>
<span>{t('marketing.plugins.marketplaceCard.usedBy')}</span>
</div>
</div>
</div>
@@ -220,7 +181,7 @@ api.send_email(
{/* CTA */}
<div className="mt-6 text-right">
<a href="/features" className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:underline">
Explore the Marketplace <ArrowRight className="w-4 h-4" />
{t('marketing.plugins.cta')} <ArrowRight className="w-4 h-4" />
</a>
</div>
</motion.div>

View File

@@ -1,69 +1,72 @@
import React from 'react';
import { Check, X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const PricingTable: React.FC = () => {
const { t } = useTranslation();
const tiers = [
{
name: 'Starter',
name: t('marketing.pricing.tiers.starter.name'),
price: '$0',
period: '/month',
description: 'Perfect for solo practitioners and small studios.',
period: t('marketing.pricing.perMonth'),
description: t('marketing.pricing.tiers.starter.description'),
features: [
'1 User',
'Unlimited Appointments',
'1 Active Automation',
'Basic Reporting',
'Email Support',
t('marketing.pricing.tiers.starter.features.0'),
t('marketing.pricing.tiers.starter.features.1'),
t('marketing.pricing.tiers.starter.features.2'),
t('marketing.pricing.tiers.starter.features.3'),
t('marketing.pricing.tiers.starter.features.4'),
],
notIncluded: [
'Custom Domain',
'Python Scripting',
'White-Labeling',
'Priority Support',
t('marketing.pricing.tiers.starter.notIncluded.0'),
t('marketing.pricing.tiers.starter.notIncluded.1'),
t('marketing.pricing.tiers.starter.notIncluded.2'),
t('marketing.pricing.tiers.starter.notIncluded.3'),
],
cta: 'Start Free',
cta: t('marketing.pricing.tiers.starter.cta'),
ctaLink: '/signup',
popular: false,
},
{
name: 'Pro',
name: t('marketing.pricing.tiers.pro.name'),
price: '$29',
period: '/month',
description: 'For growing businesses that need automation.',
period: t('marketing.pricing.perMonth'),
description: t('marketing.pricing.tiers.pro.description'),
features: [
'5 Users',
'Unlimited Appointments',
'5 Active Automations',
'Advanced Reporting',
'Priority Email Support',
'SMS Reminders',
t('marketing.pricing.tiers.pro.features.0'),
t('marketing.pricing.tiers.pro.features.1'),
t('marketing.pricing.tiers.pro.features.2'),
t('marketing.pricing.tiers.pro.features.3'),
t('marketing.pricing.tiers.pro.features.4'),
t('marketing.pricing.tiers.pro.features.5'),
],
notIncluded: [
'Custom Domain',
'Python Scripting',
'White-Labeling',
t('marketing.pricing.tiers.pro.notIncluded.0'),
t('marketing.pricing.tiers.pro.notIncluded.1'),
t('marketing.pricing.tiers.pro.notIncluded.2'),
],
cta: 'Start Trial',
cta: t('marketing.pricing.tiers.pro.cta'),
ctaLink: '/signup?plan=pro',
popular: true,
},
{
name: 'Business',
name: t('marketing.pricing.tiers.business.name'),
price: '$99',
period: '/month',
description: 'Full power of the platform for serious operations.',
period: t('marketing.pricing.perMonth'),
description: t('marketing.pricing.tiers.business.description'),
features: [
'Unlimited Users',
'Unlimited Appointments',
'Unlimited Automations',
'Custom Python Scripts',
'Custom Domain (White-Label)',
'Dedicated Support',
'API Access',
t('marketing.pricing.tiers.business.features.0'),
t('marketing.pricing.tiers.business.features.1'),
t('marketing.pricing.tiers.business.features.2'),
t('marketing.pricing.tiers.business.features.3'),
t('marketing.pricing.tiers.business.features.4'),
t('marketing.pricing.tiers.business.features.5'),
t('marketing.pricing.tiers.business.features.6'),
],
notIncluded: [],
cta: 'Contact Sales',
cta: t('marketing.pricing.contactSales'),
ctaLink: '/contact',
popular: false,
},
@@ -81,7 +84,7 @@ const PricingTable: React.FC = () => {
>
{tier.popular && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 px-4 py-1 bg-brand-500 text-white text-sm font-medium rounded-full">
Most Popular
{t('marketing.pricing.mostPopular')}
</div>
)}