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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} • {t('staff.expires', 'Expires')}{' '}
|
||||
{invitation.role_display} • {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user