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>
137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { GripVertical, X, Users, UserPlus, UserCheck } from 'lucide-react';
|
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
|
import { Customer } from '../../types';
|
|
|
|
interface CustomerBreakdownWidgetProps {
|
|
customers: Customer[];
|
|
isEditing?: boolean;
|
|
onRemove?: () => void;
|
|
}
|
|
|
|
const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
|
|
customers,
|
|
isEditing,
|
|
onRemove,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const breakdownData = useMemo(() => {
|
|
// Customers with lastVisit are returning, without are new
|
|
const returning = customers.filter((c) => c.lastVisit !== null).length;
|
|
const newCustomers = customers.filter((c) => c.lastVisit === null).length;
|
|
const total = customers.length;
|
|
|
|
return {
|
|
new: newCustomers,
|
|
returning,
|
|
total,
|
|
newPercentage: total > 0 ? Math.round((newCustomers / total) * 100) : 0,
|
|
returningPercentage: total > 0 ? Math.round((returning / total) * 100) : 0,
|
|
chartData: [
|
|
{ name: 'New', value: newCustomers, color: '#8b5cf6' },
|
|
{ name: 'Returning', value: returning, color: '#10b981' },
|
|
],
|
|
};
|
|
}, [customers]);
|
|
|
|
return (
|
|
<div className="h-full p-3 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
|
|
{isEditing && (
|
|
<>
|
|
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
|
<GripVertical size={16} />
|
|
</div>
|
|
<button
|
|
onClick={onRemove}
|
|
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<h3 className={`text-base font-semibold text-gray-900 dark:text-white mb-2 ${isEditing ? 'pl-5' : ''}`}>
|
|
Customers This Month
|
|
</h3>
|
|
|
|
<div className="flex-1 flex items-center gap-3 min-h-0">
|
|
{/* Pie Chart */}
|
|
<div className="w-20 h-20 flex-shrink-0">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={breakdownData.chartData}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={20}
|
|
outerRadius={35}
|
|
paddingAngle={2}
|
|
dataKey="value"
|
|
>
|
|
{breakdownData.chartData.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{
|
|
borderRadius: '8px',
|
|
border: 'none',
|
|
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
|
backgroundColor: '#1F2937',
|
|
color: '#F3F4F6',
|
|
}}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
|
<UserPlus size={12} className="text-purple-600 dark:text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">New</p>
|
|
<p className="text-base font-semibold text-gray-900 dark:text-white">
|
|
{breakdownData.new}{' '}
|
|
<span className="text-xs font-normal text-gray-400">
|
|
({breakdownData.newPercentage}%)
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1 rounded-lg bg-green-100 dark:bg-green-900/30">
|
|
<UserCheck size={12} className="text-green-600 dark:text-green-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">Returning</p>
|
|
<p className="text-base font-semibold text-gray-900 dark:text-white">
|
|
{breakdownData.returning}{' '}
|
|
<span className="text-xs font-normal text-gray-400">
|
|
({breakdownData.returningPercentage}%)
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-gray-700 flex-shrink-0">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
|
<Users size={12} />
|
|
<span>{t('dashboard.totalCustomers')}</span>
|
|
</div>
|
|
<span className="font-semibold text-gray-900 dark:text-white">{breakdownData.total}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CustomerBreakdownWidget;
|