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

Translate all hardcoded English strings to use i18n translation keys:

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

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

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

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

View File

@@ -237,16 +237,16 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
<form onSubmit={handleAddCustomer} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.fullName')} <span className="text-red-500">*</span></label>
<input type="text" name="name" required value={formData.name} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder="e.g. John Doe" />
<input type="text" name="name" required value={formData.name} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.namePlaceholder')} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.emailAddress')} <span className="text-red-500">*</span></label>
<input type="email" name="email" required value={formData.email} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder="e.g. john@example.com" />
<input type="email" name="email" required value={formData.email} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.emailPlaceholder')} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('customers.phoneNumber')} <span className="text-red-500">*</span></label>
<input type="tel" name="phone" required value={formData.phone} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder="e.g. (555) 123-4567" />
<input type="tel" name="phone" required value={formData.phone} onChange={handleInputChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-colors" placeholder={t('customers.phonePlaceholder')} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">

View File

@@ -1131,13 +1131,9 @@ my $response = $ua->get('${SANDBOX_URL}/services/',
<AlertCircle size={20} className="text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-semibold text-yellow-800 dark:text-yellow-200">
No Test Tokens Found
{t('help.api.noTestTokensFound')}
</h3>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
Create a <strong>test/sandbox</strong> API token in your Settings to see personalized code examples with your actual token.
Make sure to check the "Sandbox Mode" option when creating the token.
The examples below use placeholder tokens.
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1" dangerouslySetInnerHTML={{ __html: t('help.api.noTestTokensMessage') }} />
</div>
</div>
</div>
@@ -1150,10 +1146,10 @@ my $response = $ua->get('${SANDBOX_URL}/services/',
<AlertCircle size={20} className="text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-semibold text-red-800 dark:text-red-200">
Error Loading Tokens
{t('help.api.errorLoadingTokens')}
</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
Failed to load API tokens. Please check your connection and try refreshing the page.
{t('help.api.errorLoadingTokensMessage')}
</p>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
CreditCard,
Plus,
@@ -47,6 +48,7 @@ import { TransactionFilters } from '../api/payments';
type TabType = 'overview' | 'transactions' | 'payouts' | 'settings';
const Payments: React.FC = () => {
const { t } = useTranslation();
const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>();
const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'manager';
@@ -111,7 +113,7 @@ const Payments: React.FC = () => {
const handleDeleteMethod = (pmId: string) => {
if (!customerProfile) return;
if (window.confirm("Are you sure you want to delete this payment method?")) {
if (window.confirm(t('payments.confirmDeletePaymentMethod'))) {
const updatedMethods = customerProfile.paymentMethods.filter(pm => pm.id !== pmId);
if (updatedMethods.length > 0 && !updatedMethods.some(pm => pm.isDefault)) {
updatedMethods[0].isDefault = true;
@@ -187,8 +189,8 @@ const Payments: React.FC = () => {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Payments & Analytics</h2>
<p className="text-gray-500 dark:text-gray-400">Manage payments and view transaction analytics</p>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('payments.paymentsAndAnalytics')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('payments.managePaymentsDescription')}</p>
</div>
{canAcceptPayments && (
<button
@@ -196,7 +198,7 @@ const Payments: React.FC = () => {
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
<Download size={16} />
Export Data
{t('payments.exportData')}
</button>
)}
</div>
@@ -205,10 +207,10 @@ const Payments: React.FC = () => {
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'transactions', label: 'Transactions', icon: CreditCard },
{ id: 'payouts', label: 'Payouts', icon: Wallet },
{ id: 'settings', label: 'Settings', icon: CreditCard },
{ id: 'overview', label: t('payments.overview'), icon: BarChart3 },
{ id: 'transactions', label: t('payments.transactions'), icon: CreditCard },
{ id: 'payouts', label: t('payments.payouts'), icon: Wallet },
{ id: 'settings', label: t('payments.settings'), icon: CreditCard },
].map((tab) => (
<button
key={tab.id}
@@ -234,15 +236,15 @@ const Payments: React.FC = () => {
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={24} />
<div>
<h3 className="font-semibold text-yellow-800">Payment Setup Required</h3>
<h3 className="font-semibold text-yellow-800">{t('payments.paymentSetupRequired')}</h3>
<p className="text-yellow-700 mt-1">
Complete your payment setup in the Settings tab to start accepting payments and see analytics.
{t('payments.paymentSetupRequiredDesc')}
</p>
<button
onClick={() => setActiveTab('settings')}
className="mt-3 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-lg hover:bg-yellow-200"
>
Go to Settings
{t('payments.goToSettings')}
</button>
</div>
</div>
@@ -254,7 +256,7 @@ const Payments: React.FC = () => {
{/* Total Revenue */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Revenue</p>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{t('payments.totalRevenue')}</p>
<div className="p-2 bg-green-100 rounded-lg">
<DollarSign className="text-green-600" size={20} />
</div>
@@ -267,7 +269,7 @@ const Payments: React.FC = () => {
{summary?.net_revenue_display || '$0.00'}
</p>
<p className="text-sm text-gray-500 mt-1">
{summary?.total_transactions || 0} transactions
{summary?.total_transactions || 0} {t('payments.transactionsCount')}
</p>
</>
)}
@@ -276,7 +278,7 @@ const Payments: React.FC = () => {
{/* Available Balance */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Available Balance</p>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{t('payments.availableBalance')}</p>
<div className="p-2 bg-blue-100 rounded-lg">
<Wallet className="text-blue-600" size={20} />
</div>
@@ -289,7 +291,7 @@ const Payments: React.FC = () => {
${((balance?.available_total || 0) / 100).toFixed(2)}
</p>
<p className="text-sm text-gray-500 mt-1">
${((balance?.pending_total || 0) / 100).toFixed(2)} pending
${((balance?.pending_total || 0) / 100).toFixed(2)} {t('payments.pending')}
</p>
</>
)}
@@ -298,7 +300,7 @@ const Payments: React.FC = () => {
{/* Success Rate */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Success Rate</p>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{t('payments.successRate')}</p>
<div className="p-2 bg-purple-100 rounded-lg">
<TrendingUp className="text-purple-600" size={20} />
</div>
@@ -314,7 +316,7 @@ const Payments: React.FC = () => {
</p>
<p className="text-sm text-green-600 mt-1 flex items-center gap-1">
<ArrowUpRight size={14} />
{summary?.successful_transactions || 0} successful
{summary?.successful_transactions || 0} {t('payments.successful')}
</p>
</>
)}
@@ -323,7 +325,7 @@ const Payments: React.FC = () => {
{/* Average Transaction */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Avg Transaction</p>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{t('payments.avgTransaction')}</p>
<div className="p-2 bg-orange-100 rounded-lg">
<BarChart3 className="text-orange-600" size={20} />
</div>
@@ -336,7 +338,7 @@ const Payments: React.FC = () => {
{summary?.average_transaction_display || '$0.00'}
</p>
<p className="text-sm text-gray-500 mt-1">
Platform fees: {summary?.total_fees_display || '$0.00'}
{t('payments.platformFees')} {summary?.total_fees_display || '$0.00'}
</p>
</>
)}
@@ -346,22 +348,22 @@ const Payments: React.FC = () => {
{/* Recent Transactions */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Recent Transactions</h3>
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{t('payments.recentTransactions')}</h3>
<button
onClick={() => setActiveTab('transactions')}
className="text-sm text-brand-600 hover:text-brand-700 font-medium"
>
View All
{t('payments.viewAll')}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.customer')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.date')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.amount')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.status')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
@@ -380,7 +382,7 @@ const Payments: React.FC = () => {
>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">
{txn.customer_name || 'Unknown'}
{txn.customer_name || t('payments.unknown')}
</p>
<p className="text-sm text-gray-500">{txn.customer_email}</p>
</td>
@@ -389,7 +391,7 @@ const Payments: React.FC = () => {
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{txn.amount_display}</p>
<p className="text-xs text-gray-500">Fee: {txn.fee_display}</p>
<p className="text-xs text-gray-500">{t('payments.fee')} {txn.fee_display}</p>
</td>
<td className="px-6 py-4">
{getStatusBadge(txn.status)}
@@ -399,7 +401,7 @@ const Payments: React.FC = () => {
) : (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
No transactions yet
{t('payments.noTransactionsYet')}
</td>
</tr>
)}
@@ -426,7 +428,7 @@ const Payments: React.FC = () => {
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Start date"
/>
<span className="text-gray-400">to</span>
<span className="text-gray-400">{t('payments.to')}</span>
<input
type="date"
value={dateRange.end}
@@ -441,11 +443,11 @@ const Payments: React.FC = () => {
onChange={(e) => setFilters({ ...filters, status: e.target.value as any, page: 1 })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Statuses</option>
<option value="succeeded">Succeeded</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="refunded">Refunded</option>
<option value="all">{t('payments.allStatuses')}</option>
<option value="succeeded">{t('payments.succeeded')}</option>
<option value="pending">{t('payments.pending')}</option>
<option value="failed">{t('payments.failed')}</option>
<option value="refunded">{t('payments.refunded')}</option>
</select>
<select
@@ -453,9 +455,9 @@ const Payments: React.FC = () => {
onChange={(e) => setFilters({ ...filters, transaction_type: e.target.value as any, page: 1 })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Types</option>
<option value="payment">Payment</option>
<option value="refund">Refund</option>
<option value="all">{t('payments.allTypes')}</option>
<option value="payment">{t('payments.payment')}</option>
<option value="refund">{t('payments.refund')}</option>
</select>
<button
@@ -463,7 +465,7 @@ const Payments: React.FC = () => {
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
>
<RefreshCcw size={14} />
Refresh
{t('payments.refresh')}
</button>
</div>
</div>
@@ -474,13 +476,13 @@ const Payments: React.FC = () => {
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Transaction</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Net</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.transaction')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.customer')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.date')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.amount')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.net')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.status')}</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.action')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
@@ -503,7 +505,7 @@ const Payments: React.FC = () => {
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">
{txn.customer_name || 'Unknown'}
{txn.customer_name || t('payments.unknown')}
</p>
<p className="text-sm text-gray-500">{txn.customer_email}</p>
</td>
@@ -518,7 +520,7 @@ const Payments: React.FC = () => {
{txn.transaction_type === 'refund' ? '-' : ''}${(txn.net_amount / 100).toFixed(2)}
</p>
{txn.application_fee_amount > 0 && (
<p className="text-xs text-gray-400">-{txn.fee_display} fee</p>
<p className="text-xs text-gray-400">-{txn.fee_display} {t('payments.fee')}</p>
)}
</td>
<td className="px-6 py-4">
@@ -533,7 +535,7 @@ const Payments: React.FC = () => {
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-brand-600 hover:text-brand-700 hover:bg-brand-50 rounded-lg transition-colors"
>
<Eye size={14} />
View
{t('payments.view')}
</button>
</td>
</tr>
@@ -541,7 +543,7 @@ const Payments: React.FC = () => {
) : (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
No transactions found
{t('payments.noTransactionsFound')}
</td>
</tr>
)}
@@ -553,8 +555,8 @@ const Payments: React.FC = () => {
{transactions && transactions.total_pages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500">
Showing {(filters.page! - 1) * filters.page_size! + 1} to{' '}
{Math.min(filters.page! * filters.page_size!, transactions.count)} of {transactions.count}
{t('payments.showing')} {(filters.page! - 1) * filters.page_size! + 1} {t('payments.to')}{' '}
{Math.min(filters.page! * filters.page_size!, transactions.count)} {t('payments.of')} {transactions.count}
</p>
<div className="flex items-center gap-2">
<button
@@ -565,7 +567,7 @@ const Payments: React.FC = () => {
<ChevronLeft size={20} />
</button>
<span className="text-sm text-gray-600">
Page {filters.page} of {transactions.total_pages}
{t('payments.page')} {filters.page} {t('payments.of')} {transactions.total_pages}
</span>
<button
onClick={() => setFilters({ ...filters, page: filters.page! + 1 })}
@@ -591,7 +593,7 @@ const Payments: React.FC = () => {
<Wallet className="text-green-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-500">Available for Payout</p>
<p className="text-sm text-gray-500">{t('payments.availableForPayout')}</p>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400" size={20} />
) : (
@@ -615,7 +617,7 @@ const Payments: React.FC = () => {
<Clock className="text-yellow-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-500">Pending</p>
<p className="text-sm text-gray-500">{t('payments.pending')}</p>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400" size={20} />
) : (
@@ -637,17 +639,17 @@ const Payments: React.FC = () => {
{/* Payouts List */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Payout History</h3>
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{t('payments.payoutHistory')}</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Payout ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Arrival Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.payoutId')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.amount')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.status')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.arrivalDate')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('payments.method')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
@@ -680,7 +682,7 @@ const Payments: React.FC = () => {
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
No payouts yet
{t('payments.noPayoutsYet')}
</td>
</tr>
)}
@@ -709,20 +711,20 @@ const Payments: React.FC = () => {
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold">Export Transactions</h3>
<h3 className="text-lg font-semibold">{t('payments.exportTransactions')}</h3>
<button onClick={() => setShowExportModal(false)} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Export Format</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('payments.exportFormat')}</label>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'csv', label: 'CSV', icon: FileText },
{ id: 'xlsx', label: 'Excel', icon: FileSpreadsheet },
{ id: 'pdf', label: 'PDF', icon: FileText },
{ id: 'quickbooks', label: 'QuickBooks', icon: FileSpreadsheet },
{ id: 'csv', label: t('payments.csv'), icon: FileText },
{ id: 'xlsx', label: t('payments.excel'), icon: FileSpreadsheet },
{ id: 'pdf', label: t('payments.pdf'), icon: FileText },
{ id: 'quickbooks', label: t('payments.quickbooks'), icon: FileSpreadsheet },
].map((format) => (
<button
key={format.id}
@@ -741,7 +743,7 @@ const Payments: React.FC = () => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Date Range (Optional)</label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('payments.dateRangeOptional')}</label>
<div className="flex items-center gap-2">
<input
type="date"
@@ -749,7 +751,7 @@ const Payments: React.FC = () => {
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
<span className="text-gray-400">to</span>
<span className="text-gray-400">{t('payments.to')}</span>
<input
type="date"
value={dateRange.end}
@@ -767,12 +769,12 @@ const Payments: React.FC = () => {
{exportMutation.isPending ? (
<>
<Loader2 className="animate-spin" size={18} />
Exporting...
{t('payments.exporting')}
</>
) : (
<>
<Download size={18} />
Export
{t('payments.export')}
</>
)}
</button>
@@ -790,16 +792,16 @@ const Payments: React.FC = () => {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Billing</h2>
<p className="text-gray-500 dark:text-gray-400">Manage your payment methods and view invoice history.</p>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('payments.billing')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('payments.billingDescription')}</p>
</div>
{/* Payment Methods */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Payment Methods</h3>
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{t('payments.paymentMethods')}</h3>
<button onClick={() => setIsAddCardModalOpen(true)} className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
<Plus size={16} /> Add Card
<Plus size={16} /> {t('payments.addCard')}
</button>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
@@ -808,14 +810,14 @@ const Payments: React.FC = () => {
<div className="flex items-center gap-4">
<CreditCard className="text-gray-400" size={24} />
<div>
<p className="font-medium text-gray-900 dark:text-white">{pm.brand} ending in {pm.last4}</p>
{pm.isDefault && <span className="text-xs font-medium text-green-600 dark:text-green-400">Default</span>}
<p className="font-medium text-gray-900 dark:text-white">{pm.brand} {t('payments.endingIn')} {pm.last4}</p>
{pm.isDefault && <span className="text-xs font-medium text-green-600 dark:text-green-400">{t('payments.default')}</span>}
</div>
</div>
<div className="flex items-center gap-2">
{!pm.isDefault && (
<button onClick={() => handleSetDefault(pm.id)} className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 font-medium">
<Star size={14} /> Set as Default
<Star size={14} /> {t('payments.setAsDefault')}
</button>
)}
<button onClick={() => handleDeleteMethod(pm.id)} className="p-2 text-gray-400 hover:text-red-500 dark:hover:text-red-400">
@@ -823,17 +825,17 @@ const Payments: React.FC = () => {
</button>
</div>
</div>
)) : <div className="p-8 text-center text-gray-500 dark:text-gray-400">No payment methods on file.</div>}
)) : <div className="p-8 text-center text-gray-500 dark:text-gray-400">{t('payments.noPaymentMethodsOnFile')}</div>}
</div>
</div>
{/* Invoice History */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Invoice History</h3>
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{t('payments.invoiceHistory')}</h3>
</div>
<div className="p-8 text-center text-gray-500">
No invoices yet.
{t('payments.noInvoicesYet')}
</div>
</div>
@@ -843,18 +845,18 @@ const Payments: React.FC = () => {
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setIsAddCardModalOpen(false)}>
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold">Add New Card</h3>
<h3 className="text-lg font-semibold">{t('payments.addNewCard')}</h3>
<button onClick={() => setIsAddCardModalOpen(false)}><X size={20} /></button>
</div>
<form onSubmit={handleAddCard} className="p-6 space-y-4">
<div><label className="text-sm font-medium">Card Number</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"> 4242</div></div>
<div><label className="text-sm font-medium">Cardholder Name</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">{effectiveUser.name}</div></div>
<div><label className="text-sm font-medium">{t('payments.cardNumber')}</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"> 4242</div></div>
<div><label className="text-sm font-medium">{t('payments.cardholderName')}</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">{effectiveUser.name}</div></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="text-sm font-medium">Expiry</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">12 / 2028</div></div>
<div><label className="text-sm font-medium">CVV</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"></div></div>
<div><label className="text-sm font-medium">{t('payments.expiry')}</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">12 / 2028</div></div>
<div><label className="text-sm font-medium">{t('payments.cvv')}</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"></div></div>
</div>
<p className="text-xs text-gray-400 text-center">This is a simulated form. No real card data is required.</p>
<button type="submit" className="w-full py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700">Add Card</button>
<p className="text-xs text-gray-400 text-center">{t('payments.simulatedFormNote')}</p>
<button type="submit" className="w-full py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700">{t('payments.addCard')}</button>
</form>
</div>
</div>
@@ -864,7 +866,7 @@ const Payments: React.FC = () => {
);
}
return <div>Access Denied or User not found.</div>;
return <div>{t('payments.accessDeniedOrUserNotFound')}</div>;
};
export default Payments;

View File

@@ -80,7 +80,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
};
const handleMakeBookable = (user: any) => {
if (confirm(`Create a bookable resource for ${user.name || user.username}?`)) {
if (confirm(t('staff.confirmMakeBookable', { name: user.name || user.username }))) {
createResourceMutation.mutate({
name: user.name || user.username,
type: 'STAFF',
@@ -95,7 +95,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setInviteSuccess('');
if (!inviteEmail.trim()) {
setInviteError(t('staff.emailRequired', 'Email is required'));
setInviteError(t('staff.emailRequired'));
return;
}
@@ -109,7 +109,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
};
await createInvitationMutation.mutateAsync(invitationData);
setInviteSuccess(t('staff.invitationSent', 'Invitation sent successfully!'));
setInviteSuccess(t('staff.invitationSent'));
setInviteEmail('');
setCreateBookableResource(false);
setResourceName('');
@@ -120,16 +120,16 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
setInviteSuccess('');
}, 1500);
} catch (err: any) {
setInviteError(err.response?.data?.error || t('staff.invitationFailed', 'Failed to send invitation'));
setInviteError(err.response?.data?.error || t('staff.invitationFailed'));
}
};
const handleCancelInvitation = async (invitation: StaffInvitation) => {
if (confirm(t('staff.confirmCancelInvitation', `Cancel invitation to ${invitation.email}?`))) {
if (confirm(t('staff.confirmCancelInvitation', { email: invitation.email }))) {
try {
await cancelInvitationMutation.mutateAsync(invitation.id);
} catch (err: any) {
alert(err.response?.data?.error || t('staff.cancelFailed', 'Failed to cancel invitation'));
alert(err.response?.data?.error || t('staff.cancelFailed'));
}
}
};
@@ -137,9 +137,9 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const handleResendInvitation = async (invitation: StaffInvitation) => {
try {
await resendInvitationMutation.mutateAsync(invitation.id);
alert(t('staff.invitationResent', 'Invitation resent successfully!'));
alert(t('staff.invitationResent'));
} catch (err: any) {
alert(err.response?.data?.error || t('staff.resendFailed', 'Failed to resend invitation'));
alert(err.response?.data?.error || t('staff.resendFailed'));
}
};
@@ -178,11 +178,11 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
const handleToggleActive = async (user: any) => {
const action = user.is_active ? 'deactivate' : 'reactivate';
if (confirm(t('staff.confirmToggleActive', `Are you sure you want to ${action} ${user.name}?`))) {
if (confirm(t('staff.confirmToggleActive', { action, name: user.name }))) {
try {
await toggleActiveMutation.mutateAsync(user.id);
} catch (err: any) {
alert(err.response?.data?.error || t('staff.toggleFailed', `Failed to ${action} staff member`));
alert(err.response?.data?.error || t('staff.toggleFailed', { action }));
}
}
};
@@ -212,12 +212,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
id: editingStaff.id,
updates: { permissions: editPermissions },
});
setEditSuccess(t('staff.settingsSaved', 'Settings saved successfully'));
setEditSuccess(t('staff.settingsSaved'));
setTimeout(() => {
closeEditModal();
}, 1000);
} catch (err: any) {
setEditError(err.response?.data?.error || t('staff.saveFailed', 'Failed to save settings'));
setEditError(err.response?.data?.error || t('staff.saveFailed'));
}
};
@@ -225,12 +225,12 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
if (!editingStaff) return;
const action = editingStaff.is_active ? 'deactivate' : 'reactivate';
if (confirm(t('staff.confirmToggleActive', `Are you sure you want to ${action} ${editingStaff.name}?`))) {
if (confirm(t('staff.confirmToggleActive', { action, name: editingStaff.name }))) {
try {
await toggleActiveMutation.mutateAsync(editingStaff.id);
closeEditModal();
} catch (err: any) {
setEditError(err.response?.data?.error || t('staff.toggleFailed', `Failed to ${action} staff member`));
setEditError(err.response?.data?.error || t('staff.toggleFailed', { action }));
}
}
};
@@ -257,7 +257,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800 p-4">
<h3 className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-3 flex items-center gap-2">
<Clock size={16} />
{t('staff.pendingInvitations', 'Pending Invitations')} ({invitations.length})
{t('staff.pendingInvitations')} ({invitations.length})
</h3>
<div className="space-y-2">
{invitations.map((invitation) => (
@@ -272,7 +272,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<div>
<div className="font-medium text-gray-900 dark:text-white text-sm">{invitation.email}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{invitation.role_display} &bull; {t('staff.expires', 'Expires')}{' '}
{invitation.role_display} &bull; {t('staff.expires')}{' '}
{new Date(invitation.expires_at).toLocaleDateString()}
</div>
</div>
@@ -282,7 +282,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
onClick={() => handleResendInvitation(invitation)}
disabled={resendInvitationMutation.isPending}
className="p-1.5 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('staff.resendInvitation', 'Resend invitation')}
title={t('staff.resendInvitation')}
>
<RefreshCw size={16} className={resendInvitationMutation.isPending ? 'animate-spin' : ''} />
</button>
@@ -290,7 +290,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
onClick={() => handleCancelInvitation(invitation)}
disabled={cancelInvitationMutation.isPending}
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('staff.cancelInvitation', 'Cancel invitation')}
title={t('staff.cancelInvitation')}
>
<Trash2 size={16} />
</button>
@@ -378,7 +378,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<button
onClick={() => openEditModal(user)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('common.edit', 'Edit')}
title={t('common.edit')}
>
<Pencil size={16} />
</button>
@@ -392,9 +392,9 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{activeStaff.length === 0 && (
<div className="p-12 text-center">
<UserIcon size={40} className="mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p className="text-gray-500 dark:text-gray-400">{t('staff.noStaffFound', 'No staff members found')}</p>
<p className="text-gray-500 dark:text-gray-400">{t('staff.noStaffFound')}</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
{t('staff.inviteFirstStaff', 'Invite your first team member to get started')}
{t('staff.inviteFirstStaff')}
</p>
</div>
)}
@@ -412,7 +412,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{showInactiveStaff ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
<UserX size={18} />
<span className="font-medium">
{t('staff.inactiveStaff', 'Inactive Staff')} ({inactiveStaff.length})
{t('staff.inactiveStaff')} ({inactiveStaff.length})
</span>
</div>
</button>
@@ -462,7 +462,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
>
<Power size={14} />
{t('staff.reactivate', 'Reactivate')}
{t('staff.reactivate')}
</button>
</td>
</tr>
@@ -493,22 +493,19 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<form onSubmit={handleInviteSubmit} className="p-6 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t(
'staff.inviteDescription',
"Enter the email address of the person you'd like to invite. They'll receive an email with instructions to join your team."
)}
{t('staff.inviteDescription')}
</p>
{/* Email Input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.emailAddress', 'Email Address')} *
{t('staff.emailAddress')} *
</label>
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder={t('staff.emailPlaceholder', 'colleague@example.com')}
placeholder={t('staff.emailPlaceholder')}
required
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
@@ -517,22 +514,22 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{/* Role Selector */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('staff.roleLabel', 'Role')} *
{t('staff.roleLabel')} *
</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'TENANT_MANAGER' | 'TENANT_STAFF')}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="TENANT_STAFF">{t('staff.roleStaff', 'Staff Member')}</option>
<option value="TENANT_STAFF">{t('staff.roleStaff')}</option>
{canInviteManagers && (
<option value="TENANT_MANAGER">{t('staff.roleManager', 'Manager')}</option>
<option value="TENANT_MANAGER">{t('staff.roleManager')}</option>
)}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{inviteRole === 'TENANT_MANAGER'
? t('staff.managerRoleHint', 'Managers can manage staff, resources, and view reports')
: t('staff.staffRoleHint', 'Staff members can manage their own schedule and appointments')}
? t('staff.managerRoleHint')
: t('staff.staffRoleHint')}
</p>
</div>
@@ -566,10 +563,10 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
/>
<div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('staff.makeBookable', 'Make Bookable')}
{t('staff.makeBookable')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('staff.makeBookableHint', 'Create a bookable resource so customers can schedule appointments with this person')}
{t('staff.makeBookableHint')}
</p>
</div>
</label>
@@ -578,13 +575,13 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{createBookableResource && (
<div className="mt-3">
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
{t('staff.resourceName', 'Display Name (optional)')}
{t('staff.resourceName')}
</label>
<input
type="text"
value={resourceName}
onChange={(e) => setResourceName(e.target.value)}
placeholder={t('staff.resourceNamePlaceholder', "Defaults to person's name")}
placeholder={t('staff.resourceNamePlaceholder')}
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
@@ -624,7 +621,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
) : (
<Send size={16} />
)}
{t('staff.sendInvitation', 'Send Invitation')}
{t('staff.sendInvitation')}
</button>
</div>
</form>
@@ -640,7 +637,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('staff.editStaff', 'Edit Staff Member')}
{t('staff.editStaff')}
</h3>
<button
onClick={closeEditModal}
@@ -698,7 +695,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{editingStaff.role === 'owner' && (
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<p className="text-sm text-purple-700 dark:text-purple-300">
{t('staff.ownerFullAccess', 'Owners have full access to all features and settings.')}
{t('staff.ownerFullAccess')}
</p>
</div>
)}
@@ -721,20 +718,20 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{editingStaff.role !== 'owner' && effectiveUser.id !== editingStaff.id && effectiveUser.role === 'owner' && (
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
{t('staff.dangerZone', 'Danger Zone')}
{t('staff.dangerZone')}
</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{editingStaff.is_active
? t('staff.deactivateAccount', 'Deactivate Account')
: t('staff.reactivateAccount', 'Reactivate Account')}
? t('staff.deactivateAccount')
: t('staff.reactivateAccount')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{editingStaff.is_active
? t('staff.deactivateHint', 'Prevent this user from logging in while keeping their data')
: t('staff.reactivateHint', 'Allow this user to log in again')}
? t('staff.deactivateHint')
: t('staff.reactivateHint')}
</p>
</div>
<button
@@ -752,8 +749,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
<Power size={14} />
)}
{editingStaff.is_active
? t('staff.deactivate', 'Deactivate')
: t('staff.reactivate', 'Reactivate')}
? t('staff.deactivate')
: t('staff.reactivate')}
</button>
</div>
</div>
@@ -778,7 +775,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{updateStaffMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : null}
{t('common.save', 'Save Changes')}
{t('common.save')}
</button>
)}
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Clock, ArrowRight, Check, X, CreditCard, TrendingDown, AlertTriangle } from 'lucide-react';
import { User, Business } from '../types';
@@ -8,6 +9,7 @@ import { User, Business } from '../types';
* Shown when a business trial has expired and they need to either upgrade or downgrade to free tier
*/
const TrialExpired: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { user, business } = useOutletContext<{ user: User; business: Business }>();
const isOwner = user.role === 'owner';
@@ -17,34 +19,34 @@ const TrialExpired: React.FC = () => {
switch (tier) {
case 'Professional':
return [
{ name: 'Unlimited appointments', included: true },
{ name: 'Online booking portal', included: true },
{ name: 'Email notifications', included: true },
{ name: 'SMS reminders', included: true },
{ name: 'Custom branding', included: true },
{ name: 'Advanced analytics', included: true },
{ name: 'Payment processing', included: true },
{ name: 'Priority support', included: true },
{ name: t('trialExpired.features.professional.unlimitedAppointments'), included: true },
{ name: t('trialExpired.features.professional.onlineBooking'), included: true },
{ name: t('trialExpired.features.professional.emailNotifications'), included: true },
{ name: t('trialExpired.features.professional.smsReminders'), included: true },
{ name: t('trialExpired.features.professional.customBranding'), included: true },
{ name: t('trialExpired.features.professional.advancedAnalytics'), included: true },
{ name: t('trialExpired.features.professional.paymentProcessing'), included: true },
{ name: t('trialExpired.features.professional.prioritySupport'), included: true },
];
case 'Business':
return [
{ name: 'Everything in Professional', included: true },
{ name: 'Multiple locations', included: true },
{ name: 'Team management', included: true },
{ name: 'API access', included: true },
{ name: 'Custom domain', included: true },
{ name: 'White-label options', included: true },
{ name: 'Dedicated account manager', included: true },
{ name: t('trialExpired.features.business.everythingInProfessional'), included: true },
{ name: t('trialExpired.features.business.multipleLocations'), included: true },
{ name: t('trialExpired.features.business.teamManagement'), included: true },
{ name: t('trialExpired.features.business.apiAccess'), included: true },
{ name: t('trialExpired.features.business.customDomain'), included: true },
{ name: t('trialExpired.features.business.whiteLabel'), included: true },
{ name: t('trialExpired.features.business.accountManager'), included: true },
];
case 'Enterprise':
return [
{ name: 'Everything in Business', included: true },
{ name: 'Unlimited users', included: true },
{ name: 'Custom integrations', included: true },
{ name: 'SLA guarantee', included: true },
{ name: 'Custom contract terms', included: true },
{ name: '24/7 phone support', included: true },
{ name: 'On-premise deployment option', included: true },
{ name: t('trialExpired.features.enterprise.everythingInBusiness'), included: true },
{ name: t('trialExpired.features.enterprise.unlimitedUsers'), included: true },
{ name: t('trialExpired.features.enterprise.customIntegrations'), included: true },
{ name: t('trialExpired.features.enterprise.slaGuarantee'), included: true },
{ name: t('trialExpired.features.enterprise.customContracts'), included: true },
{ name: t('trialExpired.features.enterprise.phoneSupport'), included: true },
{ name: t('trialExpired.features.enterprise.onPremise'), included: true },
];
default:
return [];
@@ -52,14 +54,14 @@ const TrialExpired: React.FC = () => {
};
const freeTierFeatures = [
{ name: 'Up to 50 appointments/month', included: true },
{ name: 'Basic online booking', included: true },
{ name: 'Email notifications', included: true },
{ name: 'SMS reminders', included: false },
{ name: 'Custom branding', included: false },
{ name: 'Advanced analytics', included: false },
{ name: 'Payment processing', included: false },
{ name: 'Priority support', included: false },
{ name: t('trialExpired.features.free.upTo50Appointments'), included: true },
{ name: t('trialExpired.features.free.basicOnlineBooking'), included: true },
{ name: t('trialExpired.features.free.emailNotifications'), included: true },
{ name: t('trialExpired.features.free.smsReminders'), included: false },
{ name: t('trialExpired.features.free.customBranding'), included: false },
{ name: t('trialExpired.features.free.advancedAnalytics'), included: false },
{ name: t('trialExpired.features.free.paymentProcessing'), included: false },
{ name: t('trialExpired.features.free.prioritySupport'), included: false },
];
const paidTierFeatures = getTierFeatures(business.plan);
@@ -69,7 +71,7 @@ const TrialExpired: React.FC = () => {
};
const handleDowngrade = () => {
if (window.confirm('Are you sure you want to downgrade to the Free plan? You will lose access to premium features immediately.')) {
if (window.confirm(t('trialExpired.confirmDowngrade'))) {
// TODO: Implement downgrade to free tier API call
console.log('Downgrading to free tier...');
}
@@ -87,10 +89,12 @@ const TrialExpired: React.FC = () => {
<Clock size={48} />
</div>
</div>
<h1 className="text-3xl font-bold mb-2">Your 14-Day Trial Has Expired</h1>
<h1 className="text-3xl font-bold mb-2">{t('trialExpired.title')}</h1>
<p className="text-white/90 text-lg">
Your trial of the {business.plan} plan ended on{' '}
{business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : 'N/A'}
{t('trialExpired.subtitle', {
plan: business.plan,
date: business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : 'N/A'
})}
</p>
</div>
@@ -98,10 +102,10 @@ const TrialExpired: React.FC = () => {
<div className="p-8">
<div className="mb-8">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
What happens now?
{t('trialExpired.whatHappensNow')}
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-4">
You have two options to continue using SmoothSchedule:
{t('trialExpired.twoOptions')}
</p>
</div>
@@ -110,9 +114,9 @@ const TrialExpired: React.FC = () => {
{/* Free Tier Card */}
<div className="border-2 border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Free Plan</h3>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">{t('trialExpired.freePlan')}</h3>
<span className="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full text-sm font-medium">
$0/month
{t('trialExpired.pricePerMonth')}
</span>
</div>
<ul className="space-y-3 mb-6">
@@ -135,7 +139,7 @@ const TrialExpired: React.FC = () => {
className="w-full px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium flex items-center justify-center gap-2"
>
<TrendingDown size={20} />
Downgrade to Free
{t('trialExpired.downgradeToFree')}
</button>
)}
</div>
@@ -144,14 +148,14 @@ const TrialExpired: React.FC = () => {
<div className="border-2 border-blue-500 dark:border-blue-400 rounded-xl p-6 bg-blue-50/50 dark:bg-blue-900/20 relative">
<div className="absolute top-4 right-4">
<span className="px-3 py-1 bg-blue-500 text-white rounded-full text-xs font-bold uppercase tracking-wide">
Recommended
{t('trialExpired.recommended')}
</span>
</div>
<div className="mb-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-1">
{business.plan} Plan
{business.plan} {t('common.plan', { defaultValue: 'Plan' })}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Continue where you left off</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{t('trialExpired.continueWhereYouLeftOff')}</p>
</div>
<ul className="space-y-3 mb-6">
{paidTierFeatures.slice(0, 8).map((feature, idx) => (
@@ -162,7 +166,7 @@ const TrialExpired: React.FC = () => {
))}
{paidTierFeatures.length > 8 && (
<li className="text-sm text-gray-500 dark:text-gray-400 italic">
+ {paidTierFeatures.length - 8} more features
{t('trialExpired.moreFeatures', { count: paidTierFeatures.length - 8 })}
</li>
)}
</ul>
@@ -172,7 +176,7 @@ const TrialExpired: React.FC = () => {
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg hover:from-blue-700 hover:to-blue-600 transition-all font-medium flex items-center justify-center gap-2 shadow-lg shadow-blue-500/30"
>
<CreditCard size={20} />
Upgrade Now
{t('trialExpired.upgradeNow')}
<ArrowRight size={20} />
</button>
)}
@@ -188,8 +192,8 @@ const TrialExpired: React.FC = () => {
<div className="flex-1">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium">
{isOwner
? 'Your account has limited functionality until you choose an option.'
: 'Please contact your business owner to upgrade or downgrade the account.'}
? t('trialExpired.ownerLimitedFunctionality')
: t('trialExpired.nonOwnerContactOwner')}
</p>
</div>
</div>
@@ -198,7 +202,7 @@ const TrialExpired: React.FC = () => {
{!isOwner && (
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400">
Business Owner: <span className="font-semibold text-gray-900 dark:text-white">{business.name}</span>
{t('trialExpired.businessOwner')} <span className="font-semibold text-gray-900 dark:text-white">{business.name}</span>
</p>
</div>
)}
@@ -208,9 +212,9 @@ const TrialExpired: React.FC = () => {
{/* Footer */}
<div className="text-center mt-6">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Questions? Contact our support team at{' '}
<a href="mailto:support@smoothschedule.com" className="text-blue-600 dark:text-blue-400 hover:underline">
support@smoothschedule.com
{t('trialExpired.supportQuestion')}{' '}
<a href={`mailto:${t('trialExpired.supportEmail')}`} className="text-blue-600 dark:text-blue-400 hover:underline">
{t('trialExpired.supportEmail')}
</a>
</p>
</div>

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Eye, ShieldCheck, Ban, Pencil, Send, ChevronDown, ChevronRight, Building2, Check } from 'lucide-react';
import { useBusinesses, useUpdateBusiness } from '../../hooks/usePlatform';
import { Eye, ShieldCheck, Ban, Pencil, Send, ChevronDown, ChevronRight, Building2, Check, Trash2 } from 'lucide-react';
import { useBusinesses, useUpdateBusiness, useDeleteBusiness } from '../../hooks/usePlatform';
import { PlatformBusiness, verifyUserEmail } from '../../api/platform';
import TenantInviteModal from './components/TenantInviteModal';
import { getBaseDomain } from '../../utils/domain';
@@ -28,6 +28,10 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
const [showInactiveBusinesses, setShowInactiveBusinesses] = useState(false);
const [verifyEmailUser, setVerifyEmailUser] = useState<{ id: number; email: string; name: string } | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [deletingBusiness, setDeletingBusiness] = useState<PlatformBusiness | null>(null);
// Mutations
const deleteBusinessMutation = useDeleteBusiness();
// Filter and separate businesses
const filteredBusinesses = (businesses || []).filter(b =>
@@ -69,6 +73,17 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
}
};
const handleDeleteConfirm = async () => {
if (!deletingBusiness) return;
try {
await deleteBusinessMutation.mutateAsync(deletingBusiness.id);
setDeletingBusiness(null);
} catch (error) {
alert(t('errors.generic'));
}
};
// Helper to render business row
const renderBusinessRow = (business: PlatformBusiness) => (
<PlatformListRow
@@ -98,10 +113,10 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
<button
onClick={() => handleLoginAs(business)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
title={`Masquerade as ${business.owner.email}`}
title={t('platform.masqueradeAs') + ' ' + business.owner.email}
>
<Eye size={14} />
Masquerade
{t('common.masquerade')}
</button>
)}
<button
@@ -111,6 +126,13 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
>
<Pencil size={14} /> {t('common.edit')}
</button>
<button
onClick={() => setDeletingBusiness(business)}
className="text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
title={t('common.delete')}
>
<Trash2 size={14} /> {t('common.delete')}
</button>
</>
}
/>
@@ -158,7 +180,7 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium shadow-sm"
>
<Send size={18} />
Invite Tenant
{t('platform.inviteTenant')}
</button>
}
emptyMessage={searchTerm ? t('platform.noBusinessesFound') : t('platform.noBusinesses')}
@@ -173,7 +195,7 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
{showInactiveBusinesses ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
<Building2 size={18} />
<span className="font-medium">
Inactive Businesses ({inactiveBusinesses.length})
{t('platform.inactiveBusinesses', { count: inactiveBusinesses.length })}
</span>
</div>
</button>
@@ -233,6 +255,33 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
variant="success"
isLoading={isVerifying}
/>
<ConfirmationModal
isOpen={!!deletingBusiness}
onClose={() => setDeletingBusiness(null)}
onConfirm={handleDeleteConfirm}
title={t('platform.deleteTenant')}
message={
<div className="space-y-3">
<p>{t('platform.confirmDeleteTenantMessage')}</p>
{deletingBusiness && (
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3 border border-red-200 dark:border-red-800">
<p className="font-medium text-gray-900 dark:text-white">{deletingBusiness.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{deletingBusiness.subdomain}.smoothschedule.com</p>
{deletingBusiness.owner && (
<p className="text-sm text-gray-500 dark:text-gray-400">{deletingBusiness.owner.email}</p>
)}
</div>
)}
<p className="text-sm text-red-600 dark:text-red-400 font-medium">
{t('platform.deleteTenantWarning')}
</p>
</div>
}
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
variant="danger"
isLoading={deleteBusinessMutation.isPending}
/>
</div>
);
};

View File

@@ -227,11 +227,11 @@ const GeneralSettingsTab: React.FC = () => {
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Mail Server</p>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('platform.settings.mailServer')}</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">mail.talova.net</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Email Domain</p>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('platform.settings.emailDomain')}</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">smoothschedule.com</p>
</div>
</div>
@@ -282,7 +282,7 @@ const StripeSettingsTab: React.FC = () => {
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertCircle className="w-5 h-5" />
<span>Failed to load settings</span>
<span>{t('platform.settings.failedToLoadSettings')}</span>
</div>
</div>
);
@@ -294,7 +294,7 @@ const StripeSettingsTab: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5" />
Stripe Configuration Status
{t('platform.settings.stripeConfigStatus')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -319,7 +319,7 @@ const StripeSettingsTab: React.FC = () => {
<AlertCircle className="w-5 h-5 text-yellow-500" />
)}
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Validation</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('platform.settings.validation')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{settings?.stripe_keys_validated_at
? `Validated ${new Date(settings.stripe_keys_validated_at).toLocaleDateString()}`
@@ -332,7 +332,7 @@ const StripeSettingsTab: React.FC = () => {
{settings?.stripe_account_id && (
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-400">
<span className="font-medium">Account ID:</span> {settings.stripe_account_id}
<span className="font-medium">{t('platform.settings.accountId')}:</span> {settings.stripe_account_id}
{settings.stripe_account_name && (
<span className="ml-2">({settings.stripe_account_name})</span>
)}
@@ -357,19 +357,19 @@ const StripeSettingsTab: React.FC = () => {
<div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">Secret Key</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{t('platform.settings.secretKey')}</span>
<code className="text-sm font-mono text-gray-900 dark:text-white">
{settings?.stripe_secret_key_masked || 'Not configured'}
</code>
</div>
<div className="flex justify-between items-center py-2 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">Publishable Key</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{t('platform.settings.publishableKey')}</span>
<code className="text-sm font-mono text-gray-900 dark:text-white">
{settings?.stripe_publishable_key_masked || 'Not configured'}
</code>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Webhook Secret</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{t('platform.settings.webhookSecret')}</span>
<code className="text-sm font-mono text-gray-900 dark:text-white">
{settings?.stripe_webhook_secret_masked || 'Not configured'}
</code>
@@ -637,7 +637,7 @@ const TiersSettingsTab: React.FC = () => {
{/* Base Plans */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-medium text-gray-900 dark:text-white">Base Tiers</h3>
<h3 className="font-medium text-gray-900 dark:text-white">{t('platform.settings.baseTiers')}</h3>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{basePlans.length === 0 ? (
@@ -660,7 +660,7 @@ const TiersSettingsTab: React.FC = () => {
{/* Add-on Plans */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-medium text-gray-900 dark:text-white">Add-ons</h3>
<h3 className="font-medium text-gray-900 dark:text-white">{t('platform.settings.addOns')}</h3>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{addonPlans.length === 0 ? (

View File

@@ -34,7 +34,7 @@ const BookingSettings: React.FC = () => {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
} catch (error) {
alert('Failed to save return URL');
alert(t('settings.booking.failedToSaveReturnUrl', 'Failed to save return URL'));
} finally {
setReturnUrlSaving(false);
}
@@ -44,7 +44,7 @@ const BookingSettings: React.FC = () => {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
Only the business owner can access these settings.
{t('settings.booking.onlyOwnerCanAccess', 'Only the business owner can access these settings.')}
</p>
</div>
);
@@ -59,17 +59,17 @@ const BookingSettings: React.FC = () => {
{t('settings.booking.title', 'Booking')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Configure your booking page URL and customer redirect settings.
{t('settings.booking.description', 'Configure your booking page URL and customer redirect settings')}
</p>
</div>
{/* Booking URL */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Link2 size={20} className="text-brand-500" /> Your Booking URL
<Link2 size={20} className="text-brand-500" /> {t('settings.booking.yourBookingUrl', 'Your Booking URL')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Share this URL with your customers so they can book appointments with you.
{t('settings.booking.shareWithCustomers', 'Share this URL with your customers so they can book appointments with you.')}
</p>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<code className="flex-1 text-sm font-mono text-gray-900 dark:text-white">
@@ -82,7 +82,7 @@ const BookingSettings: React.FC = () => {
setTimeout(() => setShowToast(false), 2000);
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title="Copy to clipboard"
title={t('settings.booking.copyToClipboard', 'Copy to clipboard')}
>
<Copy size={16} />
</button>
@@ -91,30 +91,30 @@ const BookingSettings: React.FC = () => {
target="_blank"
rel="noopener noreferrer"
className="p-2 text-brand-500 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title="Open booking page"
title={t('settings.booking.openBookingPage', 'Open booking page')}
>
<ExternalLink size={16} />
</a>
</div>
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Want to use your own domain? Set up a <a href="/settings/custom-domains" className="text-brand-500 hover:underline">custom domain</a>.
{t('settings.booking.customDomainPrompt', 'Want to use your own domain? Set up a')} <a href="/settings/custom-domains" className="text-brand-500 hover:underline">{t('settings.booking.customDomain', 'custom domain')}</a>.
</p>
</section>
{/* Return URL - Where to redirect customers after booking */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
<ExternalLink size={20} className="text-green-500" /> Return URL
<ExternalLink size={20} className="text-green-500" /> {t('settings.booking.returnUrl', 'Return URL')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
After a customer completes a booking, redirect them to this URL (e.g., a thank you page on your website).
{t('settings.booking.returnUrlDescription', 'After a customer completes a booking, redirect them to this URL (e.g., a thank you page on your website).')}
</p>
<div className="flex gap-2">
<input
type="url"
value={returnUrl}
onChange={(e) => setReturnUrl(e.target.value)}
placeholder="https://yourbusiness.com/thank-you"
placeholder={t('settings.booking.returnUrlPlaceholder', 'https://yourbusiness.com/thank-you')}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500 text-sm"
/>
<button
@@ -123,11 +123,11 @@ const BookingSettings: React.FC = () => {
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium text-sm"
>
<Save size={16} />
{returnUrlSaving ? 'Saving...' : 'Save'}
{returnUrlSaving ? t('settings.booking.saving', 'Saving...') : t('settings.booking.save', 'Save')}
</button>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Leave empty to keep customers on the booking confirmation page.
{t('settings.booking.leaveEmpty', 'Leave empty to keep customers on the booking confirmation page.')}
</p>
</section>
@@ -135,7 +135,7 @@ const BookingSettings: React.FC = () => {
{showToast && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
<CheckCircle size={18} />
Copied to clipboard
{t('settings.booking.copiedToClipboard', 'Copied to clipboard')}
</div>
)}
</div>