revert try to do better
This commit is contained in:
2026-01-29 12:43:22 +00:00
parent 75fa8bd4e2
commit c7b9a14ff6
60 changed files with 1255 additions and 5336 deletions

View File

@@ -10,7 +10,6 @@ import Profile from './pages/Profile';
import { useStore } from './store/useStore';
import { authApi } from './api/client';
import { Loader2 } from 'lucide-react';
import { ErrorBoundary } from './components/ErrorBoundary';
function AppContent() {
const { t, i18n } = useTranslation();
@@ -90,11 +89,9 @@ function AppContent() {
function App() {
return (
<ErrorBoundary>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</ErrorBoundary>
<BrowserRouter>
<AppContent />
</BrowserRouter>
);
}

View File

@@ -1,5 +1,4 @@
import axios, { AxiosError } from 'axios';
import axiosRetry from 'axios-retry';
import axios from 'axios';
import type {
Family,
Category,
@@ -34,51 +33,8 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
const apiClient = axios.create({
baseURL: API_BASE_URL,
withCredentials: true,
timeout: 30000,
});
axiosRetry(apiClient, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error: AxiosError) => {
return (
axiosRetry.isNetworkOrIdempotentRequestError(error) ||
(error.response?.status ? error.response.status >= 500 : false)
);
},
onRetry: (retryCount, error, requestConfig) => {
console.log(`Retry attempt ${retryCount} for ${requestConfig.url}`, error.message);
},
});
apiClient.interceptors.request.use(
(config) => {
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
console.error('[API] Request error:', error);
return Promise.reject(error);
}
);
apiClient.interceptors.response.use(
(response) => {
console.log(`[API] ${response.config.method?.toUpperCase()} ${response.config.url} - ${response.status}`);
return response;
},
(error: AxiosError) => {
if (error.response?.status === 401) {
console.warn('[API] Unauthorized - redirecting to login');
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
console.error('[API] Response error:', error.response?.status, error.message);
return Promise.reject(error);
}
);
export const authApi = {
login: (data: LoginRequest) =>
apiClient.post<LoginResponse>('/login', data),

View File

@@ -1,82 +0,0 @@
import React, { Component, ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Button } from './ui';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
window.location.href = '/';
};
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center gradient-bg p-4">
<div className="max-w-lg w-full glass-effect rounded-2xl shadow-2xl p-8 text-center">
<div className="inline-flex p-4 bg-red-100 dark:bg-red-900/20 rounded-full mb-6">
<AlertTriangle className="w-12 h-12 text-red-600 dark:text-red-400" />
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Oops! Something went wrong
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
We're sorry, but something unexpected happened. Please try refreshing the page or going back to the home page.
</p>
{process.env.NODE_ENV === 'development' && this.state.error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/10 rounded-lg text-left">
<p className="text-sm font-mono text-red-800 dark:text-red-300 break-all">
{this.state.error.toString()}
</p>
</div>
)}
<div className="flex gap-3">
<Button variant="primary" fullWidth onClick={() => window.location.reload()}>
Reload Page
</Button>
<Button variant="secondary" fullWidth onClick={this.handleReset}>
Go to Home
</Button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,375 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { shoppingItemApi } from '../api/client';
import type { ShoppingItem } from '../types';
import {
X,
Plus,
Trash2,
ShoppingCart,
Check,
Loader2,
Pencil,
} from 'lucide-react';
import ConfirmModal from './ConfirmModal';
interface ShoppingListModalProps {
familyId: number;
onClose: () => void;
}
type ConfirmAction =
| { type: 'delete-item'; itemId: number }
| { type: 'mark-all' }
| { type: 'clear-all' };
export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) {
const { t } = useTranslation();
const [items, setItems] = useState<ShoppingItem[]>([]);
const [loading, setLoading] = useState(true);
const [newItemName, setNewItemName] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
useEffect(() => {
loadItems();
}, [familyId]);
const loadItems = async () => {
try {
setLoading(true);
const response = await shoppingItemApi.getAllByFamily(familyId);
setItems(response.data);
} catch (err) {
console.error('Error loading shopping items:', err);
alert(t('shopping.loadError'));
} finally {
setLoading(false);
}
};
const handleAddItem = async () => {
if (!newItemName.trim()) return;
try {
await shoppingItemApi.create(familyId, { name: newItemName });
setNewItemName('');
loadItems();
} catch (err) {
console.error('Error adding item:', err);
alert(t('shopping.addError'));
}
};
const handleTogglePurchased = async (itemId: number, currentStatus: boolean) => {
try {
await shoppingItemApi.markAsPurchased(familyId, itemId, { is_purchased: !currentStatus });
loadItems();
} catch (err) {
console.error('Error toggling purchased status:', err);
alert(t('shopping.toggleError'));
}
};
const handleDeleteItem = (itemId: number) => {
setConfirmAction({ type: 'delete-item', itemId });
};
const executeDeleteItem = async (itemId: number) => {
try {
await shoppingItemApi.delete(familyId, itemId);
loadItems();
} catch (err) {
console.error('Error deleting item:', err);
alert(t('shopping.deleteError'));
}
};
const handleStartEdit = (item: ShoppingItem) => {
setEditingId(item.id);
setEditingName(item.name);
};
const handleSaveEdit = async (itemId: number) => {
if (!editingName.trim()) return;
try {
await shoppingItemApi.update(familyId, itemId, { name: editingName });
setEditingId(null);
setEditingName('');
loadItems();
} catch (err) {
console.error('Error updating item:', err);
alert(t('shopping.updateError'));
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditingName('');
};
const handleMarkAllPurchased = () => {
setConfirmAction({ type: 'mark-all' });
};
const executeMarkAllPurchased = async () => {
try {
await shoppingItemApi.markAllAsPurchased(familyId);
loadItems();
} catch (err) {
console.error('Error marking all as purchased:', err);
alert(t('shopping.markAllError'));
}
};
const handleClearAll = () => {
setConfirmAction({ type: 'clear-all' });
};
const executeClearAll = async () => {
try {
await shoppingItemApi.clearAll(familyId);
loadItems();
} catch (err) {
console.error('Error clearing all items:', err);
alert(t('shopping.clearError'));
}
};
const handleConfirm = () => {
if (!confirmAction) return;
switch (confirmAction.type) {
case 'delete-item':
executeDeleteItem(confirmAction.itemId);
break;
case 'mark-all':
executeMarkAllPurchased();
break;
case 'clear-all':
executeClearAll();
break;
}
setConfirmAction(null);
};
const getConfirmModalContent = () => {
if (!confirmAction) return null;
switch (confirmAction.type) {
case 'delete-item':
return {
title: t('confirm.deleteItem'),
message: t('confirm.deleteItemMessage'),
confirmText: t('common.delete'),
};
case 'mark-all':
return {
title: t('confirm.markAll'),
message: t('confirm.markAllMessage'),
confirmText: t('confirm.markButton'),
variant: 'info' as const,
};
case 'clear-all':
return {
title: t('confirm.clearAll'),
message: t('confirm.clearAllMessage'),
confirmText: t('shopping.clear'),
};
}
};
const unpurchasedItems = items.filter(item => !item.is_purchased);
const purchasedItems = items.filter(item => item.is_purchased);
const confirmContent = getConfirmModalContent();
return (
<>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass-effect rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="btn-success p-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
<ShoppingCart className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-white">{t('shopping.title')}</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-xl transition-all"
>
<X className="w-6 h-6 text-white" />
</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
<div className="mb-6">
<div className="flex gap-2">
<input
type="text"
placeholder={t('shopping.addPlaceholder')}
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddItem()}
className="flex-1 px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all"
/>
<button
onClick={handleAddItem}
className="px-6 py-3 btn-success text-white rounded-2xl hover:shadow-lg transition-all font-semibold flex items-center gap-2"
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
</div>
) : (
<div className="space-y-6">
{unpurchasedItems.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-3">{t('shopping.toBuy')}</h3>
<div className="space-y-2">
{unpurchasedItems.map((item) => (
<div
key={item.id}
className="bg-gray-50 p-4 rounded-2xl border-2 border-gray-200 hover:border-green-300 transition-all"
>
{editingId === item.id ? (
<div className="flex gap-2">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit(item.id)}
className="flex-1 px-3 py-2 border-2 border-green-300 rounded-xl focus:border-green-500 focus:ring-2 focus:ring-green-200"
autoFocus
/>
<button
onClick={() => handleSaveEdit(item.id)}
className="px-4 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all"
>
<Check className="w-5 h-5" />
</button>
<button
onClick={handleCancelEdit}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-xl hover:bg-gray-400 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
className="w-6 h-6 border-2 border-gray-400 rounded-lg hover:border-green-500 transition-all"
/>
<span className="text-gray-800 font-medium">{item.name}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => handleStartEdit(item)}
className="p-2 hover:bg-gray-200 rounded-xl transition-all"
>
<Pencil className="w-4 h-4 text-gray-600" />
</button>
<button
onClick={() => handleDeleteItem(item.id)}
className="p-2 hover:bg-red-100 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{purchasedItems.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-3">{t('shopping.purchased')}</h3>
<div className="space-y-2">
{purchasedItems.map((item) => (
<div
key={item.id}
className="shopping-purchased p-4 rounded-2xl border-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
className="w-6 h-6 bg-green-500 border-2 border-green-500 rounded-lg flex items-center justify-center hover:bg-green-600 transition-all"
>
<Check className="w-4 h-4 text-white" />
</button>
<span className="text-gray-500 line-through">{item.name}</span>
</div>
<button
onClick={() => handleDeleteItem(item.id)}
className="p-2 hover:bg-red-100 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{items.length === 0 && (
<div className="text-center py-12 text-gray-500">
<ShoppingCart className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">{t('shopping.empty')}</p>
</div>
)}
</div>
)}
</div>
{items.length > 0 && (
<div className="p-6 border-t-2 border-gray-200 bg-gray-50">
<div className="flex gap-3">
<button
onClick={handleMarkAllPurchased}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 btn-success text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
>
<Check className="w-5 h-5" />
{t('shopping.allPurchased')}
</button>
<button
onClick={handleClearAll}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 btn-danger text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
>
<Trash2 className="w-5 h-5" />
{t('shopping.clear')}
</button>
</div>
</div>
)}
</div>
</div>
{confirmContent && (
<ConfirmModal
title={confirmContent.title}
message={confirmContent.message}
confirmText={confirmContent.confirmText}
onConfirm={handleConfirm}
onCancel={() => setConfirmAction(null)}
variant={confirmContent.variant || 'danger'}
/>
)}
</>
);
}

View File

@@ -1,132 +1,375 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ShoppingCart, CheckCheck, Trash2 } from 'lucide-react';
import { useShoppingList, useConfirm } from '../hooks';
import { shoppingService } from '../services';
import { Modal, Button, LoadingSpinner, ConfirmModal, Badge } from './ui';
import { ShoppingItemInput } from './shopping/ShoppingItemInput';
import { ShoppingItemList } from './shopping/ShoppingItemList';
import { shoppingItemApi } from '../api/client';
import type { ShoppingItem } from '../types';
import {
X,
Plus,
Trash2,
ShoppingCart,
Check,
Loader2,
Pencil,
} from 'lucide-react';
import ConfirmModal from './ConfirmModal';
interface ShoppingListModalProps {
familyId: number;
onClose: () => void;
}
type ConfirmAction =
| { type: 'delete-item'; itemId: number }
| { type: 'mark-all' }
| { type: 'clear-all' };
export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) {
const { t } = useTranslation();
const { items, loading, createItem, deleteItem, togglePurchased, markAllAsPurchased, clearAll } = useShoppingList(familyId);
const { confirmState, confirm, cancel } = useConfirm();
const [pendingAction, setPendingAction] = useState<{ type: 'delete' | 'markAll' | 'clearAll'; itemId?: number } | null>(null);
const [items, setItems] = useState<ShoppingItem[]>([]);
const [loading, setLoading] = useState(true);
const [newItemName, setNewItemName] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const stats = shoppingService.getStats(items);
useEffect(() => {
loadItems();
}, [familyId]);
const handleAddItem = async (name: string) => {
await createItem({ name });
};
const handleDelete = async (itemId: number) => {
setPendingAction({ type: 'delete', itemId });
await confirm(t('shopping.deleteConfirm'), t('shopping.deleteMessage'));
await deleteItem(itemId);
setPendingAction(null);
};
const handleMarkAll = async () => {
setPendingAction({ type: 'markAll' });
await confirm(t('shopping.markAllConfirm'), t('shopping.markAllMessage'));
await markAllAsPurchased();
setPendingAction(null);
};
const handleClearAll = async () => {
setPendingAction({ type: 'clearAll' });
await confirm(t('shopping.clearAllConfirm'), t('shopping.clearAllMessage'));
await clearAll();
setPendingAction(null);
};
const handleUpdate = async (itemId: number, name: string) => {
const item = items.find((i) => i.id === itemId);
if (item) {
await createItem({ name });
await deleteItem(itemId);
const loadItems = async () => {
try {
setLoading(true);
const response = await shoppingItemApi.getAllByFamily(familyId);
setItems(response.data);
} catch (err) {
console.error('Error loading shopping items:', err);
alert(t('shopping.loadError'));
} finally {
setLoading(false);
}
};
const handleAddItem = async () => {
if (!newItemName.trim()) return;
try {
await shoppingItemApi.create(familyId, { name: newItemName });
setNewItemName('');
loadItems();
} catch (err) {
console.error('Error adding item:', err);
alert(t('shopping.addError'));
}
};
const handleTogglePurchased = async (itemId: number, currentStatus: boolean) => {
try {
await shoppingItemApi.markAsPurchased(familyId, itemId, { is_purchased: !currentStatus });
loadItems();
} catch (err) {
console.error('Error toggling purchased status:', err);
alert(t('shopping.toggleError'));
}
};
const handleDeleteItem = (itemId: number) => {
setConfirmAction({ type: 'delete-item', itemId });
};
const executeDeleteItem = async (itemId: number) => {
try {
await shoppingItemApi.delete(familyId, itemId);
loadItems();
} catch (err) {
console.error('Error deleting item:', err);
alert(t('shopping.deleteError'));
}
};
const handleStartEdit = (item: ShoppingItem) => {
setEditingId(item.id);
setEditingName(item.name);
};
const handleSaveEdit = async (itemId: number) => {
if (!editingName.trim()) return;
try {
await shoppingItemApi.update(familyId, itemId, { name: editingName });
setEditingId(null);
setEditingName('');
loadItems();
} catch (err) {
console.error('Error updating item:', err);
alert(t('shopping.updateError'));
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditingName('');
};
const handleMarkAllPurchased = () => {
setConfirmAction({ type: 'mark-all' });
};
const executeMarkAllPurchased = async () => {
try {
await shoppingItemApi.markAllAsPurchased(familyId);
loadItems();
} catch (err) {
console.error('Error marking all as purchased:', err);
alert(t('shopping.markAllError'));
}
};
const handleClearAll = () => {
setConfirmAction({ type: 'clear-all' });
};
const executeClearAll = async () => {
try {
await shoppingItemApi.clearAll(familyId);
loadItems();
} catch (err) {
console.error('Error clearing all items:', err);
alert(t('shopping.clearError'));
}
};
const handleConfirm = () => {
if (!confirmAction) return;
switch (confirmAction.type) {
case 'delete-item':
executeDeleteItem(confirmAction.itemId);
break;
case 'mark-all':
executeMarkAllPurchased();
break;
case 'clear-all':
executeClearAll();
break;
}
setConfirmAction(null);
};
const getConfirmModalContent = () => {
if (!confirmAction) return null;
switch (confirmAction.type) {
case 'delete-item':
return {
title: t('confirm.deleteItem'),
message: t('confirm.deleteItemMessage'),
confirmText: t('common.delete'),
};
case 'mark-all':
return {
title: t('confirm.markAll'),
message: t('confirm.markAllMessage'),
confirmText: t('confirm.markButton'),
variant: 'info' as const,
};
case 'clear-all':
return {
title: t('confirm.clearAll'),
message: t('confirm.clearAllMessage'),
confirmText: t('shopping.clear'),
};
}
};
const unpurchasedItems = items.filter(item => !item.is_purchased);
const purchasedItems = items.filter(item => item.is_purchased);
const confirmContent = getConfirmModalContent();
return (
<>
<Modal isOpen={true} onClose={onClose} title={t('shopping.title')} size="lg">
{loading ? (
<div className="py-12">
<LoadingSpinner size="lg" text={t('common.loading')} />
</div>
) : (
<>
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<ShoppingCart className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{t('shopping.stats')}
</span>
</div>
<div className="flex gap-2">
<Badge variant="default">
{t('shopping.total')}: {stats.total}
</Badge>
<Badge variant="success">
{t('shopping.purchased')}: {stats.purchased}
</Badge>
<Badge variant="warning">
{t('shopping.pending')}: {stats.pending}
</Badge>
</div>
</div>
<div className="flex gap-2">
{stats.pending > 0 && (
<Button
variant="success"
size="sm"
onClick={handleMarkAll}
>
<CheckCheck className="w-4 h-4 mr-2" />
{t('shopping.markAll')}
</Button>
)}
{stats.total > 0 && (
<Button
variant="danger"
size="sm"
onClick={handleClearAll}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('shopping.clearAll')}
</Button>
)}
</div>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass-effect rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="btn-success p-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
<ShoppingCart className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-white">{t('shopping.title')}</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-xl transition-all"
>
<X className="w-6 h-6 text-white" />
</button>
</div>
<ShoppingItemInput onAdd={handleAddItem} />
<div className="p-6 flex-1 overflow-y-auto">
<div className="mb-6">
<div className="flex gap-2">
<input
type="text"
placeholder={t('shopping.addPlaceholder')}
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddItem()}
className="flex-1 px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all"
/>
<button
onClick={handleAddItem}
className="px-6 py-3 btn-success text-white rounded-2xl hover:shadow-lg transition-all font-semibold flex items-center gap-2"
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
</div>
<ShoppingItemList
items={items}
onToggle={togglePurchased}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
</>
)}
</Modal>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
</div>
) : (
<div className="space-y-6">
{unpurchasedItems.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-3">{t('shopping.toBuy')}</h3>
<div className="space-y-2">
{unpurchasedItems.map((item) => (
<div
key={item.id}
className="bg-gray-50 p-4 rounded-2xl border-2 border-gray-200 hover:border-green-300 transition-all"
>
{editingId === item.id ? (
<div className="flex gap-2">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit(item.id)}
className="flex-1 px-3 py-2 border-2 border-green-300 rounded-xl focus:border-green-500 focus:ring-2 focus:ring-green-200"
autoFocus
/>
<button
onClick={() => handleSaveEdit(item.id)}
className="px-4 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all"
>
<Check className="w-5 h-5" />
</button>
<button
onClick={handleCancelEdit}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-xl hover:bg-gray-400 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
className="w-6 h-6 border-2 border-gray-400 rounded-lg hover:border-green-500 transition-all"
/>
<span className="text-gray-800 font-medium">{item.name}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => handleStartEdit(item)}
className="p-2 hover:bg-gray-200 rounded-xl transition-all"
>
<Pencil className="w-4 h-4 text-gray-600" />
</button>
<button
onClick={() => handleDeleteItem(item.id)}
className="p-2 hover:bg-red-100 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
<ConfirmModal
isOpen={confirmState.isOpen}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={cancel}
/>
{purchasedItems.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-3">{t('shopping.purchased')}</h3>
<div className="space-y-2">
{purchasedItems.map((item) => (
<div
key={item.id}
className="shopping-purchased p-4 rounded-2xl border-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
className="w-6 h-6 bg-green-500 border-2 border-green-500 rounded-lg flex items-center justify-center hover:bg-green-600 transition-all"
>
<Check className="w-4 h-4 text-white" />
</button>
<span className="text-gray-500 line-through">{item.name}</span>
</div>
<button
onClick={() => handleDeleteItem(item.id)}
className="p-2 hover:bg-red-100 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{items.length === 0 && (
<div className="text-center py-12 text-gray-500">
<ShoppingCart className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">{t('shopping.empty')}</p>
</div>
)}
</div>
)}
</div>
{items.length > 0 && (
<div className="p-6 border-t-2 border-gray-200 bg-gray-50">
<div className="flex gap-3">
<button
onClick={handleMarkAllPurchased}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 btn-success text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
>
<Check className="w-5 h-5" />
{t('shopping.allPurchased')}
</button>
<button
onClick={handleClearAll}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 btn-danger text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
>
<Trash2 className="w-5 h-5" />
{t('shopping.clear')}
</button>
</div>
</div>
)}
</div>
</div>
{confirmContent && (
<ConfirmModal
title={confirmContent.title}
message={confirmContent.message}
confirmText={confirmContent.confirmText}
onConfirm={handleConfirm}
onCancel={() => setConfirmAction(null)}
variant={confirmContent.variant || 'danger'}
/>
)}
</>
);
}

View File

@@ -1,95 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, X } from 'lucide-react';
import { Button, Input } from '../ui';
import { CreateCategoryRequest } from '../../types';
interface AddCategorySectionProps {
showForm: boolean;
onToggle: () => void;
onCreate: (data: CreateCategoryRequest) => Promise<void>;
}
export function AddCategorySection({ showForm, onToggle, onCreate }: AddCategorySectionProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [limitAmount, setLimitAmount] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!name || !limitAmount) return;
try {
setIsSubmitting(true);
await onCreate({
name,
limit_amount: parseFloat(limitAmount),
});
setName('');
setLimitAmount('');
onToggle();
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="glass-effect rounded-2xl shadow-lg p-6">
{!showForm ? (
<button
onClick={onToggle}
className="w-full flex items-center justify-center gap-3 py-4 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-xl transition-all duration-300 group"
>
<Plus className="w-6 h-6 group-hover:scale-110 transition-transform" />
<span>{t('category.add')}</span>
</button>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-900">{t('category.add')}</h3>
<button
onClick={onToggle}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
<Input
type="text"
placeholder={t('category.name')}
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
/>
<Input
type="number"
placeholder={t('category.limit')}
value={limitAmount}
onChange={(e) => setLimitAmount(e.target.value)}
fullWidth
/>
<div className="flex gap-3">
<Button
variant="success"
fullWidth
onClick={handleSubmit}
disabled={isSubmitting || !name || !limitAmount}
>
{t('common.save')}
</Button>
<Button
variant="secondary"
fullWidth
onClick={onToggle}
disabled={isSubmitting}
>
{t('common.cancel')}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,208 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Tag, TrendingDown, Plus, Trash2, RotateCcw, History, X, DollarSign, MessageSquare, Calendar } from 'lucide-react';
import { CategoryWithRemaining } from '../../services';
import { categoryService, expenseService } from '../../services';
import { useExpenses } from '../../hooks';
import { format } from '../../utils/format';
import { Button, Input, Badge } from '../ui';
interface CategoryCardProps {
category: CategoryWithRemaining;
familyId: number;
onDelete: (categoryId: number) => void;
onReset: (categoryId: number) => void;
onUpdate: () => void;
}
export function CategoryCard({ category, familyId, onDelete, onReset, onUpdate }: CategoryCardProps) {
const { t } = useTranslation();
const [showAddExpense, setShowAddExpense] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [expenseAmount, setExpenseAmount] = useState('');
const [expenseDescription, setExpenseDescription] = useState('');
const { expenses, loadExpenses, createExpense } = useExpenses(familyId, category.id);
const handleAddExpense = async () => {
if (!expenseAmount) return;
try {
await createExpense({
amount: parseFloat(expenseAmount),
description: expenseDescription || undefined,
});
setExpenseAmount('');
setExpenseDescription('');
setShowAddExpense(false);
onUpdate();
} catch (error) {
console.error(error);
}
};
const handleShowHistory = async () => {
if (!showHistory) {
await loadExpenses();
}
setShowHistory(!showHistory);
};
const progress = categoryService.calculateProgress(category.limit_amount, category.remaining_limit);
const progressColor = categoryService.getProgressColor(progress);
return (
<div className="glass-effect rounded-2xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl">
<div className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl">
<Tag className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-gray-900">{category.name}</h3>
<p className="text-sm text-gray-600">
{t('category.limit')}: {format.currency(category.limit_amount)}
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => onDelete(category.id)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors group"
>
<Trash2 className="w-5 h-5 text-red-600 group-hover:scale-110 transition-transform" />
</button>
<button
onClick={() => onReset(category.id)}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors group"
>
<RotateCcw className="w-5 h-5 text-blue-600 group-hover:scale-110 transition-transform" />
</button>
</div>
</div>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">{t('category.remaining')}</span>
<span className="text-lg font-bold text-green-600">
{format.currency(category.remaining_limit)}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
progressColor === 'danger' ? 'bg-red-500' :
progressColor === 'warning' ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${100 - progress}%` }}
/>
</div>
</div>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
fullWidth
onClick={() => setShowAddExpense(!showAddExpense)}
>
<Plus className="w-4 h-4 mr-2" />
{t('expense.add')}
</Button>
<Button
variant="secondary"
size="sm"
fullWidth
onClick={handleShowHistory}
>
<History className="w-4 h-4 mr-2" />
{t('expense.history')}
</Button>
</div>
</div>
{showAddExpense && (
<div className="border-t border-gray-200 p-5 bg-gray-50">
<h4 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
<TrendingDown className="w-5 h-5 text-red-600" />
{t('expense.add')}
</h4>
<div className="space-y-3">
<Input
type="number"
placeholder={t('expense.amount')}
value={expenseAmount}
onChange={(e) => setExpenseAmount(e.target.value)}
fullWidth
/>
<Input
type="text"
placeholder={t('expense.description')}
value={expenseDescription}
onChange={(e) => setExpenseDescription(e.target.value)}
fullWidth
/>
<div className="flex gap-2">
<Button variant="success" fullWidth onClick={handleAddExpense}>
{t('common.save')}
</Button>
<Button variant="secondary" fullWidth onClick={() => setShowAddExpense(false)}>
{t('common.cancel')}
</Button>
</div>
</div>
</div>
)}
{showHistory && (
<div className="border-t border-gray-200 p-5 bg-gray-50 max-h-96 overflow-y-auto">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
<History className="w-5 h-5 text-blue-600" />
{t('expense.history')}
</h4>
<button
onClick={() => setShowHistory(false)}
className="p-1 hover:bg-gray-200 rounded transition-colors"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
{expenses.length === 0 ? (
<p className="text-gray-600 text-center py-4">{t('expense.noHistory')}</p>
) : (
<div className="space-y-2">
{expenseService.sortByDate(expenses).map((expense) => (
<div
key={expense.id}
className="flex items-start justify-between p-3 bg-white rounded-lg border border-gray-200"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-red-600" />
<span className="font-semibold text-gray-900">
{format.currency(expense.amount)}
</span>
</div>
{expense.description && (
<div className="flex items-start gap-2 text-sm text-gray-600">
<MessageSquare className="w-4 h-4 mt-0.5" />
<span>{expense.description}</span>
</div>
)}
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
<Calendar className="w-3 h-3" />
<span>{format.date(expense.created_at)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,31 +0,0 @@
import { CategoryWithRemaining } from '../../services';
import { CategoryCard } from './CategoryCard';
interface CategoryListProps {
categories: CategoryWithRemaining[];
familyId: number;
onDelete: (categoryId: number) => void;
onReset: (categoryId: number) => void;
onUpdate: () => void;
}
export function CategoryList({ categories, familyId, onDelete, onReset, onUpdate }: CategoryListProps) {
if (categories.length === 0) {
return null;
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{categories.map((category) => (
<CategoryCard
key={category.id}
category={category}
familyId={familyId}
onDelete={onDelete}
onReset={onReset}
onUpdate={onUpdate}
/>
))}
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { UserPlus, User } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface FamilyHeaderProps {
onInvite: () => void;
onProfile: () => void;
}
export function FamilyHeader({ onInvite, onProfile }: FamilyHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 mb-6">
<button
onClick={onInvite}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
>
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">{t('family.inviteMember')}</span>
</button>
<button
onClick={onProfile}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
>
<User className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">{t('profile.title')}</span>
</button>
</div>
);
}

View File

@@ -1,58 +0,0 @@
import { Wallet, ShoppingCart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CategoryWithRemaining } from '../../services';
import { format } from '../../utils/format';
interface FamilySummaryProps {
familyName?: string;
categories: CategoryWithRemaining[];
onShowShoppingList: () => void;
}
export function FamilySummary({ familyName, categories, onShowShoppingList }: FamilySummaryProps) {
const { t } = useTranslation();
const getTotalLimit = () => {
return categories.reduce((sum, cat) => sum + parseFloat(cat.limit_amount.toString()), 0);
};
const getTotalRemaining = () => {
return categories.reduce((sum, cat) => sum + cat.remaining_limit, 0);
};
return (
<div className="mb-6 sm:mb-8">
<div className="text-center">
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
<Wallet className="w-12 h-12 text-white" />
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-white mb-6">
{familyName || t('family.defaultName')}
</h1>
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center">
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalLimit')}</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
{format.currency(getTotalLimit())}
</p>
</div>
<div className="text-center">
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalRemaining')}</p>
<p className="text-2xl sm:text-3xl font-bold text-green-600">
{format.currency(getTotalRemaining())}
</p>
</div>
</div>
<button
onClick={onShowShoppingList}
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white font-semibold py-3 px-6 rounded-xl shadow-md transition-all duration-300 flex items-center justify-center gap-3 group"
>
<ShoppingCart className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span>{t('shopping.title')}</span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,96 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Check, Loader2 } from 'lucide-react';
import { Modal, Button } from '../ui';
import { useInviteLink } from '../../hooks';
import { InviteLinkResponse } from '../../types';
interface InviteModalProps {
onClose: () => void;
}
export function InviteModal({ onClose }: InviteModalProps) {
const { t } = useTranslation();
const { createLink } = useInviteLink();
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const handleCreateLink = async () => {
try {
setLoading(true);
const link = await createLink({ expires_in_hours: 168 });
setInviteLink(link);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!inviteLink) return;
try {
await navigator.clipboard.writeText(inviteLink.invite_url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
return (
<Modal isOpen={true} onClose={onClose} title={t('family.inviteMember')}>
{!inviteLink ? (
<div className="text-center py-6">
<p className="text-gray-700 dark:text-gray-300 mb-6">
{t('invite.description')}
</p>
<Button
variant="primary"
onClick={handleCreateLink}
disabled={loading}
fullWidth
>
{loading ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
{t('common.loading')}
</>
) : (
t('invite.create')
)}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t('invite.link')}
</p>
<p className="text-sm font-mono break-all text-gray-900 dark:text-white">
{inviteLink.invite_url}
</p>
</div>
<Button
variant="primary"
onClick={handleCopy}
fullWidth
>
{copied ? (
<>
<Check className="w-5 h-5 mr-2" />
{t('invite.copied')}
</>
) : (
<>
<Copy className="w-5 h-5 mr-2" />
{t('invite.copy')}
</>
)}
</Button>
</div>
)}
</Modal>
);
}

View File

@@ -1,113 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Users, Edit3, Save, X, AlertTriangle, Loader2 } from 'lucide-react';
import { Family } from '../../types';
import { Card, Button, Input } from '../ui';
import { familyService } from '../../services';
import { showToast } from '../../utils/toast';
import { showErrorToast } from '../../utils/errorHandler';
interface FamilySectionProps {
family: Family | null;
onLeaveFamily: () => void;
onFamilyUpdate: () => void;
leavingFamily: boolean;
}
export function FamilySection({ family, onLeaveFamily, onFamilyUpdate, leavingFamily }: FamilySectionProps) {
const { t } = useTranslation();
const [editingName, setEditingName] = useState(false);
const [newFamilyName, setNewFamilyName] = useState('');
const [savingName, setSavingName] = useState(false);
const handleStartEditName = () => {
setEditingName(true);
setNewFamilyName(family?.name || '');
};
const handleSaveName = async () => {
if (!family || !newFamilyName.trim()) return;
try {
setSavingName(true);
await familyService.update(family.id, { name: newFamilyName });
showToast.success(t('profile.familyNameUpdated'));
setEditingName(false);
onFamilyUpdate();
} catch (error) {
showErrorToast(error);
} finally {
setSavingName(false);
}
};
if (!family) return null;
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-gradient-to-br from-green-500 to-green-600 text-white rounded-xl">
<Users className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800 dark:text-white">{t('profile.family')}</h2>
</div>
<div className="mb-4">
<div className="flex justify-between items-center py-2 border-b border-gray-200 dark:border-gray-700">
<span className="text-gray-600 dark:text-gray-400">{t('profile.familyName')}</span>
{editingName ? (
<div className="flex items-center gap-2">
<Input
type="text"
value={newFamilyName}
onChange={(e) => setNewFamilyName(e.target.value)}
/>
<button
onClick={handleSaveName}
disabled={savingName}
className="p-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
>
{savingName ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
</button>
<button
onClick={() => setEditingName(false)}
className="p-1.5 bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{family.name}</span>
<button
onClick={handleStartEditName}
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900 rounded-lg transition-colors"
>
<Edit3 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
<Button
variant="danger"
fullWidth
onClick={onLeaveFamily}
disabled={leavingFamily}
>
{leavingFamily ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
{t('common.loading')}
</>
) : (
<>
<AlertTriangle className="w-5 h-5 mr-2" />
{t('profile.leaveFamily')}
</>
)}
</Button>
</Card>
);
}

View File

@@ -1,32 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button } from '../ui';
import { LANGUAGES } from '../../constants';
interface LanguageSelectorProps {
currentLanguage: string;
onLanguageChange: (lang: string) => void;
}
export function LanguageSelector({ currentLanguage, onLanguageChange }: LanguageSelectorProps) {
const { t } = useTranslation();
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('profile.language')}
</label>
<div className="flex gap-3">
{LANGUAGES.map((lang) => (
<Button
key={lang.code}
variant={currentLanguage === lang.code ? 'primary' : 'secondary'}
onClick={() => onLanguageChange(lang.code)}
fullWidth
>
{lang.name}
</Button>
))}
</div>
</div>
);
}

View File

@@ -1,88 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { FamilyMember, User } from '../../types';
import { Badge, LoadingSpinner } from '../ui';
interface MembersSectionProps {
members: FamilyMember[];
currentUser: User | null;
loading: boolean;
}
export function MembersSection({ members, currentUser, loading }: MembersSectionProps) {
const { t } = useTranslation();
if (loading) {
return (
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">
{t('profile.members')}
</h3>
<div className="flex items-center justify-center py-4">
<LoadingSpinner size="sm" />
</div>
</div>
);
}
if (members.length === 0) {
return (
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">
{t('profile.members')}
</h3>
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
{t('profile.noMembers')}
</div>
</div>
);
}
return (
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">
{t('profile.members')} ({members.length})
</h3>
<div className="space-y-2">
{members.map((member) => (
<div
key={member.id}
className={`flex items-center justify-between p-3 rounded-xl ${
member.id === currentUser?.id
? 'bg-purple-50 dark:bg-purple-900/20 border-2 border-purple-200 dark:border-purple-800'
: 'bg-gray-50 dark:bg-gray-800'
}`}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-sm font-bold">
{(member.username || member.email || '?')[0].toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-800 dark:text-white">
{member.username || member.email || t('profile.unknownUser')}
</span>
{member.id === currentUser?.id && (
<Badge variant="info" size="sm">
{t('profile.you')}
</Badge>
)}
</div>
{member.email && member.username && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{member.email}
</span>
)}
</div>
</div>
{member.is_admin && (
<Badge variant="warning" size="sm">
Admin
</Badge>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface ProfileHeaderProps {
onBack: () => void;
}
export function ProfileHeader({ onBack }: ProfileHeaderProps) {
const { t } = useTranslation();
return (
<button
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 mb-6 group"
>
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span className="font-medium">{t('common.back')}</span>
</button>
);
}

View File

@@ -1,58 +0,0 @@
import { Settings, Palette, Languages } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Theme } from '../../types';
import { Card } from '../ui';
import { ThemeSelector } from './ThemeSelector';
import { LanguageSelector } from './LanguageSelector';
interface SettingsSectionProps {
currentTheme: Theme;
currentLanguage: string;
onThemeChange: (theme: Theme) => void;
onLanguageChange: (lang: string) => void;
}
export function SettingsSection({
currentTheme,
currentLanguage,
onThemeChange,
onLanguageChange,
}: SettingsSectionProps) {
const { t } = useTranslation();
return (
<Card>
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 text-white rounded-xl">
<Settings className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800 dark:text-white">{t('profile.settings')}</h2>
</div>
<div className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-3">
<Palette className="w-4 h-4 text-gray-600 dark:text-gray-400" />
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">
{t('profile.theme')}
</h3>
</div>
<ThemeSelector currentTheme={currentTheme} onThemeChange={onThemeChange} />
</div>
<div>
<div className="flex items-center gap-2 mb-3">
<Languages className="w-4 h-4 text-gray-600 dark:text-gray-400" />
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">
{t('profile.language')}
</h3>
</div>
<LanguageSelector
currentLanguage={currentLanguage}
onLanguageChange={onLanguageChange}
/>
</div>
</div>
</Card>
);
}

View File

@@ -1,43 +0,0 @@
import { Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Theme } from '../../types';
import { THEMES } from '../../constants';
interface ThemeSelectorProps {
currentTheme: Theme;
onThemeChange: (theme: Theme) => void;
}
export function ThemeSelector({ currentTheme, onThemeChange }: ThemeSelectorProps) {
const { t } = useTranslation();
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('profile.theme')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{THEMES.map((theme) => (
<button
key={theme.id}
onClick={() => onThemeChange(theme.id)}
className={`relative p-4 rounded-xl ${theme.gradient} transition-all duration-300 ${
currentTheme === theme.id
? 'ring-4 ring-blue-500 scale-105'
: 'hover:scale-105'
}`}
>
{currentTheme === theme.id && (
<div className="absolute top-2 right-2 bg-white rounded-full p-1">
<Check className="w-4 h-4 text-blue-600" />
</div>
)}
<p className="text-sm font-medium text-center text-white drop-shadow-lg">
{theme.name}
</p>
</button>
))}
</div>
</div>
);
}

View File

@@ -1,39 +0,0 @@
import { User as UserIcon, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { User } from '../../types';
import { Button, Card } from '../ui';
interface UserInfoProps {
user: User | null;
onLogout: () => void;
}
export function UserInfo({ user, onLogout }: UserInfoProps) {
const { t } = useTranslation();
if (!user) return null;
return (
<Card>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="p-4 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl">
<UserIcon className="w-8 h-8 text-white" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{user.username || user.email || t('profile.anonymous')}
</h2>
<p className="text-gray-600 dark:text-gray-400">
{user.email || t('profile.noEmail')}
</p>
</div>
</div>
<Button variant="danger" onClick={onLogout}>
<LogOut className="w-5 h-5 mr-2" />
{t('profile.logout')}
</Button>
</div>
</Card>
);
}

View File

@@ -1,98 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, Check, Pencil, X } from 'lucide-react';
import { ShoppingItem } from '../../types';
import { Button, Input } from '../ui';
interface ShoppingItemCardProps {
item: ShoppingItem;
onToggle: (itemId: number, currentStatus: boolean) => Promise<void>;
onDelete: (itemId: number) => void;
onUpdate: (itemId: number, name: string) => Promise<void>;
}
export function ShoppingItemCard({ item, onToggle, onDelete, onUpdate }: ShoppingItemCardProps) {
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(item.name);
const handleSave = async () => {
if (!editName.trim()) return;
try {
await onUpdate(item.id, editName);
setIsEditing(false);
} catch (error) {
console.error(error);
}
};
const handleCancel = () => {
setEditName(item.name);
setIsEditing(false);
};
return (
<div
className={`flex items-center justify-between p-4 rounded-xl transition-all duration-300 ${
item.is_purchased
? 'bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800'
: 'bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700'
}`}
>
{isEditing ? (
<div className="flex-1 flex gap-2">
<Input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
fullWidth
/>
<Button variant="success" size="sm" onClick={handleSave}>
<Check className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={handleCancel}>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<div className="flex items-center gap-3 flex-1">
<button
onClick={() => onToggle(item.id, item.is_purchased)}
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-300 ${
item.is_purchased
? 'bg-green-500 border-green-500'
: 'border-gray-300 dark:border-gray-600 hover:border-green-500'
}`}
>
{item.is_purchased && <Check className="w-4 h-4 text-white" />}
</button>
<span
className={`text-lg ${
item.is_purchased
? 'line-through text-gray-500 dark:text-gray-400'
: 'text-gray-900 dark:text-white'
}`}
>
{item.name}
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setIsEditing(true)}
className="p-2 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-lg transition-colors group"
>
<Pencil className="w-5 h-5 text-blue-600 group-hover:scale-110 transition-transform" />
</button>
<button
onClick={() => onDelete(item.id)}
className="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded-lg transition-colors group"
>
<Trash2 className="w-5 h-5 text-red-600 group-hover:scale-110 transition-transform" />
</button>
</div>
</>
)}
</div>
);
}

View File

@@ -1,54 +0,0 @@
import { useState, KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import { Button, Input } from '../ui';
interface ShoppingItemInputProps {
onAdd: (name: string) => Promise<void>;
}
export function ShoppingItemInput({ onAdd }: ShoppingItemInputProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!name.trim()) return;
try {
setIsSubmitting(true);
await onAdd(name);
setName('');
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSubmit();
}
};
return (
<div className="flex gap-3 mb-6">
<Input
type="text"
placeholder={t('shopping.addItem')}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyPress={handleKeyPress}
fullWidth
/>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isSubmitting || !name.trim()}
>
<Plus className="w-5 h-5" />
</Button>
</div>
);
}

View File

@@ -1,69 +0,0 @@
import { useTranslation } from 'react-i18next';
import { ShoppingItem } from '../../types';
import { ShoppingItemCard } from './ShoppingItemCard';
import { shoppingService } from '../../services';
interface ShoppingItemListProps {
items: ShoppingItem[];
onToggle: (itemId: number, currentStatus: boolean) => Promise<void>;
onDelete: (itemId: number) => void;
onUpdate: (itemId: number, name: string) => Promise<void>;
}
export function ShoppingItemList({ items, onToggle, onDelete, onUpdate }: ShoppingItemListProps) {
const { t } = useTranslation();
if (items.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400 text-lg">
{t('shopping.noItems')}
</p>
</div>
);
}
const { pending, purchased } = shoppingService.sortItems(items);
return (
<div className="space-y-6">
{pending.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
{t('shopping.pending')} ({pending.length})
</h3>
<div className="space-y-3">
{pending.map((item) => (
<ShoppingItemCard
key={item.id}
item={item}
onToggle={onToggle}
onDelete={onDelete}
onUpdate={onUpdate}
/>
))}
</div>
</div>
)}
{purchased.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
{t('shopping.purchased')} ({purchased.length})
</h3>
<div className="space-y-3">
{purchased.map((item) => (
<ShoppingItemCard
key={item.id}
item={item}
onToggle={onToggle}
onDelete={onDelete}
onUpdate={onUpdate}
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,31 +0,0 @@
import { ReactNode } from 'react';
interface BadgeProps {
children: ReactNode;
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info';
size?: 'sm' | 'md';
className?: string;
}
export function Badge({ children, variant = 'default', size = 'md', className = '' }: BadgeProps) {
const baseClasses = 'inline-flex items-center font-medium rounded-full';
const variantClasses = {
default: 'bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
success: 'bg-green-200 text-green-800 dark:bg-green-900 dark:text-green-200',
warning: 'bg-yellow-200 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
danger: 'bg-red-200 text-red-800 dark:bg-red-900 dark:text-red-200',
info: 'bg-blue-200 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
};
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
};
return (
<span className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}>
{children}
</span>
);
}

View File

@@ -1,46 +0,0 @@
import { ButtonHTMLAttributes, ReactNode } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'success' | 'danger' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
children: ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
fullWidth = false,
className = '',
children,
disabled,
...props
}: ButtonProps) {
const baseClasses = 'font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white shadow-md hover:shadow-lg',
success: 'bg-green-600 hover:bg-green-700 text-white shadow-md hover:shadow-lg',
danger: 'bg-red-600 hover:bg-red-700 text-white shadow-md hover:shadow-lg',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white shadow-md hover:shadow-lg',
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
};
const widthClass = fullWidth ? 'w-full' : '';
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${className}`}
disabled={disabled}
{...props}
>
{children}
</button>
);
}

View File

@@ -1,23 +0,0 @@
import { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
hover?: boolean;
}
export function Card({ children, className = '', onClick, hover = false }: CardProps) {
const baseClasses = 'bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6';
const hoverClasses = hover ? 'hover:shadow-xl transition-shadow duration-200 cursor-pointer' : '';
const clickClasses = onClick ? 'cursor-pointer' : '';
return (
<div
className={`${baseClasses} ${hoverClasses} ${clickClasses} ${className}`}
onClick={onClick}
>
{children}
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { Modal, Button } from './index';
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmModal({ isOpen, title, message, onConfirm, onCancel }: ConfirmModalProps) {
return (
<Modal isOpen={isOpen} onClose={onCancel} title={title} size="sm">
<p className="text-gray-700 dark:text-gray-300 mb-6">{message}</p>
<div className="flex gap-3 justify-end">
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button variant="danger" onClick={onConfirm}>
Confirm
</Button>
</div>
</Modal>
);
}

View File

@@ -1,38 +0,0 @@
import { InputHTMLAttributes, forwardRef } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
fullWidth?: boolean;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, fullWidth = false, className = '', ...props }, ref) => {
const baseClasses = 'px-4 py-2 border rounded-lg transition-colors duration-200';
const stateClasses = error
? 'border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500';
const widthClass = fullWidth ? 'w-full' : '';
const bgClass = 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white';
return (
<div className={widthClass}>
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{label}
</label>
)}
<input
ref={ref}
className={`${baseClasses} ${stateClasses} ${bgClass} ${widthClass} ${className}`}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -1,32 +0,0 @@
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
fullScreen?: boolean;
text?: string;
}
export function LoadingSpinner({ size = 'md', fullScreen = false, text }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-6 h-6 border-2',
md: 'w-10 h-10 border-3',
lg: 'w-16 h-16 border-4',
};
const spinner = (
<div className="flex flex-col items-center gap-3">
<div
className={`${sizeClasses[size]} border-blue-600 border-t-transparent rounded-full animate-spin`}
></div>
{text && <p className="text-gray-600 dark:text-gray-400">{text}</p>}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 bg-white dark:bg-gray-900 flex items-center justify-center z-50">
{spinner}
</div>
);
}
return spinner;
}

View File

@@ -1,60 +0,0 @@
import { ReactNode, useEffect } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={handleBackdropClick}
>
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-2xl ${sizeClasses[size]} w-full max-h-[90vh] overflow-y-auto`}>
{title && (
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{title}</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl"
>
&times;
</button>
</div>
)}
<div className="p-6">{children}</div>
</div>
</div>
);
}

View File

@@ -1,70 +0,0 @@
interface SkeletonProps {
className?: string;
variant?: 'text' | 'circular' | 'rectangular';
width?: string | number;
height?: string | number;
}
export function Skeleton({ className = '', variant = 'rectangular', width, height }: SkeletonProps) {
const baseClasses = 'animate-pulse bg-gray-300 dark:bg-gray-700';
const variantClasses = {
text: 'rounded h-4',
circular: 'rounded-full',
rectangular: 'rounded-lg',
};
const style: React.CSSProperties = {};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
return (
<div
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
style={style}
/>
);
}
export function CategoryCardSkeleton() {
return (
<div className="glass-effect rounded-2xl shadow-lg p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<Skeleton variant="rectangular" width={48} height={48} />
<div className="space-y-2">
<Skeleton variant="text" width={150} />
<Skeleton variant="text" width={100} />
</div>
</div>
<div className="flex gap-2">
<Skeleton variant="circular" width={32} height={32} />
<Skeleton variant="circular" width={32} height={32} />
</div>
</div>
<div className="mb-4">
<Skeleton variant="text" width="100%" className="mb-2" />
<Skeleton variant="rectangular" width="100%" height={12} />
</div>
<div className="flex gap-2">
<Skeleton variant="rectangular" width="50%" height={40} />
<Skeleton variant="rectangular" width="50%" height={40} />
</div>
</div>
);
}
export function ShoppingItemSkeleton() {
return (
<div className="flex items-center justify-between p-4 rounded-xl bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 flex-1">
<Skeleton variant="circular" width={24} height={24} />
<Skeleton variant="text" width={200} />
</div>
<div className="flex gap-2">
<Skeleton variant="circular" width={32} height={32} />
<Skeleton variant="circular" width={32} height={32} />
</div>
</div>
);
}

View File

@@ -1,8 +0,0 @@
export { Button } from './Button';
export { Input } from './Input';
export { Card } from './Card';
export { Modal } from './Modal';
export { Badge } from './Badge';
export { LoadingSpinner } from './LoadingSpinner';
export { ConfirmModal } from './ConfirmModal';
export { Skeleton, CategoryCardSkeleton, ShoppingItemSkeleton } from './Skeleton';

View File

@@ -1,15 +0,0 @@
import { Theme } from '../types';
export const THEMES: { id: Theme; gradient: string; name: string }[] = [
{ id: 'light', gradient: 'bg-gradient-to-r from-gray-100 to-gray-200', name: 'Light' },
{ id: 'dark', gradient: 'bg-gradient-to-r from-black to-gray-900', name: 'Dark' },
{ id: 'sunset', gradient: 'bg-gradient-to-r from-orange-400 to-pink-500', name: 'Sunset' },
{ id: 'ocean', gradient: 'bg-gradient-to-r from-blue-400 to-cyan-500', name: 'Ocean' },
{ id: 'forest', gradient: 'bg-gradient-to-r from-green-400 to-teal-500', name: 'Forest' },
{ id: 'purple', gradient: 'bg-gradient-to-r from-purple-500 to-pink-500', name: 'Purple' },
];
export const LANGUAGES = [
{ code: 'ru', name: 'Русский' },
{ code: 'en', name: 'English' },
];

View File

@@ -1,6 +0,0 @@
export { useCategories } from './useCategories';
export { useExpenses } from './useExpenses';
export { useFamilyMembers } from './useFamilyMembers';
export { useShoppingList } from './useShoppingList';
export { useInviteLink } from './useInviteLink';
export { useConfirm } from './useConfirm';

View File

@@ -1,86 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { categoryService, CategoryWithRemaining } from '../services';
import { CreateCategoryRequest } from '../types';
import { showToast } from '../utils/toast';
import { showErrorToast } from '../utils/errorHandler';
export function useCategories(familyId: number) {
const [categories, setCategories] = useState<CategoryWithRemaining[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadCategories = useCallback(async () => {
if (!familyId) return;
try {
setLoading(true);
setError(null);
const data = await categoryService.getAllByFamily(familyId);
setCategories(data);
} catch (err) {
setError(err as Error);
showErrorToast(err);
} finally {
setLoading(false);
}
}, [familyId]);
useEffect(() => {
loadCategories();
}, [loadCategories]);
const createCategory = useCallback(async (data: CreateCategoryRequest) => {
try {
await categoryService.create(familyId, data);
showToast.success('Category created successfully');
await loadCategories();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadCategories]);
const deleteCategory = useCallback(async (categoryId: number) => {
try {
await categoryService.delete(familyId, categoryId);
showToast.success('Category deleted successfully');
await loadCategories();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadCategories]);
const resetLimit = useCallback(async (categoryId: number, newLimit: number) => {
try {
await categoryService.resetLimit(familyId, categoryId, newLimit);
showToast.success('Limit reset successfully');
await loadCategories();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadCategories]);
const updateCategory = useCallback(async (categoryId: number, data: Partial<CreateCategoryRequest>) => {
try {
await categoryService.update(familyId, categoryId, data);
showToast.success('Category updated successfully');
await loadCategories();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadCategories]);
return {
categories,
loading,
error,
loadCategories,
createCategory,
deleteCategory,
resetLimit,
updateCategory,
};
}

View File

@@ -1,41 +0,0 @@
import { useState, useCallback } from 'react';
interface ConfirmState {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
}
export function useConfirm() {
const [state, setState] = useState<ConfirmState>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
});
const confirm = useCallback((title: string, message: string): Promise<boolean> => {
return new Promise((resolve) => {
setState({
isOpen: true,
title,
message,
onConfirm: () => {
setState((prev) => ({ ...prev, isOpen: false }));
resolve(true);
},
});
});
}, []);
const cancel = useCallback(() => {
setState((prev) => ({ ...prev, isOpen: false }));
}, []);
return {
confirmState: state,
confirm,
cancel,
};
}

View File

@@ -1,74 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { expenseService } from '../services';
import { Expense, CreateExpenseRequest } from '../types';
import { showToast } from '../utils/toast';
import { showErrorToast } from '../utils/errorHandler';
export function useExpenses(familyId: number, categoryId: number) {
const [expenses, setExpenses] = useState<Expense[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadExpenses = useCallback(async () => {
if (!familyId || !categoryId) return;
try {
setLoading(true);
setError(null);
const data = await expenseService.getAllByCategory(familyId, categoryId);
setExpenses(data);
} catch (err) {
setError(err as Error);
showErrorToast(err);
} finally {
setLoading(false);
}
}, [familyId, categoryId]);
useEffect(() => {
loadExpenses();
}, [loadExpenses]);
const createExpense = useCallback(async (data: CreateExpenseRequest) => {
try {
await expenseService.create(familyId, categoryId, data);
showToast.success('Expense added successfully');
await loadExpenses();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, categoryId, loadExpenses]);
const deleteExpense = useCallback(async (expenseId: number) => {
try {
await expenseService.delete(familyId, categoryId, expenseId);
showToast.success('Expense deleted successfully');
await loadExpenses();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, categoryId, loadExpenses]);
const updateExpense = useCallback(async (expenseId: number, data: Partial<CreateExpenseRequest>) => {
try {
await expenseService.update(familyId, categoryId, expenseId, data);
showToast.success('Expense updated successfully');
await loadExpenses();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, categoryId, loadExpenses]);
return {
expenses,
loading,
error,
loadExpenses,
createExpense,
deleteExpense,
updateExpense,
};
}

View File

@@ -1,40 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { familyService } from '../services';
import { FamilyMember } from '../types';
import { showErrorToast } from '../utils/errorHandler';
export function useFamilyMembers(familyId: number | null) {
const [members, setMembers] = useState<FamilyMember[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const loadMembers = useCallback(async () => {
if (!familyId) {
setMembers([]);
return;
}
try {
setLoading(true);
setError(null);
const data = await familyService.getMembers(familyId);
setMembers(data);
} catch (err) {
setError(err as Error);
showErrorToast(err);
} finally {
setLoading(false);
}
}, [familyId]);
useEffect(() => {
loadMembers();
}, [loadMembers]);
return {
members,
loading,
error,
loadMembers,
};
}

View File

@@ -1,83 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { inviteService } from '../services';
import { InviteLinkResponse, CreateInviteLinkRequest } from '../types';
import { showToast } from '../utils/toast';
import { showErrorToast } from '../utils/errorHandler';
export function useInviteLink() {
const [links, setLinks] = useState<InviteLinkResponse[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const loadLinks = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await inviteService.getMyLinks();
setLinks(data);
} catch (err) {
setError(err as Error);
showErrorToast(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadLinks();
}, [loadLinks]);
const createLink = useCallback(async (data: CreateInviteLinkRequest) => {
try {
const link = await inviteService.create(data);
showToast.success('Invite link created successfully');
await loadLinks();
return link;
} catch (err) {
showErrorToast(err);
throw err;
}
}, [loadLinks]);
const deleteLink = useCallback(async (token: string) => {
try {
await inviteService.delete(token);
showToast.success('Invite link deleted successfully');
await loadLinks();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [loadLinks]);
const validateLink = useCallback(async (token: string) => {
try {
return await inviteService.validate(token);
} catch (err) {
showErrorToast(err);
throw err;
}
}, []);
const joinFamily = useCallback(async (token: string) => {
try {
const result = await inviteService.join(token);
showToast.success(result.message);
return result;
} catch (err) {
showErrorToast(err);
throw err;
}
}, []);
return {
links,
loading,
error,
loadLinks,
createLink,
deleteLink,
validateLink,
joinFamily,
};
}

View File

@@ -1,97 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { shoppingService } from '../services';
import { ShoppingItem, CreateShoppingItemRequest } from '../types';
import { showToast } from '../utils/toast';
import { showErrorToast } from '../utils/errorHandler';
export function useShoppingList(familyId: number) {
const [items, setItems] = useState<ShoppingItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadItems = useCallback(async () => {
if (!familyId) return;
try {
setLoading(true);
setError(null);
const data = await shoppingService.getAllByFamily(familyId);
setItems(data);
} catch (err) {
setError(err as Error);
showErrorToast(err);
} finally {
setLoading(false);
}
}, [familyId]);
useEffect(() => {
loadItems();
}, [loadItems]);
const createItem = useCallback(async (data: CreateShoppingItemRequest) => {
try {
await shoppingService.create(familyId, data);
showToast.success('Item added successfully');
await loadItems();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadItems]);
const deleteItem = useCallback(async (itemId: number) => {
try {
await shoppingService.delete(familyId, itemId);
showToast.success('Item deleted successfully');
await loadItems();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadItems]);
const togglePurchased = useCallback(async (itemId: number, isPurchased: boolean) => {
try {
await shoppingService.markAsPurchased(familyId, itemId, isPurchased);
await loadItems();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadItems]);
const markAllAsPurchased = useCallback(async () => {
try {
const affected = await shoppingService.markAllAsPurchased(familyId);
showToast.success(`Marked ${affected} items as purchased`);
await loadItems();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadItems]);
const clearAll = useCallback(async () => {
try {
const affected = await shoppingService.clearAll(familyId);
showToast.success(`Cleared ${affected} items`);
await loadItems();
} catch (err) {
showErrorToast(err);
throw err;
}
}, [familyId, loadItems]);
return {
items,
loading,
error,
loadItems,
createItem,
deleteItem,
togglePurchased,
markAllAsPurchased,
clearAll,
};
}

View File

@@ -1,6 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Toaster } from 'react-hot-toast'
import './i18n'
import './index.css'
import App from './App.tsx'
@@ -8,6 +7,5 @@ import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<Toaster />
</StrictMode>,
)

View File

@@ -1,657 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { categoryApi, expenseApi, inviteLinkApi } from '../api/client';
import { useStore } from '../store/useStore';
import type { Category, Expense, InviteLinkResponse } from '../types';
import {
Wallet,
TrendingDown,
Plus,
Trash2,
RotateCcw,
Loader2,
X,
DollarSign,
Tag,
History,
Calendar,
MessageSquare,
ShoppingCart,
UserPlus,
Copy,
Check,
User,
} from 'lucide-react';
import ShoppingListModal from '../components/ShoppingListModal';
export default function FamilyView() {
const { t } = useTranslation();
const { familyId } = useParams<{ familyId: string }>();
const navigate = useNavigate();
const { selectedFamily } = useStore();
const [categories, setCategories] = useState<Category[]>([]);
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showAddCategory, setShowAddCategory] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [newCategoryLimit, setNewCategoryLimit] = useState('');
const [showAddExpense, setShowAddExpense] = useState<number | null>(null);
const [expenseAmount, setExpenseAmount] = useState('');
const [expenseDescription, setExpenseDescription] = useState('');
const [showHistory, setShowHistory] = useState<number | null>(null);
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
const [showShoppingList, setShowShoppingList] = useState(false);
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!familyId) {
navigate('/');
return;
}
loadCategories();
}, [familyId]);
const loadCategories = async () => {
if (!familyId) return;
try {
setLoading(true);
setError('');
console.log('Loading categories for family:', familyId);
const response = await categoryApi.getAllByFamily(parseInt(familyId));
console.log('Categories loaded:', response.data);
setCategories(response.data);
const limits = new Map<number, number>();
for (const category of response.data) {
const limitResponse = await expenseApi.getRemainingLimit(
parseInt(familyId),
category.id
);
const limitValue = typeof limitResponse.data.remaining_limit === 'string'
? parseFloat(limitResponse.data.remaining_limit)
: limitResponse.data.remaining_limit;
limits.set(category.id, limitValue);
}
setRemainingLimits(limits);
console.log('All data loaded successfully');
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || t('family.loadError');
setError(errorMsg);
console.error('Error loading categories:', err);
} finally {
setLoading(false);
}
};
const handleAddCategory = async () => {
if (!familyId || !newCategoryName || !newCategoryLimit) return;
try {
await categoryApi.create(parseInt(familyId), {
name: newCategoryName,
limit_amount: parseFloat(newCategoryLimit),
});
setNewCategoryName('');
setNewCategoryLimit('');
setShowAddCategory(false);
loadCategories();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || t('category.createError');
alert(`${t('category.createError')}: ${errorMsg}`);
console.error('Full error:', err);
}
};
const handleDeleteCategory = async (categoryId: number) => {
if (!familyId) return;
if (!confirm(t('category.deleteConfirm'))) return;
try {
await categoryApi.delete(parseInt(familyId), categoryId);
loadCategories();
} catch (err) {
alert(t('category.deleteError'));
console.error(err);
}
};
const handleResetLimit = async (categoryId: number) => {
if (!familyId) return;
if (!confirm(t('category.resetConfirm'))) return;
try {
const expensesResponse = await expenseApi.getAllByCategory(
parseInt(familyId),
categoryId
);
for (const expense of expensesResponse.data) {
await expenseApi.delete(
parseInt(familyId),
categoryId,
expense.id
);
}
loadCategories();
} catch (err) {
alert(t('category.resetError'));
console.error(err);
}
};
const handleAddExpense = async (categoryId: number) => {
if (!familyId || !expenseAmount) return;
try {
await expenseApi.create(parseInt(familyId), categoryId, {
amount: parseFloat(expenseAmount),
description: expenseDescription || undefined,
});
setExpenseAmount('');
setExpenseDescription('');
setShowAddExpense(null);
loadCategories();
} catch (err) {
alert(t('expense.addError'));
console.error(err);
}
};
const handleShowHistory = async (categoryId: number) => {
if (!familyId) return;
if (showHistory === categoryId) {
setShowHistory(null);
return;
}
try {
const response = await expenseApi.getAllByCategory(
parseInt(familyId),
categoryId
);
setCategoryExpenses(response.data);
setShowHistory(categoryId);
} catch (err) {
alert(t('expense.historyError'));
console.error(err);
}
};
const handleCreateInviteLink = async () => {
try {
setInviteLoading(true);
const response = await inviteLinkApi.create({ expires_in_hours: 168 });
setInviteLink(response.data);
} catch (err) {
alert(t('invite.createError'));
console.error(err);
} finally {
setInviteLoading(false);
}
};
const handleCopyInviteLink = async () => {
if (!inviteLink) return;
try {
await navigator.clipboard.writeText(inviteLink.invite_url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleOpenInviteModal = () => {
setShowInviteModal(true);
setInviteLink(null);
setCopied(false);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center gradient-bg">
<div className="flex items-center gap-3 text-white">
<Loader2 className="w-8 h-8 animate-spin" />
<span className="text-xl font-medium">{t('common.loading')}</span>
</div>
</div>
);
}
const getProgressColor = (remaining: number, limit: number) => {
const percentage = (remaining / limit) * 100;
if (percentage > 50) return 'bg-green-500';
if (percentage > 25) return 'bg-yellow-500';
return 'bg-red-500';
};
const getProgressPercentage = (remaining: number, limit: number) => {
return Math.max(0, Math.min(100, (remaining / limit) * 100));
};
const getTotalLimit = () => {
return categories.reduce((sum, cat) => sum + parseFloat(cat.limit_amount.toString()), 0);
};
const getTotalRemaining = () => {
return Array.from(remainingLimits.values()).reduce((sum, val) => sum + val, 0);
};
const formatDate = (dateString: string) => {
let dateStr = dateString;
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
dateStr = dateStr + 'Z';
}
const date = new Date(dateStr);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
<div className="max-w-5xl mx-auto">
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-6">
<button
onClick={handleOpenInviteModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
>
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">{t('family.inviteMember')}</span>
</button>
<button
onClick={() => navigate('/profile')}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
>
<User className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">{t('profile.title')}</span>
</button>
</div>
<div className="text-center">
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
<Wallet className="w-12 h-12 text-white" />
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-white mb-6">
{selectedFamily?.name || t('family.defaultName')}
</h1>
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center">
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalLimit')}</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
{getTotalLimit().toFixed(2)}
</p>
</div>
<div className="text-center border-l-2 border-gray-300">
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalRemaining')}</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
{getTotalRemaining().toFixed(2)}
</p>
</div>
</div>
<button
onClick={() => setShowShoppingList(true)}
className="w-full flex items-center justify-center gap-2 px-6 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<ShoppingCart className="w-5 h-5" />
{t('family.shoppingList')}
</button>
</div>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/90 backdrop-blur-md border border-red-300/50 text-white rounded-2xl shadow-lg max-w-2xl mx-auto">
<div className="flex items-center gap-2">
<X className="w-5 h-5 flex-shrink-0" />
<span>{error}</span>
</div>
</div>
)}
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
{categories.map((category) => {
const remaining = remainingLimits.get(category.id) || 0;
const limit = parseFloat(category.limit_amount.toString());
const percentage = getProgressPercentage(remaining, limit);
return (
<div
key={category.id}
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
>
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="p-2 category-icon text-white rounded-xl shadow-lg">
<Tag className="w-6 h-6" />
</div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
{category.name}
</h2>
</div>
{showAddExpense !== category.id && (
<button
onClick={() => setShowAddExpense(category.id)}
className="flex items-center gap-2 px-4 py-2 btn-danger text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
>
<TrendingDown className="w-4 h-4" />
<span className="hidden sm:inline">{t('category.addExpense')}</span>
<span className="sm:hidden">{t('category.expense')}</span>
</button>
)}
</div>
<div className="space-y-3 mb-4">
<div className="flex justify-between items-baseline">
<span className="text-gray-600 font-medium text-sm">{t('category.remaining')}</span>
<span className="text-2xl sm:text-3xl font-bold text-gray-900">
{remaining.toFixed(2)}
</span>
</div>
<div className="flex justify-between items-baseline text-gray-500 text-sm">
<span>{t('category.limit')}</span>
<span className="text-base font-semibold">{limit.toFixed(2)} </span>
</div>
<div className="relative h-3 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${getProgressColor(remaining, limit)} transition-all duration-500 rounded-full shadow-inner`}
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 text-center font-medium">
{percentage.toFixed(0)}{t('category.percentRemaining')}
</p>
</div>
<div className="flex gap-2 justify-between">
<button
onClick={() => handleResetLimit(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<RotateCcw className="w-4 h-4" />
<span>{t('category.reset')}</span>
</button>
<button
onClick={() => handleShowHistory(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<History className="w-4 h-4" />
<span>{t('category.history')}</span>
</button>
<button
onClick={() => handleDeleteCategory(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<Trash2 className="w-4 h-4" />
<span>{t('common.delete')}</span>
</button>
</div>
{showHistory === category.id && (
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-gray-200">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800 text-lg flex items-center gap-2">
<History className="w-5 h-5" />
{t('expense.historyTitle')}
</h3>
<button
onClick={() => setShowHistory(null)}
className="p-2 hover:bg-white/50 rounded-xl transition-all"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
{categoryExpenses.length === 0 ? (
<p className="text-center text-gray-500 py-4">{t('expense.noExpenses')}</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{categoryExpenses.map((expense) => (
<div
key={expense.id}
className="expense-history-item p-3 rounded-xl shadow-sm border"
>
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-red-100 rounded-lg">
<TrendingDown className="w-4 h-4 text-red-600" />
</div>
<span className="font-bold text-gray-900 text-lg">
{parseFloat(expense.amount.toString()).toFixed(2)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
<span>{formatDate(expense.created_at)}</span>
</div>
</div>
{expense.description && (
<div className="flex items-start gap-2 text-sm text-gray-600 expense-description p-2 rounded-lg">
<MessageSquare className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{expense.description}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{showAddExpense === category.id && (
<div className="glass-effect p-6 rounded-2xl border-2 border-gray-200 mt-4">
<h3 className="font-semibold text-gray-800 mb-4 text-center">
{t('expense.addTitle')}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('expense.amount')}
</label>
<input
type="number"
placeholder="0.00"
value={expenseAmount}
onChange={(e) => setExpenseAmount(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all text-center font-semibold text-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('expense.description')}
</label>
<input
type="text"
placeholder={t('expense.descriptionPlaceholder')}
value={expenseDescription}
onChange={(e) => setExpenseDescription(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => handleAddExpense(category.id)}
className="flex-1 flex items-center justify-center gap-2 px-5 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Plus className="w-5 h-5" />
{t('common.add')}
</button>
<button
onClick={() => setShowAddExpense(null)}
className="px-5 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
<div className="flex items-center justify-center gap-3 mb-8">
<div className="p-3 category-icon rounded-2xl">
<DollarSign className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl sm:text-3xl font-bold text-gray-800">
{t('category.management')}
</h2>
</div>
{showAddCategory ? (
<div className="mb-8 p-6 glass-effect rounded-2xl border-2 border-gray-200">
<h3 className="font-bold text-gray-800 mb-5 text-center text-lg">
{t('category.newCategory')}
</h3>
<div className="space-y-4">
<input
type="text"
placeholder={t('category.categoryName')}
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium"
/>
<input
type="number"
placeholder={t('category.categoryLimit')}
value={newCategoryLimit}
onChange={(e) => setNewCategoryLimit(e.target.value)}
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium text-center"
/>
<div className="flex gap-3">
<button
onClick={handleAddCategory}
className="flex-1 flex items-center justify-center gap-2 px-6 py-4 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Plus className="w-5 h-5" />
{t('common.create')}
</button>
<button
onClick={() => setShowAddCategory(false)}
className="px-6 py-4 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
>
{t('common.cancel')}
</button>
</div>
</div>
</div>
) : (
<button
onClick={() => setShowAddCategory(true)}
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<Plus className="w-5 h-5" />
{t('category.addCategory')}
</button>
)}
</div>
</div>
{showShoppingList && familyId && (
<ShoppingListModal
familyId={parseInt(familyId)}
onClose={() => setShowShoppingList(false)}
/>
)}
{showInviteModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass-effect rounded-3xl shadow-2xl w-full max-w-md p-6 sm:p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-3 category-icon rounded-2xl">
<UserPlus className="w-6 h-6 text-white" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('invite.title')}</h2>
</div>
<button
onClick={() => setShowInviteModal(false)}
className="p-2 hover:bg-gray-100 rounded-xl transition-all"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{!inviteLink ? (
<div className="text-center">
<p className="text-gray-600 mb-6">
{t('invite.description')}
</p>
<button
onClick={handleCreateInviteLink}
disabled={inviteLoading}
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all font-semibold disabled:opacity-50"
>
{inviteLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{t('invite.creating')}
</>
) : (
<>
<UserPlus className="w-5 h-5" />
{t('invite.createLink')}
</>
)}
</button>
</div>
) : (
<div>
<p className="text-gray-600 mb-4 text-center">
{t('invite.sendLink')}
</p>
<div className="bg-gray-100 rounded-2xl p-4 mb-4">
<p className="text-sm text-gray-800 break-all font-mono">
{inviteLink.invite_url}
</p>
</div>
<button
onClick={handleCopyInviteLink}
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-2xl transition-all font-semibold ${
copied
? 'btn-success text-white'
: 'btn-primary text-white hover:shadow-xl'
}`}
>
{copied ? (
<>
<Check className="w-5 h-5" />
{t('invite.copied')}
</>
) : (
<>
<Copy className="w-5 h-5" />
{t('invite.copyLink')}
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,16 +1,29 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { categoryApi, expenseApi, inviteLinkApi } from '../api/client';
import { useStore } from '../store/useStore';
import { useCategories, useConfirm } from '../hooks';
import { FamilyHeader } from '../components/family/FamilyHeader';
import { FamilySummary } from '../components/family/FamilySummary';
import { CategoryList } from '../components/family/CategoryList';
import { AddCategorySection } from '../components/family/AddCategorySection';
import { InviteModal } from '../components/family/InviteModal';
import type { Category, Expense, InviteLinkResponse } from '../types';
import {
Wallet,
TrendingDown,
Plus,
Trash2,
RotateCcw,
Loader2,
X,
DollarSign,
Tag,
History,
Calendar,
MessageSquare,
ShoppingCart,
UserPlus,
Copy,
Check,
User,
} from 'lucide-react';
import ShoppingListModal from '../components/ShoppingListModal';
import { ConfirmModal } from '../components/ui';
export default function FamilyView() {
const { t } = useTranslation();
@@ -18,34 +31,194 @@ export default function FamilyView() {
const navigate = useNavigate();
const { selectedFamily } = useStore();
const [categories, setCategories] = useState<Category[]>([]);
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showAddCategory, setShowAddCategory] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [newCategoryLimit, setNewCategoryLimit] = useState('');
const [showAddExpense, setShowAddExpense] = useState<number | null>(null);
const [expenseAmount, setExpenseAmount] = useState('');
const [expenseDescription, setExpenseDescription] = useState('');
const [showHistory, setShowHistory] = useState<number | null>(null);
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
const [showShoppingList, setShowShoppingList] = useState(false);
const [showInviteModal, setShowInviteModal] = useState(false);
const { categories, loading, createCategory, deleteCategory, resetLimit, loadCategories } = useCategories(
parseInt(familyId || '0')
);
const { confirmState, confirm, cancel } = useConfirm();
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!familyId) {
navigate('/');
return;
}
}, [familyId, navigate]);
loadCategories();
}, [familyId]);
const loadCategories = async () => {
if (!familyId) return;
try {
setLoading(true);
setError('');
console.log('Loading categories for family:', familyId);
const response = await categoryApi.getAllByFamily(parseInt(familyId));
console.log('Categories loaded:', response.data);
setCategories(response.data);
const limits = new Map<number, number>();
for (const category of response.data) {
const limitResponse = await expenseApi.getRemainingLimit(
parseInt(familyId),
category.id
);
const limitValue = typeof limitResponse.data.remaining_limit === 'string'
? parseFloat(limitResponse.data.remaining_limit)
: limitResponse.data.remaining_limit;
limits.set(category.id, limitValue);
}
setRemainingLimits(limits);
console.log('All data loaded successfully');
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || t('family.loadError');
setError(errorMsg);
console.error('Error loading categories:', err);
} finally {
setLoading(false);
}
};
const handleAddCategory = async () => {
if (!familyId || !newCategoryName || !newCategoryLimit) return;
try {
await categoryApi.create(parseInt(familyId), {
name: newCategoryName,
limit_amount: parseFloat(newCategoryLimit),
});
setNewCategoryName('');
setNewCategoryLimit('');
setShowAddCategory(false);
loadCategories();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || t('category.createError');
alert(`${t('category.createError')}: ${errorMsg}`);
console.error('Full error:', err);
}
};
const handleDeleteCategory = async (categoryId: number) => {
await confirm(t('category.deleteConfirm'), t('category.deleteMessage'));
await deleteCategory(categoryId);
if (!familyId) return;
if (!confirm(t('category.deleteConfirm'))) return;
try {
await categoryApi.delete(parseInt(familyId), categoryId);
loadCategories();
} catch (err) {
alert(t('category.deleteError'));
console.error(err);
}
};
const handleResetLimit = async (categoryId: number) => {
await confirm(t('category.resetConfirm'), t('category.resetMessage'));
const category = categories.find((cat) => cat.id === categoryId);
if (category) {
await resetLimit(categoryId, Number(category.limit_amount));
if (!familyId) return;
if (!confirm(t('category.resetConfirm'))) return;
try {
const expensesResponse = await expenseApi.getAllByCategory(
parseInt(familyId),
categoryId
);
for (const expense of expensesResponse.data) {
await expenseApi.delete(
parseInt(familyId),
categoryId,
expense.id
);
}
loadCategories();
} catch (err) {
alert(t('category.resetError'));
console.error(err);
}
};
const handleAddExpense = async (categoryId: number) => {
if (!familyId || !expenseAmount) return;
try {
await expenseApi.create(parseInt(familyId), categoryId, {
amount: parseFloat(expenseAmount),
description: expenseDescription || undefined,
});
setExpenseAmount('');
setExpenseDescription('');
setShowAddExpense(null);
loadCategories();
} catch (err) {
alert(t('expense.addError'));
console.error(err);
}
};
const handleShowHistory = async (categoryId: number) => {
if (!familyId) return;
if (showHistory === categoryId) {
setShowHistory(null);
return;
}
try {
const response = await expenseApi.getAllByCategory(
parseInt(familyId),
categoryId
);
setCategoryExpenses(response.data);
setShowHistory(categoryId);
} catch (err) {
alert(t('expense.historyError'));
console.error(err);
}
};
const handleCreateInviteLink = async () => {
try {
setInviteLoading(true);
const response = await inviteLinkApi.create({ expires_in_hours: 168 });
setInviteLink(response.data);
} catch (err) {
alert(t('invite.createError'));
console.error(err);
} finally {
setInviteLoading(false);
}
};
const handleCopyInviteLink = async () => {
if (!inviteLink) return;
try {
await navigator.clipboard.writeText(inviteLink.invite_url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleOpenInviteModal = () => {
setShowInviteModal(true);
setInviteLink(null);
setCopied(false);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center gradient-bg">
@@ -57,51 +230,428 @@ export default function FamilyView() {
);
}
const getProgressColor = (remaining: number, limit: number) => {
const percentage = (remaining / limit) * 100;
if (percentage > 50) return 'bg-green-500';
if (percentage > 25) return 'bg-yellow-500';
return 'bg-red-500';
};
const getProgressPercentage = (remaining: number, limit: number) => {
return Math.max(0, Math.min(100, (remaining / limit) * 100));
};
const getTotalLimit = () => {
return categories.reduce((sum, cat) => sum + parseFloat(cat.limit_amount.toString()), 0);
};
const getTotalRemaining = () => {
return Array.from(remainingLimits.values()).reduce((sum, val) => sum + val, 0);
};
const formatDate = (dateString: string) => {
let dateStr = dateString;
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
dateStr = dateStr + 'Z';
}
const date = new Date(dateStr);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
<div className="max-w-5xl mx-auto">
<FamilyHeader
onInvite={() => setShowInviteModal(true)}
onProfile={() => navigate('/profile')}
/>
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-6">
<button
onClick={handleOpenInviteModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
>
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">{t('family.inviteMember')}</span>
</button>
<button
onClick={() => navigate('/profile')}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
>
<User className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">{t('profile.title')}</span>
</button>
</div>
<div className="text-center">
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
<Wallet className="w-12 h-12 text-white" />
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-white mb-6">
{selectedFamily?.name || t('family.defaultName')}
</h1>
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center">
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalLimit')}</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
{getTotalLimit().toFixed(2)}
</p>
</div>
<div className="text-center border-l-2 border-gray-300">
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalRemaining')}</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
{getTotalRemaining().toFixed(2)}
</p>
</div>
</div>
<button
onClick={() => setShowShoppingList(true)}
className="w-full flex items-center justify-center gap-2 px-6 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<ShoppingCart className="w-5 h-5" />
{t('family.shoppingList')}
</button>
</div>
</div>
</div>
<FamilySummary
familyName={selectedFamily?.name}
categories={categories}
onShowShoppingList={() => setShowShoppingList(true)}
/>
{error && (
<div className="mb-6 p-4 bg-red-500/90 backdrop-blur-md border border-red-300/50 text-white rounded-2xl shadow-lg max-w-2xl mx-auto">
<div className="flex items-center gap-2">
<X className="w-5 h-5 flex-shrink-0" />
<span>{error}</span>
</div>
</div>
)}
<CategoryList
categories={categories}
familyId={parseInt(familyId || '0')}
onDelete={handleDeleteCategory}
onReset={handleResetLimit}
onUpdate={loadCategories}
/>
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
{categories.map((category) => {
const remaining = remainingLimits.get(category.id) || 0;
const limit = parseFloat(category.limit_amount.toString());
const percentage = getProgressPercentage(remaining, limit);
<AddCategorySection
showForm={showAddCategory}
onToggle={() => setShowAddCategory(!showAddCategory)}
onCreate={createCategory}
/>
return (
<div
key={category.id}
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
>
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="p-2 category-icon text-white rounded-xl shadow-lg">
<Tag className="w-6 h-6" />
</div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
{category.name}
</h2>
</div>
{showAddExpense !== category.id && (
<button
onClick={() => setShowAddExpense(category.id)}
className="flex items-center gap-2 px-4 py-2 btn-danger text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
>
<TrendingDown className="w-4 h-4" />
<span className="hidden sm:inline">{t('category.addExpense')}</span>
<span className="sm:hidden">{t('category.expense')}</span>
</button>
)}
</div>
<div className="space-y-3 mb-4">
<div className="flex justify-between items-baseline">
<span className="text-gray-600 font-medium text-sm">{t('category.remaining')}</span>
<span className="text-2xl sm:text-3xl font-bold text-gray-900">
{remaining.toFixed(2)}
</span>
</div>
<div className="flex justify-between items-baseline text-gray-500 text-sm">
<span>{t('category.limit')}</span>
<span className="text-base font-semibold">{limit.toFixed(2)} </span>
</div>
<div className="relative h-3 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${getProgressColor(remaining, limit)} transition-all duration-500 rounded-full shadow-inner`}
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 text-center font-medium">
{percentage.toFixed(0)}{t('category.percentRemaining')}
</p>
</div>
<div className="flex gap-2 justify-between">
<button
onClick={() => handleResetLimit(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<RotateCcw className="w-4 h-4" />
<span>{t('category.reset')}</span>
</button>
<button
onClick={() => handleShowHistory(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<History className="w-4 h-4" />
<span>{t('category.history')}</span>
</button>
<button
onClick={() => handleDeleteCategory(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<Trash2 className="w-4 h-4" />
<span>{t('common.delete')}</span>
</button>
</div>
{showHistory === category.id && (
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-gray-200">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800 text-lg flex items-center gap-2">
<History className="w-5 h-5" />
{t('expense.historyTitle')}
</h3>
<button
onClick={() => setShowHistory(null)}
className="p-2 hover:bg-white/50 rounded-xl transition-all"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
{categoryExpenses.length === 0 ? (
<p className="text-center text-gray-500 py-4">{t('expense.noExpenses')}</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{categoryExpenses.map((expense) => (
<div
key={expense.id}
className="expense-history-item p-3 rounded-xl shadow-sm border"
>
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-red-100 rounded-lg">
<TrendingDown className="w-4 h-4 text-red-600" />
</div>
<span className="font-bold text-gray-900 text-lg">
{parseFloat(expense.amount.toString()).toFixed(2)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
<span>{formatDate(expense.created_at)}</span>
</div>
</div>
{expense.description && (
<div className="flex items-start gap-2 text-sm text-gray-600 expense-description p-2 rounded-lg">
<MessageSquare className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{expense.description}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{showAddExpense === category.id && (
<div className="glass-effect p-6 rounded-2xl border-2 border-gray-200 mt-4">
<h3 className="font-semibold text-gray-800 mb-4 text-center">
{t('expense.addTitle')}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('expense.amount')}
</label>
<input
type="number"
placeholder="0.00"
value={expenseAmount}
onChange={(e) => setExpenseAmount(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all text-center font-semibold text-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('expense.description')}
</label>
<input
type="text"
placeholder={t('expense.descriptionPlaceholder')}
value={expenseDescription}
onChange={(e) => setExpenseDescription(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => handleAddExpense(category.id)}
className="flex-1 flex items-center justify-center gap-2 px-5 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Plus className="w-5 h-5" />
{t('common.add')}
</button>
<button
onClick={() => setShowAddExpense(null)}
className="px-5 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
<div className="flex items-center justify-center gap-3 mb-8">
<div className="p-3 category-icon rounded-2xl">
<DollarSign className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl sm:text-3xl font-bold text-gray-800">
{t('category.management')}
</h2>
</div>
{showAddCategory ? (
<div className="mb-8 p-6 glass-effect rounded-2xl border-2 border-gray-200">
<h3 className="font-bold text-gray-800 mb-5 text-center text-lg">
{t('category.newCategory')}
</h3>
<div className="space-y-4">
<input
type="text"
placeholder={t('category.categoryName')}
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium"
/>
<input
type="number"
placeholder={t('category.categoryLimit')}
value={newCategoryLimit}
onChange={(e) => setNewCategoryLimit(e.target.value)}
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium text-center"
/>
<div className="flex gap-3">
<button
onClick={handleAddCategory}
className="flex-1 flex items-center justify-center gap-2 px-6 py-4 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Plus className="w-5 h-5" />
{t('common.create')}
</button>
<button
onClick={() => setShowAddCategory(false)}
className="px-6 py-4 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
>
{t('common.cancel')}
</button>
</div>
</div>
</div>
) : (
<button
onClick={() => setShowAddCategory(true)}
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<Plus className="w-5 h-5" />
{t('category.addCategory')}
</button>
)}
</div>
</div>
{showShoppingList && (
{showShoppingList && familyId && (
<ShoppingListModal
familyId={parseInt(familyId || '0')}
familyId={parseInt(familyId)}
onClose={() => setShowShoppingList(false)}
/>
)}
{showInviteModal && <InviteModal onClose={() => setShowInviteModal(false)} />}
{showInviteModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass-effect rounded-3xl shadow-2xl w-full max-w-md p-6 sm:p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-3 category-icon rounded-2xl">
<UserPlus className="w-6 h-6 text-white" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('invite.title')}</h2>
</div>
<button
onClick={() => setShowInviteModal(false)}
className="p-2 hover:bg-gray-100 rounded-xl transition-all"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<ConfirmModal
isOpen={confirmState.isOpen}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={cancel}
/>
{!inviteLink ? (
<div className="text-center">
<p className="text-gray-600 mb-6">
{t('invite.description')}
</p>
<button
onClick={handleCreateInviteLink}
disabled={inviteLoading}
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all font-semibold disabled:opacity-50"
>
{inviteLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{t('invite.creating')}
</>
) : (
<>
<UserPlus className="w-5 h-5" />
{t('invite.createLink')}
</>
)}
</button>
</div>
) : (
<div>
<p className="text-gray-600 mb-4 text-center">
{t('invite.sendLink')}
</p>
<div className="bg-gray-100 rounded-2xl p-4 mb-4">
<p className="text-sm text-gray-800 break-all font-mono">
{inviteLink.invite_url}
</p>
</div>
<button
onClick={handleCopyInviteLink}
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-2xl transition-all font-semibold ${
copied
? 'btn-success text-white'
: 'btn-primary text-white hover:shadow-xl'
}`}
>
{copied ? (
<>
<Check className="w-5 h-5" />
{t('invite.copied')}
</>
) : (
<>
<Copy className="w-5 h-5" />
{t('invite.copyLink')}
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,375 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { familyApi, userApi, authApi } from '../api/client';
import { useStore } from '../store/useStore';
import type { Theme } from '../types';
import {
User as UserIcon,
Users,
Settings,
AlertTriangle,
ArrowLeft,
Loader2,
Check,
Palette,
Languages,
LogOut,
Edit3,
Save,
X,
} from 'lucide-react';
const THEMES: { id: Theme; gradient: string }[] = [
{ id: 'light', gradient: 'bg-gradient-to-r from-gray-100 to-gray-200' },
{ id: 'dark', gradient: 'bg-gradient-to-r from-black to-gray-900' },
{ id: 'sunset', gradient: 'bg-gradient-to-r from-orange-400 to-pink-500' },
{ id: 'ocean', gradient: 'bg-gradient-to-r from-blue-400 to-cyan-500' },
{ id: 'forest', gradient: 'bg-gradient-to-r from-green-400 to-teal-500' },
{ id: 'purple', gradient: 'bg-gradient-to-r from-purple-500 to-pink-500' },
];
export default function Profile() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences, familyMembers, setFamilyMembers } = useStore();
const [membersLoading, setMembersLoading] = useState(false);
const [leavingFamily, setLeavingFamily] = useState(false);
const [editingName, setEditingName] = useState(false);
const [newFamilyName, setNewFamilyName] = useState('');
const [savingName, setSavingName] = useState(false);
useEffect(() => {
if (user?.family_id) {
loadFamily();
}
}, [user?.family_id]);
useEffect(() => {
if (user?.family_id && selectedFamily) {
loadMembers();
}
}, [user?.family_id, selectedFamily]);
const loadFamily = async () => {
if (!user?.family_id) return;
try {
const response = await familyApi.getById(user.family_id);
setSelectedFamily(response.data);
} catch (err) {
console.error('Error loading family:', err);
}
};
const loadMembers = async () => {
if (!user?.family_id) return;
try {
setMembersLoading(true);
const response = await familyApi.getMembers(user.family_id);
console.log('Loaded members:', response.data);
setFamilyMembers(response.data);
} catch (err) {
console.error('Error loading members:', err);
} finally {
setMembersLoading(false);
}
};
const handleLeaveFamily = async () => {
if (!confirm(t('profile.leaveConfirm'))) return;
try {
setLeavingFamily(true);
await userApi.leaveFamily();
const meResponse = await authApi.me();
setUser(meResponse.data);
setSelectedFamily(null);
setFamilyMembers([]);
navigate('/');
} catch (err) {
console.error('Error leaving family:', err);
alert(t('profile.leaveError'));
} finally {
setLeavingFamily(false);
}
};
const handleThemeChange = (theme: Theme) => {
setPreferences({ theme });
document.documentElement.setAttribute('data-theme', theme);
};
const handleLocaleChange = (locale: 'ru' | 'en') => {
setPreferences({ locale });
i18n.changeLanguage(locale);
};
const handleStartEditName = () => {
setNewFamilyName(selectedFamily?.name || '');
setEditingName(true);
};
const handleSaveName = async () => {
if (!selectedFamily || !newFamilyName.trim()) return;
try {
setSavingName(true);
const response = await familyApi.update(selectedFamily.id, { name: newFamilyName.trim() });
setSelectedFamily(response.data);
setEditingName(false);
} catch (err) {
console.error('Error updating family name:', err);
alert(t('profile.renameError'));
} finally {
setSavingName(false);
}
};
const handleBack = () => {
if (user?.family_id) {
navigate(`/family/${user.family_id}`);
} else {
navigate('/');
}
};
return (
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
<div className="max-w-2xl mx-auto">
<button
onClick={handleBack}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300"
>
<ArrowLeft className="w-5 h-5" />
<span className="font-medium">{t('common.back')}</span>
</button>
<div className="text-center mb-8">
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
<UserIcon className="w-12 h-12 text-white" />
</div>
<h1 className="text-4xl font-bold text-white mb-2">{t('profile.title')}</h1>
</div>
<div className="space-y-6">
<div className="glass-effect rounded-2xl shadow-lg p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 category-icon text-white rounded-xl">
<UserIcon className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('profile.info')}</h2>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-gray-200">
<span className="text-gray-600">{t('profile.username')}</span>
<span className="font-medium text-gray-900">{user?.username || '-'}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-gray-200">
<span className="text-gray-600">{t('profile.email')}</span>
<span className="font-medium text-gray-900">{user?.email || '-'}</span>
</div>
</div>
</div>
{selectedFamily && (
<div className="glass-effect rounded-2xl shadow-lg p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 btn-success text-white rounded-xl">
<Users className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('profile.family')}</h2>
</div>
<div className="mb-4">
<div className="flex justify-between items-center py-2 border-b border-gray-200">
<span className="text-gray-600">{t('profile.familyName')}</span>
{editingName ? (
<div className="flex items-center gap-2">
<input
type="text"
value={newFamilyName}
onChange={(e) => setNewFamilyName(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200"
autoFocus
/>
<button
onClick={handleSaveName}
disabled={savingName}
className="p-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
>
{savingName ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
</button>
<button
onClick={() => setEditingName(false)}
className="p-1.5 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{selectedFamily.name}</span>
<button
onClick={handleStartEditName}
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
<Edit3 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
<div className="mb-2">
<h3 className="text-sm font-medium text-gray-600 mb-3">{t('profile.members')}</h3>
{membersLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
) : familyMembers.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
{t('profile.noMembers') || 'Нет участников'}
</div>
) : (
<div className="space-y-2">
{familyMembers.map((member) => (
<div
key={member.id}
className={`flex items-center justify-between p-3 rounded-xl ${member.id === user?.id ? 'member-current border' : 'bg-gray-50'}`}
>
<div className="flex items-center gap-2">
<div className="w-8 h-8 category-icon rounded-full flex items-center justify-center text-white text-sm font-medium">
{(member.username || member.email || '?')[0].toUpperCase()}
</div>
<span className="font-medium text-gray-800">
{member.username || member.email || t('profile.unknownUser')}
</span>
{member.id === user?.id && (
<span className="text-xs bg-purple-200 text-purple-700 px-2 py-0.5 rounded-full">
{t('profile.you')}
</span>
)}
</div>
{member.is_admin && (
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
Admin
</span>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
<div className="glass-effect rounded-2xl shadow-lg p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 btn-primary text-white rounded-xl">
<Settings className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('profile.settings')}</h2>
</div>
<div className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-3">
<Languages className="w-4 h-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-600">{t('profile.language')}</h3>
</div>
<div className="flex gap-3">
<button
onClick={() => handleLocaleChange('ru')}
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
preferences.locale === 'ru'
? 'btn-primary text-white shadow-lg'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<span className="mr-2">🇷🇺</span>
Русский
{preferences.locale === 'ru' && <Check className="w-4 h-4 inline ml-2" />}
</button>
<button
onClick={() => handleLocaleChange('en')}
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
preferences.locale === 'en'
? 'btn-primary text-white shadow-lg'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<span className="mr-2">🇬🇧</span>
English
{preferences.locale === 'en' && <Check className="w-4 h-4 inline ml-2" />}
</button>
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-3">
<Palette className="w-4 h-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-600">{t('profile.theme')}</h3>
</div>
<div className="grid grid-cols-3 gap-3">
{THEMES.map((theme) => (
<button
key={theme.id}
onClick={() => handleThemeChange(theme.id)}
className={`relative p-1 rounded-xl transition-all ${
preferences.theme === theme.id
? 'ring-2 ring-purple-500 ring-offset-2'
: 'hover:scale-105'
}`}
>
<div className={`h-12 rounded-lg ${theme.gradient}`} />
<span className="text-xs text-gray-600 mt-1 block">{t(`profile.themes.${theme.id}`)}</span>
{preferences.theme === theme.id && (
<div className="absolute top-2 right-2 w-5 h-5 bg-white rounded-full flex items-center justify-center shadow">
<Check className="w-3 h-3 text-purple-600" />
</div>
)}
</button>
))}
</div>
</div>
</div>
</div>
{selectedFamily && (
<div className="glass-effect rounded-2xl shadow-lg p-6 border-2 border-red-200">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 btn-danger text-white rounded-xl">
<AlertTriangle className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('profile.dangerZone')}</h2>
</div>
<p className="text-gray-600 mb-4">{t('profile.leaveDescription')}</p>
<button
onClick={handleLeaveFamily}
disabled={leavingFamily}
className="w-full flex items-center justify-center gap-2 px-6 py-3 btn-danger text-white rounded-xl transition-all font-semibold disabled:opacity-50"
>
{leavingFamily ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{t('profile.leaving')}
</>
) : (
<>
<LogOut className="w-5 h-5" />
{t('profile.leaveFamily')}
</>
)}
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,28 +1,44 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { User as UserIcon } from 'lucide-react';
import { familyApi, authApi } from '../api/client';
import { familyApi, userApi, authApi } from '../api/client';
import { useStore } from '../store/useStore';
import { useFamilyMembers, useConfirm } from '../hooks';
import { Theme } from '../types';
import { ProfileHeader } from '../components/profile/ProfileHeader';
import { UserInfo } from '../components/profile/UserInfo';
import { FamilySection } from '../components/profile/FamilySection';
import { MembersSection } from '../components/profile/MembersSection';
import { SettingsSection } from '../components/profile/SettingsSection';
import { ConfirmModal, Card } from '../components/ui';
import { showToast } from '../utils/toast';
import { showErrorToast } from '../utils/errorHandler';
import type { Theme } from '../types';
import {
User as UserIcon,
Users,
Settings,
AlertTriangle,
ArrowLeft,
Loader2,
Check,
Palette,
Languages,
LogOut,
Edit3,
Save,
X,
} from 'lucide-react';
const THEMES: { id: Theme; gradient: string }[] = [
{ id: 'light', gradient: 'bg-gradient-to-r from-gray-100 to-gray-200' },
{ id: 'dark', gradient: 'bg-gradient-to-r from-black to-gray-900' },
{ id: 'sunset', gradient: 'bg-gradient-to-r from-orange-400 to-pink-500' },
{ id: 'ocean', gradient: 'bg-gradient-to-r from-blue-400 to-cyan-500' },
{ id: 'forest', gradient: 'bg-gradient-to-r from-green-400 to-teal-500' },
{ id: 'purple', gradient: 'bg-gradient-to-r from-purple-500 to-pink-500' },
];
export default function Profile() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences } = useStore();
const { members, loading: membersLoading, loadMembers } = useFamilyMembers(user?.family_id || null);
const { confirmState, confirm, cancel } = useConfirm();
const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences, familyMembers, setFamilyMembers } = useStore();
const [membersLoading, setMembersLoading] = useState(false);
const [leavingFamily, setLeavingFamily] = useState(false);
const [editingName, setEditingName] = useState(false);
const [newFamilyName, setNewFamilyName] = useState('');
const [savingName, setSavingName] = useState(false);
useEffect(() => {
if (user?.family_id) {
@@ -30,6 +46,12 @@ export default function Profile() {
}
}, [user?.family_id]);
useEffect(() => {
if (user?.family_id && selectedFamily) {
loadMembers();
}
}, [user?.family_id, selectedFamily]);
const loadFamily = async () => {
if (!user?.family_id) return;
try {
@@ -40,53 +62,90 @@ export default function Profile() {
}
};
const loadMembers = async () => {
if (!user?.family_id) return;
try {
setMembersLoading(true);
const response = await familyApi.getMembers(user.family_id);
console.log('Loaded members:', response.data);
setFamilyMembers(response.data);
} catch (err) {
console.error('Error loading members:', err);
} finally {
setMembersLoading(false);
}
};
const handleLeaveFamily = async () => {
await confirm(t('profile.leaveConfirm'), t('profile.leaveMessage'));
if (!confirm(t('profile.leaveConfirm'))) return;
try {
setLeavingFamily(true);
const { userApi } = await import('../api/client');
await userApi.leaveFamily();
const meResponse = await authApi.me();
setUser(meResponse.data);
setSelectedFamily(null);
setFamilyMembers([]);
showToast.success(t('profile.leftFamily'));
navigate('/');
} catch (error) {
showErrorToast(error);
} catch (err) {
console.error('Error leaving family:', err);
alert(t('profile.leaveError'));
} finally {
setLeavingFamily(false);
}
};
const handleLogout = async () => {
const handleThemeChange = (theme: Theme) => {
setPreferences({ theme });
document.documentElement.setAttribute('data-theme', theme);
};
const handleLocaleChange = (locale: 'ru' | 'en') => {
setPreferences({ locale });
i18n.changeLanguage(locale);
};
const handleStartEditName = () => {
setNewFamilyName(selectedFamily?.name || '');
setEditingName(true);
};
const handleSaveName = async () => {
if (!selectedFamily || !newFamilyName.trim()) return;
try {
await authApi.logout();
setUser(null);
setSelectedFamily(null);
navigate('/login');
} catch (error) {
showErrorToast(error);
setSavingName(true);
const response = await familyApi.update(selectedFamily.id, { name: newFamilyName.trim() });
setSelectedFamily(response.data);
setEditingName(false);
} catch (err) {
console.error('Error updating family name:', err);
alert(t('profile.renameError'));
} finally {
setSavingName(false);
}
};
const handleThemeChange = (theme: Theme) => {
setPreferences({ ...preferences, theme });
showToast.success(t('profile.themeChanged'));
};
const handleLocaleChange = (locale: string) => {
i18n.changeLanguage(locale);
setPreferences({ ...preferences, locale: locale as 'ru' | 'en' });
showToast.success(t('profile.languageChanged'));
const handleBack = () => {
if (user?.family_id) {
navigate(`/family/${user.family_id}`);
} else {
navigate('/');
}
};
return (
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
<div className="max-w-4xl mx-auto">
<ProfileHeader onBack={() => navigate(user?.family_id ? `/family/${user.family_id}` : '/')} />
<div className="max-w-2xl mx-auto">
<button
onClick={handleBack}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300"
>
<ArrowLeft className="w-5 h-5" />
<span className="font-medium">{t('common.back')}</span>
</button>
<div className="text-center mb-8">
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
@@ -96,63 +155,221 @@ export default function Profile() {
</div>
<div className="space-y-6">
<Card>
<div className="glass-effect rounded-2xl shadow-lg p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl">
<div className="p-2 category-icon text-white rounded-xl">
<UserIcon className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800 dark:text-white">{t('profile.info')}</h2>
<h2 className="text-xl font-bold text-gray-800">{t('profile.info')}</h2>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-gray-200 dark:border-gray-700">
<span className="text-gray-600 dark:text-gray-400">{t('profile.username')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{user?.username || '-'}
</span>
<div className="flex justify-between items-center py-2 border-b border-gray-200">
<span className="text-gray-600">{t('profile.username')}</span>
<span className="font-medium text-gray-900">{user?.username || '-'}</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-gray-600 dark:text-gray-400">{t('profile.email')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{user?.email || '-'}
</span>
<div className="flex justify-between items-center py-2 border-b border-gray-200">
<span className="text-gray-600">{t('profile.email')}</span>
<span className="font-medium text-gray-900">{user?.email || '-'}</span>
</div>
</div>
</Card>
</div>
{selectedFamily && (
<Card>
<FamilySection
family={selectedFamily}
onLeaveFamily={handleLeaveFamily}
onFamilyUpdate={loadFamily}
leavingFamily={leavingFamily}
/>
<div className="mt-4">
<MembersSection
members={members}
currentUser={user}
loading={membersLoading}
/>
<div className="glass-effect rounded-2xl shadow-lg p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 btn-success text-white rounded-xl">
<Users className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('profile.family')}</h2>
</div>
</Card>
<div className="mb-4">
<div className="flex justify-between items-center py-2 border-b border-gray-200">
<span className="text-gray-600">{t('profile.familyName')}</span>
{editingName ? (
<div className="flex items-center gap-2">
<input
type="text"
value={newFamilyName}
onChange={(e) => setNewFamilyName(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200"
autoFocus
/>
<button
onClick={handleSaveName}
disabled={savingName}
className="p-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
>
{savingName ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
</button>
<button
onClick={() => setEditingName(false)}
className="p-1.5 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{selectedFamily.name}</span>
<button
onClick={handleStartEditName}
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
<Edit3 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
<div className="mb-2">
<h3 className="text-sm font-medium text-gray-600 mb-3">{t('profile.members')}</h3>
{membersLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
) : familyMembers.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
{t('profile.noMembers') || 'Нет участников'}
</div>
) : (
<div className="space-y-2">
{familyMembers.map((member) => (
<div
key={member.id}
className={`flex items-center justify-between p-3 rounded-xl ${member.id === user?.id ? 'member-current border' : 'bg-gray-50'}`}
>
<div className="flex items-center gap-2">
<div className="w-8 h-8 category-icon rounded-full flex items-center justify-center text-white text-sm font-medium">
{(member.username || member.email || '?')[0].toUpperCase()}
</div>
<span className="font-medium text-gray-800">
{member.username || member.email || t('profile.unknownUser')}
</span>
{member.id === user?.id && (
<span className="text-xs bg-purple-200 text-purple-700 px-2 py-0.5 rounded-full">
{t('profile.you')}
</span>
)}
</div>
{member.is_admin && (
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
Admin
</span>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
<SettingsSection
currentTheme={preferences.theme}
currentLanguage={preferences.locale}
onThemeChange={handleThemeChange}
onLanguageChange={handleLocaleChange}
/>
<div className="glass-effect rounded-2xl shadow-lg p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 btn-primary text-white rounded-xl">
<Settings className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('profile.settings')}</h2>
</div>
<div className="space-y-6">
<div>
<div className="flex items-center gap-2 mb-3">
<Languages className="w-4 h-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-600">{t('profile.language')}</h3>
</div>
<div className="flex gap-3">
<button
onClick={() => handleLocaleChange('ru')}
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
preferences.locale === 'ru'
? 'btn-primary text-white shadow-lg'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<span className="mr-2">🇷🇺</span>
Русский
{preferences.locale === 'ru' && <Check className="w-4 h-4 inline ml-2" />}
</button>
<button
onClick={() => handleLocaleChange('en')}
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
preferences.locale === 'en'
? 'btn-primary text-white shadow-lg'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<span className="mr-2">🇬🇧</span>
English
{preferences.locale === 'en' && <Check className="w-4 h-4 inline ml-2" />}
</button>
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-3">
<Palette className="w-4 h-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-600">{t('profile.theme')}</h3>
</div>
<div className="grid grid-cols-3 gap-3">
{THEMES.map((theme) => (
<button
key={theme.id}
onClick={() => handleThemeChange(theme.id)}
className={`relative p-1 rounded-xl transition-all ${
preferences.theme === theme.id
? 'ring-2 ring-purple-500 ring-offset-2'
: 'hover:scale-105'
}`}
>
<div className={`h-12 rounded-lg ${theme.gradient}`} />
<span className="text-xs text-gray-600 mt-1 block">{t(`profile.themes.${theme.id}`)}</span>
{preferences.theme === theme.id && (
<div className="absolute top-2 right-2 w-5 h-5 bg-white rounded-full flex items-center justify-center shadow">
<Check className="w-3 h-3 text-purple-600" />
</div>
)}
</button>
))}
</div>
</div>
</div>
</div>
{selectedFamily && (
<div className="glass-effect rounded-2xl shadow-lg p-6 border-2 border-red-200">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 btn-danger text-white rounded-xl">
<AlertTriangle className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('profile.dangerZone')}</h2>
</div>
<p className="text-gray-600 mb-4">{t('profile.leaveDescription')}</p>
<button
onClick={handleLeaveFamily}
disabled={leavingFamily}
className="w-full flex items-center justify-center gap-2 px-6 py-3 btn-danger text-white rounded-xl transition-all font-semibold disabled:opacity-50"
>
{leavingFamily ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{t('profile.leaving')}
</>
) : (
<>
<LogOut className="w-5 h-5" />
{t('profile.leaveFamily')}
</>
)}
</button>
</div>
)}
</div>
</div>
<ConfirmModal
isOpen={confirmState.isOpen}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={cancel}
/>
</div>
);
}

View File

@@ -1,102 +0,0 @@
import { categoryApi, expenseApi } from '../api/client';
import { Category, CreateCategoryRequest, RemainingLimit } from '../types';
import { handleApiError } from '../utils/errorHandler';
export interface CategoryWithRemaining extends Category {
remaining_limit: number;
}
export const categoryService = {
async getAllByFamily(familyId: number): Promise<CategoryWithRemaining[]> {
try {
const categoriesRes = await categoryApi.getAllByFamily(familyId);
const categories = categoriesRes.data;
const categoriesWithRemaining = await Promise.all(
categories.map(async (category) => {
try {
const remainingRes = await expenseApi.getRemainingLimit(familyId, category.id);
return {
...category,
remaining_limit: Number(remainingRes.data.remaining_limit),
};
} catch {
return {
...category,
remaining_limit: Number(category.limit_amount),
};
}
})
);
return categoriesWithRemaining;
} catch (error) {
handleApiError(error);
}
},
async getById(familyId: number, categoryId: number): Promise<CategoryWithRemaining> {
try {
const [categoryRes, remainingRes] = await Promise.all([
categoryApi.getById(familyId, categoryId),
expenseApi.getRemainingLimit(familyId, categoryId),
]);
return {
...categoryRes.data,
remaining_limit: Number(remainingRes.data.remaining_limit),
};
} catch (error) {
handleApiError(error);
}
},
async create(familyId: number, data: CreateCategoryRequest): Promise<Category> {
try {
const res = await categoryApi.create(familyId, data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async update(familyId: number, categoryId: number, data: Partial<CreateCategoryRequest>): Promise<Category> {
try {
const res = await categoryApi.update(familyId, categoryId, data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async delete(familyId: number, categoryId: number): Promise<void> {
try {
await categoryApi.delete(familyId, categoryId);
} catch (error) {
handleApiError(error);
}
},
async resetLimit(familyId: number, categoryId: number, newLimit: number): Promise<Category> {
try {
const res = await categoryApi.resetLimit(familyId, categoryId, newLimit);
return res.data;
} catch (error) {
handleApiError(error);
}
},
calculateProgress(limitAmount: number | string, remainingLimit: number): number {
const limit = Number(limitAmount);
const remaining = Number(remainingLimit);
if (limit === 0) return 0;
const spent = limit - remaining;
return Math.min(100, Math.max(0, (spent / limit) * 100));
},
getProgressColor(progress: number): string {
if (progress >= 90) return 'danger';
if (progress >= 70) return 'warning';
return 'success';
},
};

View File

@@ -1,71 +0,0 @@
import { expenseApi } from '../api/client';
import { Expense, CreateExpenseRequest } from '../types';
import { handleApiError } from '../utils/errorHandler';
export const expenseService = {
async getAllByCategory(familyId: number, categoryId: number): Promise<Expense[]> {
try {
const res = await expenseApi.getAllByCategory(familyId, categoryId);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async getById(familyId: number, categoryId: number, expenseId: number): Promise<Expense> {
try {
const res = await expenseApi.getById(familyId, categoryId, expenseId);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async create(familyId: number, categoryId: number, data: CreateExpenseRequest): Promise<Expense> {
try {
const res = await expenseApi.create(familyId, categoryId, data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async update(familyId: number, categoryId: number, expenseId: number, data: Partial<CreateExpenseRequest>): Promise<Expense> {
try {
const res = await expenseApi.update(familyId, categoryId, expenseId, data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async delete(familyId: number, categoryId: number, expenseId: number): Promise<void> {
try {
await expenseApi.delete(familyId, categoryId, expenseId);
} catch (error) {
handleApiError(error);
}
},
formatAmount(amount: number | string): string {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(num);
},
sortByDate(expenses: Expense[], order: 'asc' | 'desc' = 'desc'): Expense[] {
return [...expenses].sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return order === 'desc' ? dateB - dateA : dateA - dateB;
});
},
getTotalAmount(expenses: Expense[]): number {
return expenses.reduce((sum, expense) => sum + Number(expense.amount), 0);
},
};

View File

@@ -1,84 +0,0 @@
import { familyApi } from '../api/client';
import { Family, CreateFamilyRequest, CreateMyFamilyRequest, CreateMyFamilyResponse, VerifyFamilyPasswordRequest, FamilyMember } from '../types';
import { handleApiError } from '../utils/errorHandler';
export const familyService = {
async getAll(): Promise<Family[]> {
try {
const res = await familyApi.getAll();
return res.data;
} catch (error) {
handleApiError(error);
}
},
async getById(id: number): Promise<Family> {
try {
const res = await familyApi.getById(id);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async create(data: CreateFamilyRequest): Promise<Family> {
try {
const res = await familyApi.create(data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async createMyFamily(data: CreateMyFamilyRequest): Promise<CreateMyFamilyResponse> {
try {
const res = await familyApi.createMyFamily(data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async update(id: number, data: { name: string }): Promise<Family> {
try {
const res = await familyApi.update(id, data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async delete(id: number): Promise<void> {
try {
await familyApi.delete(id);
} catch (error) {
handleApiError(error);
}
},
async verifyPassword(id: number, data: VerifyFamilyPasswordRequest): Promise<boolean> {
try {
const res = await familyApi.verifyPassword(id, data);
return res.data.valid;
} catch (error) {
handleApiError(error);
}
},
async getMembers(familyId: number): Promise<FamilyMember[]> {
try {
const res = await familyApi.getMembers(familyId);
return res.data;
} catch (error) {
handleApiError(error);
}
},
formatMemberName(member: FamilyMember): string {
return member.username || member.email || 'Unknown User';
},
countAdmins(members: FamilyMember[]): number {
return members.filter((m) => m.is_admin).length;
},
};

View File

@@ -1,7 +0,0 @@
export { categoryService } from './categoryService';
export { expenseService } from './expenseService';
export { familyService } from './familyService';
export { shoppingService } from './shoppingService';
export { inviteService } from './inviteService';
export type { CategoryWithRemaining } from './categoryService';

View File

@@ -1,75 +0,0 @@
import { inviteLinkApi } from '../api/client';
import { CreateInviteLinkRequest, InviteLinkResponse, ValidateInviteResponse, JoinFamilyResponse } from '../types';
import { handleApiError } from '../utils/errorHandler';
export const inviteService = {
async create(data: CreateInviteLinkRequest): Promise<InviteLinkResponse> {
try {
const res = await inviteLinkApi.create(data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async getMyLinks(): Promise<InviteLinkResponse[]> {
try {
const res = await inviteLinkApi.getMyLinks();
return res.data;
} catch (error) {
handleApiError(error);
}
},
async delete(token: string): Promise<void> {
try {
await inviteLinkApi.delete(token);
} catch (error) {
handleApiError(error);
}
},
async validate(token: string): Promise<ValidateInviteResponse> {
try {
const res = await inviteLinkApi.validate(token);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async join(token: string): Promise<JoinFamilyResponse> {
try {
const res = await inviteLinkApi.join(token);
return res.data;
} catch (error) {
handleApiError(error);
}
},
isExpired(expiresAt: string | null): boolean {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
},
isMaxUsesReached(link: InviteLinkResponse): boolean {
if (link.max_uses === null) return false;
return link.uses_count >= link.max_uses;
},
isActive(link: InviteLinkResponse): boolean {
return !this.isExpired(link.expires_at) && !this.isMaxUsesReached(link);
},
formatExpiresAt(expiresAt: string | null): string {
if (!expiresAt) return 'Never';
const date = new Date(expiresAt);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
},
};

View File

@@ -1,91 +0,0 @@
import { shoppingItemApi } from '../api/client';
import { ShoppingItem, CreateShoppingItemRequest, UpdateShoppingItemRequest, MarkAsPurchasedRequest } from '../types';
import { handleApiError } from '../utils/errorHandler';
export const shoppingService = {
async getAllByFamily(familyId: number): Promise<ShoppingItem[]> {
try {
const res = await shoppingItemApi.getAllByFamily(familyId);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async getById(familyId: number, itemId: number): Promise<ShoppingItem> {
try {
const res = await shoppingItemApi.getById(familyId, itemId);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async create(familyId: number, data: CreateShoppingItemRequest): Promise<ShoppingItem> {
try {
const res = await shoppingItemApi.create(familyId, data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async update(familyId: number, itemId: number, data: UpdateShoppingItemRequest): Promise<ShoppingItem> {
try {
const res = await shoppingItemApi.update(familyId, itemId, data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async delete(familyId: number, itemId: number): Promise<void> {
try {
await shoppingItemApi.delete(familyId, itemId);
} catch (error) {
handleApiError(error);
}
},
async markAsPurchased(familyId: number, itemId: number, isPurchased: boolean): Promise<ShoppingItem> {
try {
const data: MarkAsPurchasedRequest = { is_purchased: isPurchased };
const res = await shoppingItemApi.markAsPurchased(familyId, itemId, data);
return res.data;
} catch (error) {
handleApiError(error);
}
},
async markAllAsPurchased(familyId: number): Promise<number> {
try {
const res = await shoppingItemApi.markAllAsPurchased(familyId);
return res.data.affected_rows;
} catch (error) {
handleApiError(error);
}
},
async clearAll(familyId: number): Promise<number> {
try {
const res = await shoppingItemApi.clearAll(familyId);
return res.data.affected_rows;
} catch (error) {
handleApiError(error);
}
},
sortItems(items: ShoppingItem[]): { pending: ShoppingItem[]; purchased: ShoppingItem[] } {
const pending = items.filter((item) => !item.is_purchased);
const purchased = items.filter((item) => item.is_purchased);
return { pending, purchased };
},
getStats(items: ShoppingItem[]): { total: number; purchased: number; pending: number; progress: number } {
const total = items.length;
const purchased = items.filter((item) => item.is_purchased).length;
const pending = total - purchased;
const progress = total > 0 ? (purchased / total) * 100 : 0;
return { total, purchased, pending, progress };
},
};

View File

@@ -7,18 +7,6 @@ const getStoredPreferences = () => {
return { theme, locale };
};
interface CacheEntry<T> {
data: T;
timestamp: number;
}
interface CacheState {
categories: Map<number, CacheEntry<Category[]>>;
members: Map<number, CacheEntry<FamilyMember[]>>;
}
const CACHE_TTL = 5 * 60 * 1000;
interface AppState {
user: User | null;
isAuthenticated: boolean;
@@ -28,7 +16,6 @@ interface AppState {
categories: Category[];
familyMembers: FamilyMember[];
preferences: { theme: Theme; locale: Locale };
cache: CacheState;
setUser: (user: User | null) => void;
setIsLoading: (loading: boolean) => void;
@@ -38,15 +25,9 @@ interface AppState {
setFamilyMembers: (members: FamilyMember[]) => void;
setPreferences: (prefs: Partial<{ theme: Theme; locale: Locale }>) => void;
logout: () => void;
getCachedCategories: (familyId: number) => Category[] | null;
setCachedCategories: (familyId: number, categories: Category[]) => void;
getCachedMembers: (familyId: number) => FamilyMember[] | null;
setCachedMembers: (familyId: number, members: FamilyMember[]) => void;
clearCache: () => void;
}
export const useStore = create<AppState>((set, get) => ({
export const useStore = create<AppState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
@@ -55,10 +36,6 @@ export const useStore = create<AppState>((set, get) => ({
categories: [],
familyMembers: [],
preferences: getStoredPreferences(),
cache: {
categories: new Map(),
members: new Map(),
},
setUser: (user) => set({ user, isAuthenticated: !!user }),
@@ -79,59 +56,6 @@ export const useStore = create<AppState>((set, get) => ({
return { preferences: newPrefs };
}),
getCachedCategories: (familyId: number) => {
const cached = get().cache.categories.get(familyId);
if (!cached) return null;
if (Date.now() - cached.timestamp > CACHE_TTL) {
set((state) => {
const newCache = { ...state.cache };
newCache.categories.delete(familyId);
return { cache: newCache };
});
return null;
}
return cached.data;
},
setCachedCategories: (familyId: number, categories: Category[]) => {
set((state) => {
const newCache = { ...state.cache };
newCache.categories.set(familyId, { data: categories, timestamp: Date.now() });
return { cache: newCache };
});
},
getCachedMembers: (familyId: number) => {
const cached = get().cache.members.get(familyId);
if (!cached) return null;
if (Date.now() - cached.timestamp > CACHE_TTL) {
set((state) => {
const newCache = { ...state.cache };
newCache.members.delete(familyId);
return { cache: newCache };
});
return null;
}
return cached.data;
},
setCachedMembers: (familyId: number, members: FamilyMember[]) => {
set((state) => {
const newCache = { ...state.cache };
newCache.members.set(familyId, { data: members, timestamp: Date.now() });
return { cache: newCache };
});
},
clearCache: () => {
set((state) => ({
cache: {
categories: new Map(),
members: new Map(),
},
}));
},
logout: () => set({
user: null,
isAuthenticated: false,
@@ -139,9 +63,5 @@ export const useStore = create<AppState>((set, get) => ({
families: [],
categories: [],
familyMembers: [],
cache: {
categories: new Map(),
members: new Map(),
},
}),
}));

View File

@@ -1,39 +0,0 @@
export interface ApiError {
message: string;
status?: number;
code?: string;
}
export interface ValidationError extends ApiError {
field?: string;
errors?: Record<string, string[]>;
}
export class AppError extends Error {
status?: number;
code?: string;
constructor(message: string, status?: number, code?: string) {
super(message);
this.name = 'AppError';
this.status = status;
this.code = code;
}
}
export function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as ApiError).message === 'string'
);
}
export function isValidationError(error: unknown): error is ValidationError {
return (
isApiError(error) &&
'field' in error &&
typeof (error as ValidationError).field === 'string'
);
}

View File

@@ -1,53 +0,0 @@
import { AxiosError } from 'axios';
import { ApiError, AppError } from '../types/errors';
import { showToast } from './toast';
export function handleApiError(error: unknown): never {
if (error instanceof AppError) {
throw error;
}
if (error instanceof AxiosError) {
const status = error.response?.status;
const message = error.response?.data?.message || error.message;
const code = error.response?.data?.code;
throw new AppError(message, status, code);
}
if (error instanceof Error) {
throw new AppError(error.message);
}
throw new AppError('Unknown error occurred');
}
export function showErrorToast(error: unknown): void {
let message = 'An unexpected error occurred';
if (error instanceof AppError) {
message = error.message;
} else if (error instanceof AxiosError) {
message = error.response?.data?.message || error.message;
} else if (error instanceof Error) {
message = error.message;
}
showToast.error(message);
}
export function getErrorMessage(error: unknown): string {
if (error instanceof AppError) {
return error.message;
}
if (error instanceof AxiosError) {
return error.response?.data?.message || error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'An unexpected error occurred';
}

View File

@@ -1,35 +0,0 @@
export const format = {
currency(amount: number | string, locale: string = 'ru-RU', currency: string = 'RUB'): string {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(num);
},
date(dateString: string, locale: string = 'ru-RU'): string {
let dateStr = dateString;
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
dateStr = dateStr + 'Z';
}
const date = new Date(dateStr);
return date.toLocaleString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
},
percentage(value: number, decimals: number = 0): string {
return `${value.toFixed(decimals)}%`;
},
number(value: number | string, locale: string = 'ru-RU'): string {
const num = typeof value === 'string' ? parseFloat(value) : value;
return new Intl.NumberFormat(locale).format(num);
},
};

View File

@@ -1,40 +0,0 @@
export const progress = {
calculate(current: number, total: number): number {
if (total === 0) return 0;
return Math.min(100, Math.max(0, (current / total) * 100));
},
calculateRemaining(limit: number, spent: number): number {
return Math.max(0, limit - spent);
},
calculatePercentageRemaining(limit: number, remaining: number): number {
if (limit === 0) return 0;
return Math.min(100, Math.max(0, (remaining / limit) * 100));
},
getColorClass(percentage: number): string {
if (percentage >= 90) return 'red';
if (percentage >= 70) return 'yellow';
if (percentage >= 50) return 'orange';
return 'green';
},
getVariantFromPercentage(percentage: number): 'success' | 'warning' | 'danger' {
if (percentage >= 90) return 'danger';
if (percentage >= 70) return 'warning';
return 'success';
},
isLow(percentage: number): boolean {
return percentage < 25;
},
isMedium(percentage: number): boolean {
return percentage >= 25 && percentage < 75;
},
isHigh(percentage: number): boolean {
return percentage >= 75;
},
};

View File

@@ -1,64 +0,0 @@
import toast from 'react-hot-toast';
export const showToast = {
success: (message: string) => {
toast.success(message, {
duration: 3000,
position: 'top-right',
style: {
background: 'var(--toast-bg, #10b981)',
color: 'var(--toast-text, #ffffff)',
},
});
},
error: (message: string) => {
toast.error(message, {
duration: 4000,
position: 'top-right',
style: {
background: 'var(--toast-error-bg, #ef4444)',
color: 'var(--toast-text, #ffffff)',
},
});
},
loading: (message: string) => {
return toast.loading(message, {
position: 'top-right',
style: {
background: 'var(--toast-bg, #3b82f6)',
color: 'var(--toast-text, #ffffff)',
},
});
},
dismiss: (toastId?: string) => {
toast.dismiss(toastId);
},
promise: <T,>(
promise: Promise<T>,
messages: {
loading: string;
success: string;
error: string;
}
) => {
return toast.promise(
promise,
{
loading: messages.loading,
success: messages.success,
error: messages.error,
},
{
position: 'top-right',
style: {
background: 'var(--toast-bg)',
color: 'var(--toast-text)',
},
}
);
},
};

View File

@@ -1,52 +0,0 @@
export const validation = {
isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
},
isValidAmount(amount: string | number): boolean {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
return !isNaN(num) && num > 0;
},
isNonEmpty(value: string): boolean {
return value.trim().length > 0;
},
minLength(value: string, min: number): boolean {
return value.trim().length >= min;
},
maxLength(value: string, max: number): boolean {
return value.trim().length <= max;
},
isPositiveNumber(value: string | number): boolean {
const num = typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(num) && num > 0;
},
isNonNegativeNumber(value: string | number): boolean {
const num = typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(num) && num >= 0;
},
validateForm<T extends Record<string, any>>(
values: T,
rules: Partial<Record<keyof T, (value: any) => string | null>>
): Partial<Record<keyof T, string>> {
const errors: Partial<Record<keyof T, string>> = {};
for (const field in rules) {
const validator = rules[field];
if (validator) {
const error = validator(values[field]);
if (error) {
errors[field] = error;
}
}
}
return errors;
},
};