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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user