v1.0.3
add multi lang
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<title>KtoCo - Wydatki wspólne</title>
|
||||
<title>WhoWhat - Shared Expenses</title>
|
||||
<link rel="icon" href="/icons/icon-192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('ktoco_theme');
|
||||
var t = localStorage.getItem('whowhat_theme');
|
||||
if (t === 'light' || t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ktoco-frontend",
|
||||
"name": "whowhat-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from './auth/AuthContext.jsx';
|
||||
import { useHouseholdContext } from './household/HouseholdContext.jsx';
|
||||
import { useTranslation } from './i18n/I18nContext.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Register from './pages/Register.jsx';
|
||||
import ForgotPassword from './pages/ForgotPassword.jsx';
|
||||
@@ -26,7 +27,8 @@ function RequireAuth() {
|
||||
|
||||
function RequireHousehold() {
|
||||
const { households, householdsLoading } = useHouseholdContext();
|
||||
if (householdsLoading) return <div className="page-loading">Ładowanie…</div>;
|
||||
const { t } = useTranslation();
|
||||
if (householdsLoading) return <div className="page-loading">{t('app.loading')}</div>;
|
||||
if (households.length === 0) return <Navigate to="/onboarding" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const TOKEN_KEY = 'ktoco_token';
|
||||
const ACTIVE_HOUSEHOLD_KEY = 'ktoco_active_household';
|
||||
const TOKEN_KEY = 'whowhat_token';
|
||||
const ACTIVE_HOUSEHOLD_KEY = 'whowhat_active_household';
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
@@ -15,7 +15,7 @@ export function setToken(token) {
|
||||
// misreading "no household" and bouncing to /onboarding.
|
||||
export function clearInvalidSession() {
|
||||
setToken(null);
|
||||
window.dispatchEvent(new Event('ktoco:auth-invalid'));
|
||||
window.dispatchEvent(new Event('whowhat:auth-invalid'));
|
||||
}
|
||||
|
||||
export function getActiveHouseholdId() {
|
||||
@@ -60,7 +60,7 @@ export async function apiFetch(path, options = {}) {
|
||||
const data = isJson ? await res.json() : await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(isJson ? data.error || 'Wystąpił błąd' : data, res.status);
|
||||
throw new ApiError(isJson ? data.error || 'generic' : data, res.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -200,6 +200,14 @@ export function useUpdateNotifications() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccountLanguage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (language) => api.put('/auth/me/language', { language }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangePassword() {
|
||||
return useMutation({
|
||||
mutationFn: ({ currentPassword, newPassword }) =>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { api, getToken, setToken } from '../api/client.js';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
const USER_KEY = 'ktoco_user';
|
||||
const USER_KEY = 'whowhat_user';
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const qc = useQueryClient();
|
||||
const { language, setLanguage } = useTranslation();
|
||||
const [user, setUser] = useState(() => {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
@@ -24,19 +26,22 @@ export function AuthProvider({ children }) {
|
||||
const data = await api.post('/auth/login', { email, password });
|
||||
setToken(data.token);
|
||||
persistUser(data.user);
|
||||
if (data.user.language) setLanguage(data.user.language);
|
||||
return data.user;
|
||||
},
|
||||
[persistUser]
|
||||
[persistUser, setLanguage]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (email, password, name) => {
|
||||
const data = await api.post('/auth/register', { email, password, name });
|
||||
// The account is created with whatever language the visitor is currently browsing
|
||||
// in, so e.g. reset-password emails match what they saw at signup.
|
||||
const data = await api.post('/auth/register', { email, password, name, language });
|
||||
setToken(data.token);
|
||||
persistUser(data.user);
|
||||
return data.user;
|
||||
},
|
||||
[persistUser]
|
||||
[persistUser, language]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
@@ -56,8 +61,8 @@ export function AuthProvider({ children }) {
|
||||
persistUser(null);
|
||||
qc.clear();
|
||||
}
|
||||
window.addEventListener('ktoco:auth-invalid', handleAuthInvalid);
|
||||
return () => window.removeEventListener('ktoco:auth-invalid', handleAuthInvalid);
|
||||
window.addEventListener('whowhat:auth-invalid', handleAuthInvalid);
|
||||
return () => window.removeEventListener('whowhat:auth-invalid', handleAuthInvalid);
|
||||
}, [persistUser, qc]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
const items = [
|
||||
{ to: '/', label: 'Start', icon: 'home', end: true },
|
||||
{ to: '/history', label: 'Historia', icon: 'receipt_long' },
|
||||
];
|
||||
|
||||
const rightItems = [
|
||||
{ to: '/settlements', label: 'Rozliczenia', icon: 'payments' },
|
||||
{ to: '/stats', label: 'Statystyki', icon: 'bar_chart' },
|
||||
];
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function BottomNav() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = [
|
||||
{ to: '/', label: t('bottomNav.home'), icon: 'home', end: true },
|
||||
{ to: '/history', label: t('bottomNav.history'), icon: 'receipt_long' },
|
||||
];
|
||||
|
||||
const rightItems = [
|
||||
{ to: '/settlements', label: t('bottomNav.settlements'), icon: 'payments' },
|
||||
{ to: '/stats', label: t('bottomNav.stats'), icon: 'bar_chart' },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bottom-nav">
|
||||
{items.map((item) => (
|
||||
@@ -20,7 +23,7 @@ export default function BottomNav() {
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/add" className="fab" aria-label="Dodaj wydatek">
|
||||
<NavLink to="/add" className="fab" aria-label={t('bottomNav.addExpense')}>
|
||||
<Icon name="add" />
|
||||
</NavLink>
|
||||
{rightItems.map((item) => (
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function CategoryPieChart({ data, currency }) {
|
||||
const { t } = useTranslation();
|
||||
const chartData = data
|
||||
.filter((d) => d.total > 0)
|
||||
.map((d) => ({ name: d.name || 'Bez kategorii', value: d.total, color: d.color || '#6b7280' }));
|
||||
.map((d) => ({ name: d.name || t('charts.noCategory'), value: d.total, color: d.color || '#6b7280' }));
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return <p className="empty-state">Brak wydatków w tym miesiącu</p>;
|
||||
return <p className="empty-state">{t('charts.noExpensesThisMonth')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import Icon from './Icon.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
const ConfirmContext = createContext(null);
|
||||
|
||||
export function ConfirmProvider({ children }) {
|
||||
const [state, setState] = useState(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const confirmAction = useCallback((options) => {
|
||||
return new Promise((resolve) => {
|
||||
setState({
|
||||
title: options.title || 'Czy na pewno?',
|
||||
message: options.message || '',
|
||||
confirmLabel: options.confirmLabel || 'Usuń',
|
||||
cancelLabel: options.cancelLabel || 'Anuluj',
|
||||
danger: options.danger !== false,
|
||||
resolve,
|
||||
const confirmAction = useCallback(
|
||||
(options) => {
|
||||
return new Promise((resolve) => {
|
||||
setState({
|
||||
title: options.title || t('confirmDialog.defaultTitle'),
|
||||
message: options.message || '',
|
||||
confirmLabel: options.confirmLabel || t('confirmDialog.defaultConfirm'),
|
||||
cancelLabel: options.cancelLabel || t('confirmDialog.defaultCancel'),
|
||||
danger: options.danger !== false,
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
function handle(result) {
|
||||
state?.resolve(result);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Component } from 'react';
|
||||
import en from '../i18n/locales/en/errorBoundary.json';
|
||||
import pl from '../i18n/locales/pl/errorBoundary.json';
|
||||
|
||||
// Sits above I18nProvider so it must keep working even if the provider itself throws —
|
||||
// it can't rely on useTranslation() and instead reads the persisted language directly.
|
||||
function currentTranslations() {
|
||||
const lang = localStorage.getItem('whowhat_language');
|
||||
return lang === 'en' ? en : pl;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
state = { error: null };
|
||||
@@ -8,17 +17,18 @@ export default class ErrorBoundary extends Component {
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.error('Nieobsłużony błąd renderowania:', error, info);
|
||||
console.error('Unhandled render error:', error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
const t = currentTranslations();
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Coś poszło nie tak</h1>
|
||||
<p className="auth-subtitle">Wystąpił nieoczekiwany błąd. Spróbuj odświeżyć stronę.</p>
|
||||
<h1>{t.title}</h1>
|
||||
<p className="auth-subtitle">{t.subtitle}</p>
|
||||
<button className="btn-primary" onClick={() => window.location.reload()}>
|
||||
Odśwież
|
||||
{t.refresh}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useUpdateExpense, useDeleteExpense } from '../api/queries.js';
|
||||
import { useConfirm } from './ConfirmDialogProvider.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import PayerToggle from './PayerToggle.jsx';
|
||||
import SplitSelector from './SplitSelector.jsx';
|
||||
import Icon from './Icon.jsx';
|
||||
@@ -9,6 +10,7 @@ export default function ExpenseEditModal({ expense, members, categories, currenc
|
||||
const updateExpense = useUpdateExpense();
|
||||
const deleteExpense = useDeleteExpense();
|
||||
const confirmDialog = useConfirm();
|
||||
const { t, tError } = useTranslation();
|
||||
|
||||
const [amount, setAmount] = useState(String(expense.amount));
|
||||
const [title, setTitle] = useState(expense.title);
|
||||
@@ -50,15 +52,15 @@ export default function ExpenseEditModal({ expense, members, categories, currenc
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = await confirmDialog({
|
||||
title: 'Usunąć ten wydatek?',
|
||||
message: 'Tej operacji nie da się cofnąć.',
|
||||
confirmLabel: 'Usuń',
|
||||
title: t('expenseEditModal.deleteConfirmTitle'),
|
||||
message: t('expenseEditModal.deleteConfirmMessage'),
|
||||
confirmLabel: t('common.delete'),
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteExpense.mutateAsync(expense.id);
|
||||
@@ -69,7 +71,7 @@ export default function ExpenseEditModal({ expense, members, categories, currenc
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Edytuj wydatek</h3>
|
||||
<h3>{t('expenseEditModal.title')}</h3>
|
||||
<button className="icon-btn" onClick={onClose}><Icon name="close" /></button>
|
||||
</div>
|
||||
|
||||
@@ -84,12 +86,12 @@ export default function ExpenseEditModal({ expense, members, categories, currenc
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Tytuł</label>
|
||||
<label>{t('expenseEditModal.titleFieldLabel')}</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kategoria</label>
|
||||
<label>{t('expenseEditModal.categoryLabel')}</label>
|
||||
<div className="category-grid">
|
||||
{(categories || []).map((c) => (
|
||||
<div
|
||||
@@ -105,7 +107,7 @@ export default function ExpenseEditModal({ expense, members, categories, currenc
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kto płacił?</label>
|
||||
<label>{t('expenseEditModal.whoPaidLabel')}</label>
|
||||
<PayerToggle members={members} payerId={payerId} onChange={setPayerId} />
|
||||
</div>
|
||||
|
||||
@@ -121,7 +123,7 @@ export default function ExpenseEditModal({ expense, members, categories, currenc
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Data</label>
|
||||
<label>{t('expenseEditModal.dateLabel')}</label>
|
||||
<input type="date" value={expenseDate} onChange={(e) => setExpenseDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
@@ -129,10 +131,10 @@ export default function ExpenseEditModal({ expense, members, categories, currenc
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn-danger" onClick={handleDelete} disabled={deleteExpense.isPending}>
|
||||
<Icon name="delete" /> Usuń
|
||||
<Icon name="delete" /> {t('common.delete')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleSave} disabled={updateExpense.isPending}>
|
||||
Zapisz
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Icon from './Icon.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function ExpenseListItem({ expense, category, payer, currentUserId, currency, onClick }) {
|
||||
const myShare = expense.shares.find((s) => s.user_id === currentUserId)?.share_amount ?? 0;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="expense-item" onClick={onClick}>
|
||||
@@ -19,7 +21,7 @@ export default function ExpenseListItem({ expense, category, payer, currentUserI
|
||||
</div>
|
||||
<div className="amount-col">
|
||||
<div className="amount">{expense.amount.toFixed(2)} {currency}</div>
|
||||
<div className="share">Twoja część: {myShare.toFixed(2)} {currency}</div>
|
||||
<div className="share">{t('expenseListItem.yourShare')}: {myShare.toFixed(2)} {currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useInstallPrompt } from '../pwa/useInstallPrompt.js';
|
||||
import Icon from './Icon.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
const DISMISS_KEY = 'ktoco_install_dismissed';
|
||||
const DISMISS_KEY = 'whowhat_install_dismissed';
|
||||
|
||||
export default function InstallBanner() {
|
||||
const { canInstall, isIOS, promptInstall } = useInstallPrompt();
|
||||
const [dismissed, setDismissed] = useState(() => localStorage.getItem(DISMISS_KEY) === '1');
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (dismissed || (!canInstall && !isIOS)) return null;
|
||||
|
||||
@@ -20,20 +22,20 @@ export default function InstallBanner() {
|
||||
<Icon name="install_mobile" />
|
||||
<div className="install-banner-text">
|
||||
{canInstall ? (
|
||||
<span>Zainstaluj KtoCo jako aplikację na telefonie</span>
|
||||
<span>{t('installBanner.installPrompt', { appName: t('common.appName') })}</span>
|
||||
) : (
|
||||
<span>
|
||||
Dodaj KtoCo do ekranu głównego: dotknij <Icon name="ios_share" className="inline-icon" /> Udostępnij, a
|
||||
potem „Dodaj do ekranu początkowego”
|
||||
{t('installBanner.installIosPrompt', { appName: t('common.appName') })}{' '}
|
||||
<Icon name="ios_share" className="inline-icon" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canInstall && (
|
||||
<button className="btn-primary install-banner-btn" onClick={promptInstall}>
|
||||
Zainstaluj
|
||||
{t('installBanner.install')}
|
||||
</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={handleDismiss} aria-label="Zamknij">
|
||||
<button className="icon-btn" onClick={handleDismiss} aria-label={t('installBanner.close')}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
27
frontend/src/components/LanguageSwitcher.jsx
Normal file
27
frontend/src/components/LanguageSwitcher.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useUpdateAccountLanguage } from '../api/queries.js';
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { language, setLanguage, languages, t } = useTranslation();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const updateAccountLanguage = useUpdateAccountLanguage();
|
||||
|
||||
function handleSelect(code) {
|
||||
setLanguage(code);
|
||||
if (isAuthenticated) updateAccountLanguage.mutate(code);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>{t('languageSwitcher.label')}</h3>
|
||||
<div className="theme-toggle">
|
||||
{languages.map((l) => (
|
||||
<button key={l.code} className={language === l.code ? 'tab active' : 'tab'} onClick={() => handleSelect(l.code)}>
|
||||
<span>{l.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function MonthlyBarChart({ data, currency }) {
|
||||
const { t } = useTranslation();
|
||||
if (!data || data.length === 0) {
|
||||
return <p className="empty-state">Brak danych historycznych</p>;
|
||||
return <p className="empty-state">{t('charts.noHistoricalData')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useOnlineSync } from '../offline/useOnlineSync.js';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function OfflineBanner() {
|
||||
const { isOnline, pendingCount } = useOnlineSync();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isOnline && pendingCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`offline-banner ${isOnline ? 'offline-banner--syncing' : ''}`}>
|
||||
{!isOnline && <span>Brak połączenia — wydatki zapisują się lokalnie</span>}
|
||||
{isOnline && pendingCount > 0 && <span>Synchronizowanie {pendingCount} wydatków…</span>}
|
||||
{!isOnline && <span>{t('offlineBanner.offline')}</span>}
|
||||
{isOnline && pendingCount > 0 && <span>{t('offlineBanner.syncing', { count: pendingCount })}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import Icon from './Icon.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function PasswordField({ value, onChange, placeholder }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="password-field">
|
||||
@@ -17,7 +19,7 @@ export default function PasswordField({ value, onChange, placeholder }) {
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label={visible ? 'Ukryj hasło' : 'Pokaż hasło'}
|
||||
aria-label={visible ? t('passwordField.hide') : t('passwordField.show')}
|
||||
>
|
||||
<Icon name={visible ? 'visibility_off' : 'visibility'} />
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Cell } from 'recharts';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
const COLORS = ['#4f46e5', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16'];
|
||||
|
||||
export default function PayerComparisonChart({ members, byShare, currency }) {
|
||||
const { t } = useTranslation();
|
||||
const data = members.map((m) => ({
|
||||
name: m.name,
|
||||
value: byShare.find((s) => s.userId === m.id)?.total || 0,
|
||||
}));
|
||||
|
||||
if (data.every((d) => d.value === 0)) {
|
||||
return <p className="empty-state">Brak wydatków w tym miesiącu</p>;
|
||||
return <p className="empty-state">{t('charts.noExpensesThisMonth')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import Icon from './Icon.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function SettingsButton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<NavLink to="/settings" className="top-settings-btn" aria-label="Ustawienia">
|
||||
<NavLink to="/settings" className="top-settings-btn" aria-label={t('settingsButton.settings')}>
|
||||
<Icon name="settings" />
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useUpdateSettlement, useDeleteSettlement } from '../api/queries.js';
|
||||
import { useConfirm } from './ConfirmDialogProvider.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import PayerToggle from './PayerToggle.jsx';
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
@@ -8,6 +9,7 @@ export default function SettlementEditModal({ settlement, members, currency, onC
|
||||
const updateSettlement = useUpdateSettlement();
|
||||
const deleteSettlement = useDeleteSettlement();
|
||||
const confirmDialog = useConfirm();
|
||||
const { t, tError } = useTranslation();
|
||||
|
||||
const [fromUserId, setFromUserId] = useState(settlement.from_user_id);
|
||||
const [toUserId, setToUserId] = useState(settlement.to_user_id);
|
||||
@@ -27,22 +29,22 @@ export default function SettlementEditModal({ settlement, members, currency, onC
|
||||
async function handleSave() {
|
||||
setError('');
|
||||
if (!fromUserId || !toUserId || !amount || Number(amount) <= 0) {
|
||||
setError('Wybierz obie osoby i podaj kwotę większą od zera');
|
||||
setError(t('settlements.selectBothAndAmount'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateSettlement.mutateAsync({ id: settlement.id, fromUserId, toUserId, amount: Number(amount) });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = await confirmDialog({
|
||||
title: 'Usunąć to rozliczenie?',
|
||||
message: 'Saldo zostanie przeliczone tak, jakby ta płatność nigdy nie miała miejsca.',
|
||||
confirmLabel: 'Usuń',
|
||||
title: t('settlementEditModal.deleteConfirmTitle'),
|
||||
message: t('settlementEditModal.deleteConfirmMessage'),
|
||||
confirmLabel: t('common.delete'),
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteSettlement.mutateAsync(settlement.id);
|
||||
@@ -53,17 +55,17 @@ export default function SettlementEditModal({ settlement, members, currency, onC
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Edytuj rozliczenie</h3>
|
||||
<h3>{t('settlementEditModal.title')}</h3>
|
||||
<button className="icon-btn" onClick={onClose}><Icon name="close" /></button>
|
||||
</div>
|
||||
|
||||
<div className="expense-form">
|
||||
<div className="field">
|
||||
<label>Kto płacił?</label>
|
||||
<label>{t('settlementEditModal.whoPaidLabel')}</label>
|
||||
<PayerToggle members={members} payerId={fromUserId} onChange={handleFromChange} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Komu?</label>
|
||||
<label>{t('settlementEditModal.toWhomLabel')}</label>
|
||||
<PayerToggle members={toOptions} payerId={toUserId} onChange={setToUserId} />
|
||||
</div>
|
||||
<input
|
||||
@@ -79,10 +81,10 @@ export default function SettlementEditModal({ settlement, members, currency, onC
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn-danger" onClick={handleDelete} disabled={deleteSettlement.isPending}>
|
||||
<Icon name="delete" /> Usuń
|
||||
<Icon name="delete" /> {t('common.delete')}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleSave} disabled={updateSettlement.isPending}>
|
||||
Zapisz
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Icon from './Icon.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
function memberName(members, id) {
|
||||
return members.find((m) => m.id === id)?.name || '—';
|
||||
}
|
||||
|
||||
export default function SettlementListItem({ settlement, members, currency, onClick }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="expense-item settlement-item" onClick={onClick}>
|
||||
<div className="cat-icon settlement-icon">
|
||||
@@ -17,7 +19,7 @@ export default function SettlementListItem({ settlement, members, currency, onCl
|
||||
<div className="meta">
|
||||
<span>{settlement.settled_at.slice(0, 10)}</span>
|
||||
<span>·</span>
|
||||
<span>Rozliczenie</span>
|
||||
<span>{t('settlementListItem.settlement')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="amount-col">
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'equal', label: 'Po równo (50/50)' },
|
||||
{ value: 'exact', label: 'Dokładny podział' },
|
||||
{ value: 'full', label: 'Całość na jedną osobę' },
|
||||
];
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
function round2(n) {
|
||||
return Math.round(n * 100) / 100;
|
||||
@@ -25,6 +20,7 @@ function isEmptyShares(shares, members) {
|
||||
}
|
||||
|
||||
function ExactSplitEditor({ members, amount, exactShares, onExactSharesChange }) {
|
||||
const { t } = useTranslation();
|
||||
const total = Number(amount) || 0;
|
||||
const editableMembers = members.slice(0, -1);
|
||||
const lastMember = members[members.length - 1];
|
||||
@@ -81,7 +77,7 @@ function ExactSplitEditor({ members, amount, exactShares, onExactSharesChange })
|
||||
<input type="number" value={bValue.toFixed(2)} readOnly disabled />
|
||||
</div>
|
||||
</div>
|
||||
{overAllocated && <p className="form-error">Suma udziałów przekracza kwotę wydatku</p>}
|
||||
{overAllocated && <p className="form-error">{t('splitSelector.overAllocated')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,11 +98,11 @@ function ExactSplitEditor({ members, amount, exactShares, onExactSharesChange })
|
||||
))}
|
||||
{lastMember && (
|
||||
<div className="field">
|
||||
<label>{lastMember.name} (wyliczane automatycznie)</label>
|
||||
<label>{lastMember.name} {t('splitSelector.autoCalculated')}</label>
|
||||
<input type="number" value={lastValue.toFixed(2)} readOnly disabled />
|
||||
</div>
|
||||
)}
|
||||
{overAllocated && <p className="form-error">Suma udziałów przekracza kwotę wydatku</p>}
|
||||
{overAllocated && <p className="form-error">{t('splitSelector.overAllocated')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,9 +117,16 @@ export default function SplitSelector({
|
||||
fullOwedBy,
|
||||
onFullOwedByChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const OPTIONS = [
|
||||
{ value: 'equal', label: t('splitSelector.equal') },
|
||||
{ value: 'exact', label: t('splitSelector.exact') },
|
||||
{ value: 'full', label: t('splitSelector.full') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<label>Jak dzielimy wydatek?</label>
|
||||
<label>{t('splitSelector.label')}</label>
|
||||
<div className="split-options">
|
||||
{OPTIONS.map((opt) => (
|
||||
<div
|
||||
@@ -153,7 +156,7 @@ export default function SplitSelector({
|
||||
className={`toggle-btn ${fullOwedBy === m.id ? 'selected' : ''}`}
|
||||
onClick={() => onFullOwedByChange(m.id)}
|
||||
>
|
||||
{m.name} winien(-na) całość
|
||||
{t('splitSelector.owesFullAmount', { name: m.name })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
98
frontend/src/i18n/I18nContext.jsx
Normal file
98
frontend/src/i18n/I18nContext.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } from './languages.js';
|
||||
|
||||
const LANGUAGE_KEY = 'whowhat_language';
|
||||
const SUPPORTED_CODES = SUPPORTED_LANGUAGES.map((l) => l.code);
|
||||
|
||||
// Every locales/<lang>/<namespace>.json file is bundled here at build time and merged
|
||||
// into one dictionary per language, keyed by namespace (the filename without extension).
|
||||
const modules = import.meta.glob('./locales/*/*.json', { eager: true });
|
||||
|
||||
function buildDictionaries() {
|
||||
const dictionaries = {};
|
||||
for (const path in modules) {
|
||||
const match = path.match(/\.\/locales\/([^/]+)\/([^/]+)\.json$/);
|
||||
if (!match) continue;
|
||||
const [, lang, namespace] = match;
|
||||
dictionaries[lang] = dictionaries[lang] || {};
|
||||
dictionaries[lang][namespace] = modules[path].default;
|
||||
}
|
||||
return dictionaries;
|
||||
}
|
||||
|
||||
const DICTIONARIES = buildDictionaries();
|
||||
|
||||
function resolve(dictionary, key) {
|
||||
const [namespace, ...rest] = key.split('.');
|
||||
let node = dictionary?.[namespace];
|
||||
for (const part of rest) {
|
||||
if (node == null) return undefined;
|
||||
node = node[part];
|
||||
}
|
||||
return typeof node === 'string' ? node : undefined;
|
||||
}
|
||||
|
||||
function interpolate(template, params) {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, name) => (name in params ? String(params[name]) : match));
|
||||
}
|
||||
|
||||
function detectInitialLanguage() {
|
||||
const stored = localStorage.getItem(LANGUAGE_KEY);
|
||||
if (stored && SUPPORTED_CODES.includes(stored)) return stored;
|
||||
const browserLang = (navigator.language || '').slice(0, 2).toLowerCase();
|
||||
if (SUPPORTED_CODES.includes(browserLang)) return browserLang;
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
const I18nContext = createContext(null);
|
||||
|
||||
export function I18nProvider({ children }) {
|
||||
const [language, setLanguageState] = useState(detectInitialLanguage);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('lang', language);
|
||||
}, [language]);
|
||||
|
||||
const setLanguage = useCallback((lang) => {
|
||||
if (!SUPPORTED_CODES.includes(lang)) return;
|
||||
localStorage.setItem(LANGUAGE_KEY, lang);
|
||||
setLanguageState(lang);
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key, params) => {
|
||||
const value =
|
||||
resolve(DICTIONARIES[language], key) ??
|
||||
resolve(DICTIONARIES[DEFAULT_LANGUAGE], key) ??
|
||||
resolve(DICTIONARIES.en, key);
|
||||
if (value === undefined) return key;
|
||||
return interpolate(value, params);
|
||||
},
|
||||
[language]
|
||||
);
|
||||
|
||||
// Translates an error code coming from the API (ApiError#message). Falls back to the
|
||||
// raw code (or a generic message) if the backend ever sends something not in errors.json.
|
||||
const tError = useCallback(
|
||||
(err) => {
|
||||
const code = err?.message;
|
||||
if (!code) return t('errors.generic');
|
||||
return resolve(DICTIONARIES[language], `errors.${code}`) ?? code;
|
||||
},
|
||||
[language, t]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, setLanguage, languages: SUPPORTED_LANGUAGES, t, tError }),
|
||||
[language, setLanguage, t, tError]
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) throw new Error('useTranslation must be used within I18nProvider');
|
||||
return ctx;
|
||||
}
|
||||
13
frontend/src/i18n/languages.js
Normal file
13
frontend/src/i18n/languages.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Registry of supported UI languages.
|
||||
//
|
||||
// To add a new language:
|
||||
// 1. Create frontend/src/i18n/locales/<code>/ and add a .json file per namespace,
|
||||
// mirroring the files in locales/en/ (same filenames, same keys).
|
||||
// 2. Add an entry below with the language's own name (shown in the language switcher).
|
||||
// No other code changes are needed — I18nContext.jsx discovers locale files automatically.
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
];
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'pl';
|
||||
15
frontend/src/i18n/locales/en/addExpense.json
Normal file
15
frontend/src/i18n/locales/en/addExpense.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"pageTitle": "Add expense",
|
||||
"titleLabel": "Title / description",
|
||||
"titlePlaceholder": "e.g. Grocery shopping",
|
||||
"categoryLabel": "Category",
|
||||
"whoPaidLabel": "Who paid?",
|
||||
"dateLabel": "Date",
|
||||
"saving": "Saving…",
|
||||
"submit": "Add expense",
|
||||
"defaultTitle": "Expense",
|
||||
"invalidAmount": "Enter a valid amount",
|
||||
"selectPayer": "Choose who paid",
|
||||
"selectFullOwedBy": "Choose who owes the full amount",
|
||||
"offlineNotice": "No connection — the expense was saved locally and will sync automatically."
|
||||
}
|
||||
3
frontend/src/i18n/locales/en/app.json
Normal file
3
frontend/src/i18n/locales/en/app.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"loading": "Loading…"
|
||||
}
|
||||
7
frontend/src/i18n/locales/en/bottomNav.json
Normal file
7
frontend/src/i18n/locales/en/bottomNav.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"home": "Home",
|
||||
"history": "History",
|
||||
"settlements": "Settlements",
|
||||
"stats": "Stats",
|
||||
"addExpense": "Add expense"
|
||||
}
|
||||
5
frontend/src/i18n/locales/en/charts.json
Normal file
5
frontend/src/i18n/locales/en/charts.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"noCategory": "No category",
|
||||
"noExpensesThisMonth": "No expenses this month",
|
||||
"noHistoricalData": "No historical data"
|
||||
}
|
||||
22
frontend/src/i18n/locales/en/common.json
Normal file
22
frontend/src/i18n/locales/en/common.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"appName": "WhoWhat",
|
||||
"save": "Save",
|
||||
"saveChanges": "Save changes",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading…",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"you": "You",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied to clipboard!",
|
||||
"copyFailed": "Couldn't copy automatically — select the field above and copy manually.",
|
||||
"logout": "Log out",
|
||||
"areYouSure": "Are you sure?"
|
||||
}
|
||||
5
frontend/src/i18n/locales/en/confirmDialog.json
Normal file
5
frontend/src/i18n/locales/en/confirmDialog.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"defaultTitle": "Are you sure?",
|
||||
"defaultConfirm": "Delete",
|
||||
"defaultCancel": "Cancel"
|
||||
}
|
||||
8
frontend/src/i18n/locales/en/dashboard.json
Normal file
8
frontend/src/i18n/locales/en/dashboard.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"settlementsTitle": "Settlements",
|
||||
"allSettled": "You're all settled up!",
|
||||
"settleUpButton": "Settle up",
|
||||
"expensesByCategoryTitle": "Expenses by category (this month)",
|
||||
"monthSummaryTitle": "Month summary",
|
||||
"totalLabel": "Total"
|
||||
}
|
||||
5
frontend/src/i18n/locales/en/errorBoundary.json
Normal file
5
frontend/src/i18n/locales/en/errorBoundary.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Something went wrong",
|
||||
"subtitle": "An unexpected error occurred. Try refreshing the page.",
|
||||
"refresh": "Refresh"
|
||||
}
|
||||
40
frontend/src/i18n/locales/en/errors.json
Normal file
40
frontend/src/i18n/locales/en/errors.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"already_a_member": "You're already a member of this household",
|
||||
"already_settled": "You're already settled up",
|
||||
"amount_must_be_positive": "The amount must be greater than zero",
|
||||
"both_must_be_members": "Both people must be members of the household",
|
||||
"category_name_required": "A category name is required",
|
||||
"category_not_found": "Category not found",
|
||||
"current_password_incorrect": "Current password is incorrect",
|
||||
"email_already_registered": "An account with this email already exists",
|
||||
"email_required": "Email is required",
|
||||
"expense_not_found": "Expense not found",
|
||||
"household_has_no_members": "This household has no members to split the expense between",
|
||||
"household_not_found": "Household not found",
|
||||
"household_not_selected": "No household selected",
|
||||
"internal_server_error": "Internal server error",
|
||||
"invalid_credentials": "Invalid email or password",
|
||||
"invalid_or_expired_token": "Your session has expired — please log in again",
|
||||
"invite_code_invalid_or_expired": "This invite code is invalid or has expired",
|
||||
"invite_code_required": "An invite code is required",
|
||||
"missing_bearer_token": "Authentication required",
|
||||
"missing_expense_fields": "Amount, title, date, payer and split type are required",
|
||||
"missing_login_fields": "Email and password are required",
|
||||
"missing_password_fields": "Current and new password are required",
|
||||
"missing_registration_fields": "Email, password and name are required",
|
||||
"missing_reset_fields": "Token and password are required",
|
||||
"missing_settlement_fields": "From, to and amount are required",
|
||||
"name_required": "Name is required",
|
||||
"not_a_household_member": "You're not a member of this household",
|
||||
"password_too_short": "Password must be at least 6 characters",
|
||||
"payer_and_recipient_must_differ": "The payer and recipient must be different people",
|
||||
"payer_must_be_member": "The payer must be a member of the household",
|
||||
"reset_link_invalid_or_expired": "This password reset link is invalid or has expired",
|
||||
"settlement_not_found": "Settlement not found",
|
||||
"share_assigned_to_non_member": "A share was assigned to someone outside the household",
|
||||
"shares_must_sum_to_amount": "The shares must add up to the expense amount",
|
||||
"shares_required": "Shares are required for this split type",
|
||||
"unknown_split_type": "Unknown split type",
|
||||
"user_not_found": "User not found"
|
||||
}
|
||||
9
frontend/src/i18n/locales/en/expenseEditModal.json
Normal file
9
frontend/src/i18n/locales/en/expenseEditModal.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Edit expense",
|
||||
"titleFieldLabel": "Title",
|
||||
"categoryLabel": "Category",
|
||||
"whoPaidLabel": "Who paid?",
|
||||
"dateLabel": "Date",
|
||||
"deleteConfirmTitle": "Delete this expense?",
|
||||
"deleteConfirmMessage": "This can't be undone."
|
||||
}
|
||||
3
frontend/src/i18n/locales/en/expenseListItem.json
Normal file
3
frontend/src/i18n/locales/en/expenseListItem.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"yourShare": "Your share"
|
||||
}
|
||||
9
frontend/src/i18n/locales/en/forgotPassword.json
Normal file
9
frontend/src/i18n/locales/en/forgotPassword.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Reset password",
|
||||
"sentMessage": "If an account with this email exists, we've sent a message with a password reset link.",
|
||||
"subtitle": "Enter the email we should send the reset link to",
|
||||
"emailPlaceholder": "Email",
|
||||
"sending": "Sending…",
|
||||
"submit": "Send link",
|
||||
"backToLogin": "Back to login"
|
||||
}
|
||||
7
frontend/src/i18n/locales/en/history.json
Normal file
7
frontend/src/i18n/locales/en/history.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"pageTitle": "History",
|
||||
"allMonths": "All months",
|
||||
"allCategories": "All categories",
|
||||
"allPayers": "All payers",
|
||||
"emptyState": "No entries match the filters"
|
||||
}
|
||||
6
frontend/src/i18n/locales/en/installBanner.json
Normal file
6
frontend/src/i18n/locales/en/installBanner.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"installPrompt": "Install {{appName}} as an app on your phone",
|
||||
"installIosPrompt": "Add {{appName}} to your home screen: tap Share, then “Add to Home Screen”",
|
||||
"install": "Install",
|
||||
"close": "Close"
|
||||
}
|
||||
5
frontend/src/i18n/locales/en/joinInvite.json
Normal file
5
frontend/src/i18n/locales/en/joinInvite.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Joining household",
|
||||
"waiting": "One moment…",
|
||||
"goToApp": "Go to the app"
|
||||
}
|
||||
3
frontend/src/i18n/locales/en/languageSwitcher.json
Normal file
3
frontend/src/i18n/locales/en/languageSwitcher.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"label": "Language"
|
||||
}
|
||||
11
frontend/src/i18n/locales/en/login.json
Normal file
11
frontend/src/i18n/locales/en/login.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"subtitleInvite": "Log in to join the household you were invited to",
|
||||
"subtitle": "Log in to manage shared expenses",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password",
|
||||
"loggingIn": "Logging in…",
|
||||
"submit": "Log in",
|
||||
"forgotPassword": "Forgot your password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"register": "Sign up"
|
||||
}
|
||||
4
frontend/src/i18n/locales/en/offlineBanner.json
Normal file
4
frontend/src/i18n/locales/en/offlineBanner.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"offline": "No connection — expenses are saved locally",
|
||||
"syncing": "Syncing {{count}} expenses…"
|
||||
}
|
||||
14
frontend/src/i18n/locales/en/onboarding.json
Normal file
14
frontend/src/i18n/locales/en/onboarding.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"back": "Back",
|
||||
"title": "Welcome!",
|
||||
"subtitle": "Create a new household or join your partner/roommate with a code",
|
||||
"createTab": "Create",
|
||||
"joinTab": "Join with code",
|
||||
"defaultHouseholdName": "Our household",
|
||||
"householdNamePlaceholder": "Household name",
|
||||
"creating": "Creating…",
|
||||
"createButton": "Create household",
|
||||
"inviteCodePlaceholder": "Invite code",
|
||||
"joining": "Joining…",
|
||||
"joinButton": "Join"
|
||||
}
|
||||
4
frontend/src/i18n/locales/en/passwordField.json
Normal file
4
frontend/src/i18n/locales/en/passwordField.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"show": "Show password",
|
||||
"hide": "Hide password"
|
||||
}
|
||||
11
frontend/src/i18n/locales/en/register.json
Normal file
11
frontend/src/i18n/locales/en/register.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"subtitleInvite": "Create an account to join the household you were invited to",
|
||||
"subtitle": "Create an account to start splitting expenses",
|
||||
"namePlaceholder": "Name",
|
||||
"emailPlaceholder": "Email",
|
||||
"passwordPlaceholder": "Password (min. 6 characters)",
|
||||
"creating": "Creating account…",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account?",
|
||||
"login": "Log in"
|
||||
}
|
||||
12
frontend/src/i18n/locales/en/resetPassword.json
Normal file
12
frontend/src/i18n/locales/en/resetPassword.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"passwordMismatch": "Passwords don't match",
|
||||
"invalidLinkTitle": "Invalid link",
|
||||
"invalidLinkMessage": "The password reset token is missing. Request a new link.",
|
||||
"requestNewLink": "Reset password",
|
||||
"title": "Set a new password",
|
||||
"done": "Your password has been changed. Redirecting to login…",
|
||||
"newPasswordPlaceholder": "New password (min. 6 characters)",
|
||||
"confirmPasswordPlaceholder": "Repeat new password",
|
||||
"saving": "Saving…",
|
||||
"submit": "Set new password"
|
||||
}
|
||||
81
frontend/src/i18n/locales/en/settings.json
Normal file
81
frontend/src/i18n/locales/en/settings.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"pageTitle": "Settings",
|
||||
"install": {
|
||||
"title": "App",
|
||||
"installed": "The app is installed on this device",
|
||||
"installButton": "Install app",
|
||||
"iosPrompt": "Tap Share, then “Add to Home Screen”.",
|
||||
"secureContextRequired": "Installing requires a secure connection — open the app at an address starting with {{https}}, not a local IP address.",
|
||||
"notOfferedYet": "Your browser hasn't offered to install yet. Refresh the page after using the app for a bit, or use the browser menu (⋮) and choose “Install app” / “Add to Home Screen”."
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"nameLabel": "Name",
|
||||
"emailLabel": "Email",
|
||||
"deleteAccountButton": "Delete account",
|
||||
"deleteConfirmTitle": "Delete your account?",
|
||||
"deleteConfirmMessage": "This can't be undone.",
|
||||
"deleteConfirmLabel": "Delete account"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"emailLabel": "Email notifications",
|
||||
"emailDesc": "You'll get an email when someone in the household adds a new expense"
|
||||
},
|
||||
"households": {
|
||||
"title": "Your households",
|
||||
"memberCountOne": "{{count}} member",
|
||||
"memberCountOther": "{{count}} members",
|
||||
"active": "Active",
|
||||
"switch": "Switch",
|
||||
"createOrJoin": "Create or join a household",
|
||||
"activeNameLabel": "Active household name",
|
||||
"currencyLabel": "Currency",
|
||||
"membersHeading": "Members",
|
||||
"youSuffix": "(You)",
|
||||
"removeAria": "Remove",
|
||||
"inviteAnother": "Invite another person with this code:",
|
||||
"inviteLinkHint": "Or send a link that goes straight to sign-up and joins the household:",
|
||||
"regenerateCode": "Generate new code",
|
||||
"deleteHouseholdButton": "Delete household",
|
||||
"leaveConfirmTitle": "Leave this household?",
|
||||
"leaveConfirmMessage": "You'll need to join again with an invite code to come back.",
|
||||
"leaveConfirmLabel": "Leave",
|
||||
"removeMemberConfirmTitle": "Remove {{name}} from the household?",
|
||||
"removeMemberConfirmMessage": "This person will lose access to the household (expense history is kept).",
|
||||
"removeMemberConfirmLabel": "Remove",
|
||||
"deleteHouseholdConfirmTitle": "Delete household “{{name}}”?",
|
||||
"deleteHouseholdConfirmMessage": "This deletes the entire expense history for all members. This can't be undone.",
|
||||
"deleteHouseholdConfirmLabel": "Delete household"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"currentPasswordLabel": "Current password",
|
||||
"newPasswordLabel": "New password",
|
||||
"confirmPasswordLabel": "Repeat new password",
|
||||
"passwordMismatch": "New passwords don't match",
|
||||
"passwordChanged": "Password changed",
|
||||
"changePasswordButton": "Change password"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
"iconLabel": "Icon",
|
||||
"nameLabel": "Category name",
|
||||
"namePlaceholder": "e.g. Pets",
|
||||
"addButton": "Add category",
|
||||
"deleteAria": "Delete",
|
||||
"deleteConfirmTitle": "Delete category “{{name}}”?",
|
||||
"deleteConfirmMessage": "Existing expenses keep their history but lose their assigned category.",
|
||||
"deleteConfirmLabel": "Delete"
|
||||
},
|
||||
"data": {
|
||||
"title": "Data",
|
||||
"downloadCsv": "Download CSV"
|
||||
}
|
||||
}
|
||||
3
frontend/src/i18n/locales/en/settingsButton.json
Normal file
3
frontend/src/i18n/locales/en/settingsButton.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"settings": "Settings"
|
||||
}
|
||||
7
frontend/src/i18n/locales/en/settlementEditModal.json
Normal file
7
frontend/src/i18n/locales/en/settlementEditModal.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "Edit settlement",
|
||||
"whoPaidLabel": "Who paid?",
|
||||
"toWhomLabel": "To whom?",
|
||||
"deleteConfirmTitle": "Delete this settlement?",
|
||||
"deleteConfirmMessage": "The balance will be recalculated as if this payment never happened."
|
||||
}
|
||||
3
frontend/src/i18n/locales/en/settlementListItem.json
Normal file
3
frontend/src/i18n/locales/en/settlementListItem.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"settlement": "Settlement"
|
||||
}
|
||||
17
frontend/src/i18n/locales/en/settlements.json
Normal file
17
frontend/src/i18n/locales/en/settlements.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"pageTitle": "Settlements",
|
||||
"settlementsTitle": "Settlements",
|
||||
"allSettled": "You're all settled up!",
|
||||
"suggestedTitle": "Suggested settlement (tap to fill the form below)",
|
||||
"settleAllButton": "Settle everything automatically",
|
||||
"settling": "Settling…",
|
||||
"recordPaymentTitle": "Record a payment",
|
||||
"whoPaysLabel": "Who's paying?",
|
||||
"toWhomLabel": "To whom?",
|
||||
"saveSettlementButton": "Save settlement",
|
||||
"saving": "Saving…",
|
||||
"savedNotice": "Payment saved!",
|
||||
"historyTitle": "Settlement history",
|
||||
"emptyState": "No settlements recorded yet",
|
||||
"selectBothAndAmount": "Select both people and enter an amount greater than zero"
|
||||
}
|
||||
9
frontend/src/i18n/locales/en/splitSelector.json
Normal file
9
frontend/src/i18n/locales/en/splitSelector.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"label": "How do we split this?",
|
||||
"equal": "Equally (50/50)",
|
||||
"exact": "Exact amounts",
|
||||
"full": "Full amount to one person",
|
||||
"autoCalculated": "(calculated automatically)",
|
||||
"overAllocated": "The shares exceed the expense amount",
|
||||
"owesFullAmount": "{{name}} owes the full amount"
|
||||
}
|
||||
6
frontend/src/i18n/locales/en/stats.json
Normal file
6
frontend/src/i18n/locales/en/stats.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pageTitle": "Stats",
|
||||
"monthOverMonthTitle": "Month-over-month expenses",
|
||||
"comparisonTitle": "Who's spending more (this month)",
|
||||
"categoriesTitle": "Categories, highest first"
|
||||
}
|
||||
15
frontend/src/i18n/locales/pl/addExpense.json
Normal file
15
frontend/src/i18n/locales/pl/addExpense.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"pageTitle": "Dodaj wydatek",
|
||||
"titleLabel": "Tytuł / opis",
|
||||
"titlePlaceholder": "np. Zakupy Biedronka",
|
||||
"categoryLabel": "Kategoria",
|
||||
"whoPaidLabel": "Kto płacił?",
|
||||
"dateLabel": "Data",
|
||||
"saving": "Zapisywanie…",
|
||||
"submit": "Dodaj wydatek",
|
||||
"defaultTitle": "Wydatek",
|
||||
"invalidAmount": "Podaj poprawną kwotę",
|
||||
"selectPayer": "Wybierz kto płacił",
|
||||
"selectFullOwedBy": "Wybierz kto jest winien całość",
|
||||
"offlineNotice": "Brak sieci — wydatek zapisano lokalnie i zsynchronizuje się automatycznie."
|
||||
}
|
||||
3
frontend/src/i18n/locales/pl/app.json
Normal file
3
frontend/src/i18n/locales/pl/app.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"loading": "Ładowanie…"
|
||||
}
|
||||
7
frontend/src/i18n/locales/pl/bottomNav.json
Normal file
7
frontend/src/i18n/locales/pl/bottomNav.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"home": "Start",
|
||||
"history": "Historia",
|
||||
"settlements": "Rozliczenia",
|
||||
"stats": "Statystyki",
|
||||
"addExpense": "Dodaj wydatek"
|
||||
}
|
||||
5
frontend/src/i18n/locales/pl/charts.json
Normal file
5
frontend/src/i18n/locales/pl/charts.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"noCategory": "Bez kategorii",
|
||||
"noExpensesThisMonth": "Brak wydatków w tym miesiącu",
|
||||
"noHistoricalData": "Brak danych historycznych"
|
||||
}
|
||||
22
frontend/src/i18n/locales/pl/common.json
Normal file
22
frontend/src/i18n/locales/pl/common.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"appName": "WhoWhat",
|
||||
"save": "Zapisz",
|
||||
"saveChanges": "Zapisz zmiany",
|
||||
"saving": "Zapisywanie…",
|
||||
"saved": "Zapisano",
|
||||
"cancel": "Anuluj",
|
||||
"delete": "Usuń",
|
||||
"add": "Dodaj",
|
||||
"edit": "Edytuj",
|
||||
"close": "Zamknij",
|
||||
"confirm": "Potwierdź",
|
||||
"loading": "Ładowanie…",
|
||||
"yes": "Tak",
|
||||
"no": "Nie",
|
||||
"you": "Ty",
|
||||
"copy": "Kopiuj",
|
||||
"copied": "Skopiowano do schowka!",
|
||||
"copyFailed": "Nie udało się skopiować automatycznie — zaznacz pole powyżej i skopiuj ręcznie.",
|
||||
"logout": "Wyloguj się",
|
||||
"areYouSure": "Czy na pewno?"
|
||||
}
|
||||
5
frontend/src/i18n/locales/pl/confirmDialog.json
Normal file
5
frontend/src/i18n/locales/pl/confirmDialog.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"defaultTitle": "Czy na pewno?",
|
||||
"defaultConfirm": "Usuń",
|
||||
"defaultCancel": "Anuluj"
|
||||
}
|
||||
8
frontend/src/i18n/locales/pl/dashboard.json
Normal file
8
frontend/src/i18n/locales/pl/dashboard.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"settlementsTitle": "Rozliczenia",
|
||||
"allSettled": "Jesteście na czysto!",
|
||||
"settleUpButton": "Rozlicz się",
|
||||
"expensesByCategoryTitle": "Wydatki wg kategorii (ten miesiąc)",
|
||||
"monthSummaryTitle": "Podsumowanie miesiąca",
|
||||
"totalLabel": "Suma"
|
||||
}
|
||||
5
frontend/src/i18n/locales/pl/errorBoundary.json
Normal file
5
frontend/src/i18n/locales/pl/errorBoundary.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Coś poszło nie tak",
|
||||
"subtitle": "Wystąpił nieoczekiwany błąd. Spróbuj odświeżyć stronę.",
|
||||
"refresh": "Odśwież"
|
||||
}
|
||||
40
frontend/src/i18n/locales/pl/errors.json
Normal file
40
frontend/src/i18n/locales/pl/errors.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"generic": "Coś poszło nie tak. Spróbuj ponownie.",
|
||||
"already_a_member": "Jesteś już członkiem tego gospodarstwa",
|
||||
"already_settled": "Jesteście już rozliczeni",
|
||||
"amount_must_be_positive": "Kwota musi być większa od zera",
|
||||
"both_must_be_members": "Obie osoby muszą być członkami gospodarstwa",
|
||||
"category_name_required": "Nazwa kategorii jest wymagana",
|
||||
"category_not_found": "Kategoria nie znaleziona",
|
||||
"current_password_incorrect": "Bieżące hasło jest nieprawidłowe",
|
||||
"email_already_registered": "Konto z tym adresem e-mail już istnieje",
|
||||
"email_required": "E-mail jest wymagany",
|
||||
"expense_not_found": "Wydatek nie znaleziony",
|
||||
"household_has_no_members": "Gospodarstwo domowe nie ma członków do podziału wydatku",
|
||||
"household_not_found": "Gospodarstwo nie znalezione",
|
||||
"household_not_selected": "Nie wybrano gospodarstwa",
|
||||
"internal_server_error": "Wewnętrzny błąd serwera",
|
||||
"invalid_credentials": "Nieprawidłowy e-mail lub hasło",
|
||||
"invalid_or_expired_token": "Twoja sesja wygasła — zaloguj się ponownie",
|
||||
"invite_code_invalid_or_expired": "Kod zaproszenia jest nieprawidłowy lub wygasł",
|
||||
"invite_code_required": "Kod zaproszenia jest wymagany",
|
||||
"missing_bearer_token": "Wymagane jest zalogowanie",
|
||||
"missing_expense_fields": "Kwota, tytuł, data, płacący i typ podziału są wymagane",
|
||||
"missing_login_fields": "E-mail i hasło są wymagane",
|
||||
"missing_password_fields": "Bieżące i nowe hasło są wymagane",
|
||||
"missing_registration_fields": "E-mail, hasło i imię są wymagane",
|
||||
"missing_reset_fields": "Token i hasło są wymagane",
|
||||
"missing_settlement_fields": "Nadawca, odbiorca i kwota są wymagane",
|
||||
"name_required": "Imię jest wymagane",
|
||||
"not_a_household_member": "Nie jesteś członkiem tego gospodarstwa",
|
||||
"password_too_short": "Hasło musi mieć co najmniej 6 znaków",
|
||||
"payer_and_recipient_must_differ": "Płacący i odbiorca muszą być różnymi osobami",
|
||||
"payer_must_be_member": "Płacący musi być członkiem gospodarstwa domowego",
|
||||
"reset_link_invalid_or_expired": "Link do resetu hasła jest nieprawidłowy lub wygasł",
|
||||
"settlement_not_found": "Rozliczenie nie znalezione",
|
||||
"share_assigned_to_non_member": "Udział przypisano osobie spoza gospodarstwa domowego",
|
||||
"shares_must_sum_to_amount": "Suma udziałów musi być równa kwocie wydatku",
|
||||
"shares_required": "Udziały są wymagane dla wybranego typu podziału",
|
||||
"unknown_split_type": "Nieznany typ podziału",
|
||||
"user_not_found": "Użytkownik nie znaleziony"
|
||||
}
|
||||
9
frontend/src/i18n/locales/pl/expenseEditModal.json
Normal file
9
frontend/src/i18n/locales/pl/expenseEditModal.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Edytuj wydatek",
|
||||
"titleFieldLabel": "Tytuł",
|
||||
"categoryLabel": "Kategoria",
|
||||
"whoPaidLabel": "Kto płacił?",
|
||||
"dateLabel": "Data",
|
||||
"deleteConfirmTitle": "Usunąć ten wydatek?",
|
||||
"deleteConfirmMessage": "Tej operacji nie da się cofnąć."
|
||||
}
|
||||
3
frontend/src/i18n/locales/pl/expenseListItem.json
Normal file
3
frontend/src/i18n/locales/pl/expenseListItem.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"yourShare": "Twoja część"
|
||||
}
|
||||
9
frontend/src/i18n/locales/pl/forgotPassword.json
Normal file
9
frontend/src/i18n/locales/pl/forgotPassword.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Przypomnij hasło",
|
||||
"sentMessage": "Jeśli konto z tym adresem e-mail istnieje, wysłaliśmy wiadomość z linkiem do resetu hasła.",
|
||||
"subtitle": "Podaj e-mail, na który wyślemy link do zresetowania hasła",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"sending": "Wysyłanie…",
|
||||
"submit": "Wyślij link",
|
||||
"backToLogin": "Powrót do logowania"
|
||||
}
|
||||
7
frontend/src/i18n/locales/pl/history.json
Normal file
7
frontend/src/i18n/locales/pl/history.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"pageTitle": "Historia",
|
||||
"allMonths": "Wszystkie miesiące",
|
||||
"allCategories": "Wszystkie kategorie",
|
||||
"allPayers": "Wszyscy płacący",
|
||||
"emptyState": "Brak wpisów spełniających filtry"
|
||||
}
|
||||
6
frontend/src/i18n/locales/pl/installBanner.json
Normal file
6
frontend/src/i18n/locales/pl/installBanner.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"installPrompt": "Zainstaluj {{appName}} jako aplikację na telefonie",
|
||||
"installIosPrompt": "Dodaj {{appName}} do ekranu głównego: dotknij Udostępnij, a potem „Dodaj do ekranu początkowego”",
|
||||
"install": "Zainstaluj",
|
||||
"close": "Zamknij"
|
||||
}
|
||||
5
frontend/src/i18n/locales/pl/joinInvite.json
Normal file
5
frontend/src/i18n/locales/pl/joinInvite.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Dołączanie do gospodarstwa",
|
||||
"waiting": "Chwileczkę…",
|
||||
"goToApp": "Przejdź do aplikacji"
|
||||
}
|
||||
3
frontend/src/i18n/locales/pl/languageSwitcher.json
Normal file
3
frontend/src/i18n/locales/pl/languageSwitcher.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"label": "Język"
|
||||
}
|
||||
11
frontend/src/i18n/locales/pl/login.json
Normal file
11
frontend/src/i18n/locales/pl/login.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"subtitleInvite": "Zaloguj się, aby dołączyć do zaproszonego gospodarstwa domowego",
|
||||
"subtitle": "Zaloguj się, by zarządzać wspólnymi wydatkami",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Hasło",
|
||||
"loggingIn": "Logowanie…",
|
||||
"submit": "Zaloguj się",
|
||||
"forgotPassword": "Zapomniałeś hasła?",
|
||||
"noAccount": "Nie masz konta?",
|
||||
"register": "Zarejestruj się"
|
||||
}
|
||||
4
frontend/src/i18n/locales/pl/offlineBanner.json
Normal file
4
frontend/src/i18n/locales/pl/offlineBanner.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"offline": "Brak połączenia — wydatki zapisują się lokalnie",
|
||||
"syncing": "Synchronizowanie {{count}} wydatków…"
|
||||
}
|
||||
14
frontend/src/i18n/locales/pl/onboarding.json
Normal file
14
frontend/src/i18n/locales/pl/onboarding.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"back": "Cofnij",
|
||||
"title": "Witaj!",
|
||||
"subtitle": "Załóż nowe gospodarstwo domowe albo dołącz do partnera/partnerki kodem",
|
||||
"createTab": "Utwórz",
|
||||
"joinTab": "Dołącz kodem",
|
||||
"defaultHouseholdName": "Nasze gospodarstwo",
|
||||
"householdNamePlaceholder": "Nazwa gospodarstwa",
|
||||
"creating": "Tworzenie…",
|
||||
"createButton": "Utwórz gospodarstwo",
|
||||
"inviteCodePlaceholder": "Kod zaproszenia",
|
||||
"joining": "Dołączanie…",
|
||||
"joinButton": "Dołącz"
|
||||
}
|
||||
4
frontend/src/i18n/locales/pl/passwordField.json
Normal file
4
frontend/src/i18n/locales/pl/passwordField.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"show": "Pokaż hasło",
|
||||
"hide": "Ukryj hasło"
|
||||
}
|
||||
11
frontend/src/i18n/locales/pl/register.json
Normal file
11
frontend/src/i18n/locales/pl/register.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"subtitleInvite": "Załóż konto, aby dołączyć do zaproszonego gospodarstwa domowego",
|
||||
"subtitle": "Załóż konto, by zacząć dzielić wydatki",
|
||||
"namePlaceholder": "Imię",
|
||||
"emailPlaceholder": "E-mail",
|
||||
"passwordPlaceholder": "Hasło (min. 6 znaków)",
|
||||
"creating": "Tworzenie konta…",
|
||||
"submit": "Zarejestruj się",
|
||||
"haveAccount": "Masz już konto?",
|
||||
"login": "Zaloguj się"
|
||||
}
|
||||
12
frontend/src/i18n/locales/pl/resetPassword.json
Normal file
12
frontend/src/i18n/locales/pl/resetPassword.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"passwordMismatch": "Hasła nie są takie same",
|
||||
"invalidLinkTitle": "Nieprawidłowy link",
|
||||
"invalidLinkMessage": "Brakuje tokenu resetu hasła. Poproś o nowy link.",
|
||||
"requestNewLink": "Przypomnij hasło",
|
||||
"title": "Ustaw nowe hasło",
|
||||
"done": "Hasło zostało zmienione. Przenoszenie do logowania…",
|
||||
"newPasswordPlaceholder": "Nowe hasło (min. 6 znaków)",
|
||||
"confirmPasswordPlaceholder": "Powtórz nowe hasło",
|
||||
"saving": "Zapisywanie…",
|
||||
"submit": "Ustaw nowe hasło"
|
||||
}
|
||||
81
frontend/src/i18n/locales/pl/settings.json
Normal file
81
frontend/src/i18n/locales/pl/settings.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"pageTitle": "Ustawienia",
|
||||
"install": {
|
||||
"title": "Aplikacja",
|
||||
"installed": "Aplikacja jest zainstalowana na tym urządzeniu",
|
||||
"installButton": "Zainstaluj aplikację",
|
||||
"iosPrompt": "Dotknij Udostępnij, a potem „Dodaj do ekranu początkowego”.",
|
||||
"secureContextRequired": "Instalacja wymaga bezpiecznego połączenia — otwórz aplikację pod adresem zaczynającym się od {{https}}, a nie przez lokalny adres IP.",
|
||||
"notOfferedYet": "Przeglądarka jeszcze nie zaproponowała instalacji. Odśwież stronę po chwili korzystania z aplikacji albo użyj menu przeglądarki (⋮) i wybierz „Zainstaluj aplikację” / „Dodaj do ekranu głównego”."
|
||||
},
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"nameLabel": "Imię",
|
||||
"emailLabel": "E-mail",
|
||||
"deleteAccountButton": "Usuń konto",
|
||||
"deleteConfirmTitle": "Usunąć swoje konto?",
|
||||
"deleteConfirmMessage": "Tej operacji nie da się cofnąć.",
|
||||
"deleteConfirmLabel": "Usuń konto"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Wygląd",
|
||||
"light": "Jasny",
|
||||
"dark": "Ciemny",
|
||||
"system": "Systemowy"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"emailLabel": "Powiadomienia mailowe",
|
||||
"emailDesc": "Dostaniesz e-mail, gdy ktoś w gospodarstwie doda nowy wydatek"
|
||||
},
|
||||
"households": {
|
||||
"title": "Twoje gospodarstwa",
|
||||
"memberCountOne": "{{count}} osoba",
|
||||
"memberCountOther": "{{count}} osoby/osób",
|
||||
"active": "Aktywne",
|
||||
"switch": "Przełącz",
|
||||
"createOrJoin": "Utwórz lub dołącz do gospodarstwa",
|
||||
"activeNameLabel": "Nazwa aktywnego gospodarstwa",
|
||||
"currencyLabel": "Waluta",
|
||||
"membersHeading": "Członkowie",
|
||||
"youSuffix": "(Ty)",
|
||||
"removeAria": "Usuń",
|
||||
"inviteAnother": "Zaproś kolejną osobę tym kodem:",
|
||||
"inviteLinkHint": "Albo wyślij link, który od razu przeniesie do rejestracji i dołączy do gospodarstwa:",
|
||||
"regenerateCode": "Wygeneruj nowy kod",
|
||||
"deleteHouseholdButton": "Usuń gospodarstwo",
|
||||
"leaveConfirmTitle": "Opuścić to gospodarstwo?",
|
||||
"leaveConfirmMessage": "Będziesz musiał(a) dołączyć ponownie kodem zaproszenia, żeby wrócić.",
|
||||
"leaveConfirmLabel": "Opuść",
|
||||
"removeMemberConfirmTitle": "Usunąć {{name}} z gospodarstwa?",
|
||||
"removeMemberConfirmMessage": "Ta osoba straci dostęp do gospodarstwa (historia wydatków zostanie zachowana).",
|
||||
"removeMemberConfirmLabel": "Usuń",
|
||||
"deleteHouseholdConfirmTitle": "Usunąć gospodarstwo „{{name}}”?",
|
||||
"deleteHouseholdConfirmMessage": "Usunie to całą historię wydatków wszystkich członków. Tej operacji nie da się cofnąć.",
|
||||
"deleteHouseholdConfirmLabel": "Usuń gospodarstwo"
|
||||
},
|
||||
"security": {
|
||||
"title": "Bezpieczeństwo",
|
||||
"currentPasswordLabel": "Bieżące hasło",
|
||||
"newPasswordLabel": "Nowe hasło",
|
||||
"confirmPasswordLabel": "Powtórz nowe hasło",
|
||||
"passwordMismatch": "Nowe hasła nie są takie same",
|
||||
"passwordChanged": "Hasło zostało zmienione",
|
||||
"changePasswordButton": "Zmień hasło"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorie",
|
||||
"iconLabel": "Ikona",
|
||||
"nameLabel": "Nazwa kategorii",
|
||||
"namePlaceholder": "np. Zwierzęta",
|
||||
"addButton": "Dodaj kategorię",
|
||||
"deleteAria": "Usuń",
|
||||
"deleteConfirmTitle": "Usunąć kategorię „{{name}}”?",
|
||||
"deleteConfirmMessage": "Istniejące wydatki zachowają swoją historię, ale stracą przypisaną kategorię.",
|
||||
"deleteConfirmLabel": "Usuń"
|
||||
},
|
||||
"data": {
|
||||
"title": "Dane",
|
||||
"downloadCsv": "Pobierz CSV"
|
||||
}
|
||||
}
|
||||
3
frontend/src/i18n/locales/pl/settingsButton.json
Normal file
3
frontend/src/i18n/locales/pl/settingsButton.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"settings": "Ustawienia"
|
||||
}
|
||||
7
frontend/src/i18n/locales/pl/settlementEditModal.json
Normal file
7
frontend/src/i18n/locales/pl/settlementEditModal.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "Edytuj rozliczenie",
|
||||
"whoPaidLabel": "Kto płacił?",
|
||||
"toWhomLabel": "Komu?",
|
||||
"deleteConfirmTitle": "Usunąć to rozliczenie?",
|
||||
"deleteConfirmMessage": "Saldo zostanie przeliczone tak, jakby ta płatność nigdy nie miała miejsca."
|
||||
}
|
||||
3
frontend/src/i18n/locales/pl/settlementListItem.json
Normal file
3
frontend/src/i18n/locales/pl/settlementListItem.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"settlement": "Rozliczenie"
|
||||
}
|
||||
17
frontend/src/i18n/locales/pl/settlements.json
Normal file
17
frontend/src/i18n/locales/pl/settlements.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"pageTitle": "Rozliczenia",
|
||||
"settlementsTitle": "Rozliczenia",
|
||||
"allSettled": "Jesteście na czysto!",
|
||||
"suggestedTitle": "Sugerowane rozliczenie (dotknij, aby wypełnić formularz poniżej)",
|
||||
"settleAllButton": "Rozlicz wszystko automatycznie",
|
||||
"settling": "Rozliczanie…",
|
||||
"recordPaymentTitle": "Zapisz płatność",
|
||||
"whoPaysLabel": "Kto płaci?",
|
||||
"toWhomLabel": "Komu?",
|
||||
"saveSettlementButton": "Zapisz rozliczenie",
|
||||
"saving": "Zapisywanie…",
|
||||
"savedNotice": "Zapisano płatność!",
|
||||
"historyTitle": "Historia rozliczeń",
|
||||
"emptyState": "Brak zapisanych rozliczeń",
|
||||
"selectBothAndAmount": "Wybierz obie osoby i podaj kwotę większą od zera"
|
||||
}
|
||||
9
frontend/src/i18n/locales/pl/splitSelector.json
Normal file
9
frontend/src/i18n/locales/pl/splitSelector.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"label": "Jak dzielimy wydatek?",
|
||||
"equal": "Po równo (50/50)",
|
||||
"exact": "Dokładny podział",
|
||||
"full": "Całość na jedną osobę",
|
||||
"autoCalculated": "(wyliczane automatycznie)",
|
||||
"overAllocated": "Suma udziałów przekracza kwotę wydatku",
|
||||
"owesFullAmount": "{{name}} winien(-na) całość"
|
||||
}
|
||||
6
frontend/src/i18n/locales/pl/stats.json
Normal file
6
frontend/src/i18n/locales/pl/stats.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pageTitle": "Statystyki",
|
||||
"monthOverMonthTitle": "Wydatki miesiąc do miesiąca",
|
||||
"comparisonTitle": "Kto więcej konsumuje (ten miesiąc)",
|
||||
"categoriesTitle": "Kategorie od najdroższej"
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App.jsx';
|
||||
import { AuthProvider } from './auth/AuthContext.jsx';
|
||||
import { ThemeProvider } from './theme/ThemeContext.jsx';
|
||||
import { I18nProvider } from './i18n/I18nContext.jsx';
|
||||
import { HouseholdProvider } from './household/HouseholdContext.jsx';
|
||||
import { ConfirmProvider } from './components/ConfirmDialogProvider.jsx';
|
||||
import ErrorBoundary from './components/ErrorBoundary.jsx';
|
||||
@@ -19,19 +20,21 @@ const queryClient = new QueryClient({
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<HouseholdProvider>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</HouseholdProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<HouseholdProvider>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</HouseholdProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { openDB } from 'idb';
|
||||
|
||||
const DB_NAME = 'ktoco-offline';
|
||||
const DB_NAME = 'whowhat-offline';
|
||||
const STORE = 'pending-expenses';
|
||||
|
||||
async function getDb() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useCategories, useCreateExpense } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import { queueExpense } from '../offline/db.js';
|
||||
import PayerToggle from '../components/PayerToggle.jsx';
|
||||
import SplitSelector from '../components/SplitSelector.jsx';
|
||||
@@ -18,6 +19,7 @@ export default function AddExpense() {
|
||||
const { activeHousehold: household } = useHouseholdContext();
|
||||
const { data: categories } = useCategories();
|
||||
const createExpense = useCreateExpense();
|
||||
const { t, tError } = useTranslation();
|
||||
|
||||
const members = household?.members || [];
|
||||
|
||||
@@ -49,21 +51,21 @@ export default function AddExpense() {
|
||||
setNotice('');
|
||||
|
||||
if (!amount || Number(amount) <= 0) {
|
||||
setError('Podaj poprawną kwotę');
|
||||
setError(t('addExpense.invalidAmount'));
|
||||
return;
|
||||
}
|
||||
if (!payerId) {
|
||||
setError('Wybierz kto płacił');
|
||||
setError(t('addExpense.selectPayer'));
|
||||
return;
|
||||
}
|
||||
if (splitType === 'full' && !fullOwedBy) {
|
||||
setError('Wybierz kto jest winien całość');
|
||||
setError(t('addExpense.selectFullOwedBy'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
amount: Number(amount),
|
||||
title: title || 'Wydatek',
|
||||
title: title || t('addExpense.defaultTitle'),
|
||||
categoryId,
|
||||
expenseDate,
|
||||
payerId,
|
||||
@@ -77,17 +79,17 @@ export default function AddExpense() {
|
||||
} catch (err) {
|
||||
if (!navigator.onLine) {
|
||||
await queueExpense(payload);
|
||||
setNotice('Brak sieci — wydatek zapisano lokalnie i zsynchronizuje się automatycznie.');
|
||||
setNotice(t('addExpense.offlineNotice'));
|
||||
setTimeout(() => navigate('/', { replace: true }), 1200);
|
||||
} else {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Dodaj wydatek</h1>
|
||||
<h1 className="page-title">{t('addExpense.pageTitle')}</h1>
|
||||
<form onSubmit={handleSubmit} className="expense-form">
|
||||
<input
|
||||
className="amount-input"
|
||||
@@ -101,12 +103,12 @@ export default function AddExpense() {
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Tytuł / opis</label>
|
||||
<input type="text" placeholder="np. Zakupy Biedronka" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<label>{t('addExpense.titleLabel')}</label>
|
||||
<input type="text" placeholder={t('addExpense.titlePlaceholder')} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kategoria</label>
|
||||
<label>{t('addExpense.categoryLabel')}</label>
|
||||
<div className="category-grid">
|
||||
{(categories || []).map((c) => (
|
||||
<div
|
||||
@@ -122,7 +124,7 @@ export default function AddExpense() {
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kto płacił?</label>
|
||||
<label>{t('addExpense.whoPaidLabel')}</label>
|
||||
<PayerToggle members={members} payerId={payerId} onChange={setPayerId} />
|
||||
</div>
|
||||
|
||||
@@ -138,7 +140,7 @@ export default function AddExpense() {
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Data</label>
|
||||
<label>{t('addExpense.dateLabel')}</label>
|
||||
<input type="date" value={expenseDate} onChange={(e) => setExpenseDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
@@ -146,7 +148,7 @@ export default function AddExpense() {
|
||||
{notice && <p className="form-error" style={{ color: '#1e40af' }}>{notice}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={createExpense.isPending}>
|
||||
{createExpense.isPending ? 'Zapisywanie…' : 'Dodaj wydatek'}
|
||||
{createExpense.isPending ? t('addExpense.saving') : t('addExpense.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useBalance, useSummary } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import CategoryPieChart from '../components/CategoryPieChart.jsx';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
|
||||
@@ -13,6 +14,7 @@ export default function Dashboard() {
|
||||
const { activeHousehold: household } = useHouseholdContext();
|
||||
const { data: balance } = useBalance();
|
||||
const { data: summary } = useSummary();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const members = household?.members || [];
|
||||
const currency = household?.currency || 'PLN';
|
||||
@@ -23,17 +25,17 @@ export default function Dashboard() {
|
||||
|
||||
<div className="card settlement-tile">
|
||||
{!balance ? (
|
||||
<p>Ładowanie…</p>
|
||||
<p>{t('common.loading')}</p>
|
||||
) : balance.settled ? (
|
||||
<>
|
||||
<p>Rozliczenia</p>
|
||||
<p>{t('dashboard.settlementsTitle')}</p>
|
||||
<p className="amount settled">
|
||||
Jesteście na czysto! <Icon name="celebration" />
|
||||
{t('dashboard.allSettled')} <Icon name="celebration" />
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Rozliczenia</p>
|
||||
<p>{t('dashboard.settlementsTitle')}</p>
|
||||
<div className="settlement-transactions">
|
||||
{balance.transactions.map((tx, i) => (
|
||||
<p key={i} className="amount owed">
|
||||
@@ -42,23 +44,23 @@ export default function Dashboard() {
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-primary btn-center" onClick={() => navigate('/settlements')}>
|
||||
Rozlicz się
|
||||
{t('dashboard.settleUpButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Wydatki wg kategorii (ten miesiąc)</h3>
|
||||
{!summary ? <p>Ładowanie…</p> : <CategoryPieChart data={summary.byCategory} currency={currency} />}
|
||||
<h3>{t('dashboard.expensesByCategoryTitle')}</h3>
|
||||
{!summary ? <p>{t('common.loading')}</p> : <CategoryPieChart data={summary.byCategory} currency={currency} />}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Podsumowanie miesiąca</h3>
|
||||
<h3>{t('dashboard.monthSummaryTitle')}</h3>
|
||||
{summary && (
|
||||
<div className="summary-grid">
|
||||
<div>
|
||||
<div className="label">Suma</div>
|
||||
<div className="label">{t('dashboard.totalLabel')}</div>
|
||||
<div className="value">{summary.total.toFixed(2)} {currency}</div>
|
||||
</div>
|
||||
{members.map((m) => (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForgotPassword } from '../api/queries.js';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const forgotPassword = useForgotPassword();
|
||||
const { t, tError } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -15,35 +17,35 @@ export default function ForgotPassword() {
|
||||
await forgotPassword.mutateAsync(email);
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Przypomnij hasło</h1>
|
||||
<h1>{t('forgotPassword.title')}</h1>
|
||||
{sent ? (
|
||||
<p>Jeśli konto z tym adresem e-mail istnieje, wysłaliśmy wiadomość z linkiem do resetu hasła.</p>
|
||||
<p>{t('forgotPassword.sentMessage')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="auth-subtitle">Podaj e-mail, na który wyślemy link do zresetowania hasła</p>
|
||||
<p className="auth-subtitle">{t('forgotPassword.subtitle')}</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
placeholder={t('forgotPassword.emailPlaceholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={forgotPassword.isPending}>
|
||||
{forgotPassword.isPending ? 'Wysyłanie…' : 'Wyślij link'}
|
||||
{forgotPassword.isPending ? t('forgotPassword.sending') : t('forgotPassword.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Link to="/login">Powrót do logowania</Link>
|
||||
<Link to="/login">{t('forgotPassword.backToLogin')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useCategories, useExpenses, useSettlements } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import ExpenseListItem from '../components/ExpenseListItem.jsx';
|
||||
import ExpenseEditModal from '../components/ExpenseEditModal.jsx';
|
||||
import SettlementListItem from '../components/SettlementListItem.jsx';
|
||||
@@ -30,6 +31,7 @@ export default function History() {
|
||||
const { user } = useAuth();
|
||||
const { activeHousehold: household } = useHouseholdContext();
|
||||
const { data: categories } = useCategories();
|
||||
const { t } = useTranslation();
|
||||
const [month, setMonth] = useState('');
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [payerId, setPayerId] = useState('');
|
||||
@@ -69,31 +71,31 @@ export default function History() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Historia</h1>
|
||||
<h1 className="page-title">{t('history.pageTitle')}</h1>
|
||||
|
||||
<div className="filters">
|
||||
<select value={month} onChange={(e) => setMonth(e.target.value)}>
|
||||
<option value="">Wszystkie miesiące</option>
|
||||
<option value="">{t('history.allMonths')}</option>
|
||||
{monthOptions().map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
<option value="">Wszystkie kategorie</option>
|
||||
<option value="">{t('history.allCategories')}</option>
|
||||
{(categories || []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={payerId} onChange={(e) => setPayerId(e.target.value)}>
|
||||
<option value="">Wszyscy płacący</option>
|
||||
<option value="">{t('history.allPayers')}</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading && <p>Ładowanie…</p>}
|
||||
{!isLoading && items.length === 0 && <p className="empty-state">Brak wpisów spełniających filtry</p>}
|
||||
{isLoading && <p>{t('common.loading')}</p>}
|
||||
{!isLoading && items.length === 0 && <p className="empty-state">{t('history.emptyState')}</p>}
|
||||
|
||||
{items.map((item) =>
|
||||
item.type === 'expense' ? (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useJoinHousehold } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
|
||||
export default function JoinInvite() {
|
||||
const { code } = useParams();
|
||||
@@ -10,6 +11,7 @@ export default function JoinInvite() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { switchHousehold } = useHouseholdContext();
|
||||
const joinHousehold = useJoinHousehold();
|
||||
const { t, tError } = useTranslation();
|
||||
const [error, setError] = useState('');
|
||||
const [attempted, setAttempted] = useState(false);
|
||||
|
||||
@@ -22,7 +24,7 @@ export default function JoinInvite() {
|
||||
switchHousehold(result.household.id);
|
||||
navigate('/', { replace: true });
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
.catch((err) => setError(tError(err)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, attempted, code]);
|
||||
|
||||
@@ -32,16 +34,16 @@ export default function JoinInvite() {
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Dołączanie do gospodarstwa</h1>
|
||||
<h1>{t('joinInvite.title')}</h1>
|
||||
{error ? (
|
||||
<>
|
||||
<p className="form-error">{error}</p>
|
||||
<button className="btn-primary" onClick={() => navigate('/', { replace: true })}>
|
||||
Przejdź do aplikacji
|
||||
{t('joinInvite.goToApp')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="auth-subtitle">Chwileczkę…</p>
|
||||
<p className="auth-subtitle">{t('joinInvite.waiting')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useJoinHousehold } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import PasswordField from '../components/PasswordField.jsx';
|
||||
|
||||
export default function Login() {
|
||||
@@ -12,6 +13,7 @@ export default function Login() {
|
||||
const inviteCode = params.get('code') || '';
|
||||
const joinHousehold = useJoinHousehold();
|
||||
const { switchHousehold } = useHouseholdContext();
|
||||
const { t, tError } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -33,7 +35,7 @@ export default function Login() {
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -41,24 +43,28 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>KtoCo</h1>
|
||||
<p className="auth-subtitle">
|
||||
{inviteCode ? 'Zaloguj się, aby dołączyć do zaproszonego gospodarstwa domowego' : 'Zaloguj się, by zarządzać wspólnymi wydatkami'}
|
||||
</p>
|
||||
<h1>{t('common.appName')}</h1>
|
||||
<p className="auth-subtitle">{inviteCode ? t('login.subtitleInvite') : t('login.subtitle')}</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input type="email" placeholder="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<PasswordField placeholder="Hasło" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<PasswordField placeholder={t('login.passwordPlaceholder')} value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Logowanie…' : 'Zaloguj się'}
|
||||
{loading ? t('login.loggingIn') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
<Link to="/forgot-password">Zapomniałeś hasła?</Link>
|
||||
<Link to="/forgot-password">{t('login.forgotPassword')}</Link>
|
||||
</p>
|
||||
<p>
|
||||
Nie masz konta?{' '}
|
||||
<Link to={inviteCode ? `/register?code=${encodeURIComponent(inviteCode)}` : '/register'}>Zarejestruj się</Link>
|
||||
{t('login.noAccount')}{' '}
|
||||
<Link to={inviteCode ? `/register?code=${encodeURIComponent(inviteCode)}` : '/register'}>{t('login.register')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,15 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCreateHousehold, useJoinHousehold } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
|
||||
export default function Onboarding() {
|
||||
const navigate = useNavigate();
|
||||
const { switchHousehold } = useHouseholdContext();
|
||||
const { t, tError } = useTranslation();
|
||||
const [mode, setMode] = useState('create');
|
||||
const [name, setName] = useState('Nasze gospodarstwo');
|
||||
const [name, setName] = useState(t('onboarding.defaultHouseholdName'));
|
||||
const [currency, setCurrency] = useState('PLN');
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -24,7 +26,7 @@ export default function Onboarding() {
|
||||
switchHousehold(result.household.id);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,30 +38,35 @@ export default function Onboarding() {
|
||||
switchHousehold(result.household.id);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<button type="button" className="icon-btn back-btn" onClick={() => navigate(-1)} aria-label="Cofnij">
|
||||
<Icon name="arrow_back" /> Cofnij
|
||||
<button type="button" className="icon-btn back-btn" onClick={() => navigate(-1)} aria-label={t('onboarding.back')}>
|
||||
<Icon name="arrow_back" /> {t('onboarding.back')}
|
||||
</button>
|
||||
<h1>Witaj!</h1>
|
||||
<p className="auth-subtitle">Załóż nowe gospodarstwo domowe albo dołącz do partnera/partnerki kodem</p>
|
||||
<h1>{t('onboarding.title')}</h1>
|
||||
<p className="auth-subtitle">{t('onboarding.subtitle')}</p>
|
||||
|
||||
<div className="tabs">
|
||||
<button className={mode === 'create' ? 'tab active' : 'tab'} onClick={() => setMode('create')}>
|
||||
Utwórz
|
||||
{t('onboarding.createTab')}
|
||||
</button>
|
||||
<button className={mode === 'join' ? 'tab active' : 'tab'} onClick={() => setMode('join')}>
|
||||
Dołącz kodem
|
||||
{t('onboarding.joinTab')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'create' ? (
|
||||
<form onSubmit={handleCreate} className="auth-form">
|
||||
<input type="text" placeholder="Nazwa gospodarstwa" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('onboarding.householdNamePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<select value={currency} onChange={(e) => setCurrency(e.target.value)}>
|
||||
<option value="PLN">PLN</option>
|
||||
<option value="EUR">EUR</option>
|
||||
@@ -67,21 +74,21 @@ export default function Onboarding() {
|
||||
</select>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={createHousehold.isPending}>
|
||||
{createHousehold.isPending ? 'Tworzenie…' : 'Utwórz gospodarstwo'}
|
||||
{createHousehold.isPending ? t('onboarding.creating') : t('onboarding.createButton')}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleJoin} className="auth-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kod zaproszenia"
|
||||
placeholder={t('onboarding.inviteCodePlaceholder')}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
required
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={joinHousehold.isPending}>
|
||||
{joinHousehold.isPending ? 'Dołączanie…' : 'Dołącz'}
|
||||
{joinHousehold.isPending ? t('onboarding.joining') : t('onboarding.joinButton')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useJoinHousehold } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import PasswordField from '../components/PasswordField.jsx';
|
||||
|
||||
export default function Register() {
|
||||
@@ -12,6 +13,7 @@ export default function Register() {
|
||||
const inviteCode = params.get('code') || '';
|
||||
const joinHousehold = useJoinHousehold();
|
||||
const { switchHousehold } = useHouseholdContext();
|
||||
const { t, tError } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -32,7 +34,7 @@ export default function Register() {
|
||||
navigate('/onboarding', { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -40,28 +42,30 @@ export default function Register() {
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>KtoCo</h1>
|
||||
<p className="auth-subtitle">
|
||||
{inviteCode
|
||||
? 'Załóż konto, aby dołączyć do zaproszonego gospodarstwa domowego'
|
||||
: 'Załóż konto, by zacząć dzielić wydatki'}
|
||||
</p>
|
||||
<h1>{t('common.appName')}</h1>
|
||||
<p className="auth-subtitle">{inviteCode ? t('register.subtitleInvite') : t('register.subtitle')}</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input type="text" placeholder="Imię" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
<input type="email" placeholder="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<input type="text" placeholder={t('register.namePlaceholder')} value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t('register.emailPlaceholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<PasswordField
|
||||
placeholder="Hasło (min. 6 znaków)"
|
||||
placeholder={t('register.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Tworzenie konta…' : 'Zarejestruj się'}
|
||||
{loading ? t('register.creating') : t('register.submit')}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
Masz już konto?{' '}
|
||||
<Link to={inviteCode ? `/login?code=${encodeURIComponent(inviteCode)}` : '/login'}>Zaloguj się</Link>
|
||||
{t('register.haveAccount')}{' '}
|
||||
<Link to={inviteCode ? `/login?code=${encodeURIComponent(inviteCode)}` : '/login'}>{t('register.login')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useResetPassword } from '../api/queries.js';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import PasswordField from '../components/PasswordField.jsx';
|
||||
|
||||
export default function ResetPassword() {
|
||||
@@ -8,6 +9,7 @@ export default function ResetPassword() {
|
||||
const [params] = useSearchParams();
|
||||
const token = params.get('token') || '';
|
||||
const resetPassword = useResetPassword();
|
||||
const { t, tError } = useTranslation();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
@@ -18,7 +20,7 @@ export default function ResetPassword() {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (password !== confirm) {
|
||||
setError('Hasła nie są takie same');
|
||||
setError(t('resetPassword.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -26,32 +28,40 @@ export default function ResetPassword() {
|
||||
setDone(true);
|
||||
setTimeout(() => navigate('/login', { replace: true }), 1500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Nieprawidłowy link</h1>
|
||||
<p>Brakuje tokenu resetu hasła. Poproś o nowy link.</p>
|
||||
<p><Link to="/forgot-password">Przypomnij hasło</Link></p>
|
||||
<h1>{t('resetPassword.invalidLinkTitle')}</h1>
|
||||
<p>{t('resetPassword.invalidLinkMessage')}</p>
|
||||
<p><Link to="/forgot-password">{t('resetPassword.requestNewLink')}</Link></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Ustaw nowe hasło</h1>
|
||||
<h1>{t('resetPassword.title')}</h1>
|
||||
{done ? (
|
||||
<p>Hasło zostało zmienione. Przenoszenie do logowania…</p>
|
||||
<p>{t('resetPassword.done')}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<PasswordField placeholder="Nowe hasło (min. 6 znaków)" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<PasswordField placeholder="Powtórz nowe hasło" value={confirm} onChange={(e) => setConfirm(e.target.value)} />
|
||||
<PasswordField
|
||||
placeholder={t('resetPassword.newPasswordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<PasswordField
|
||||
placeholder={t('resetPassword.confirmPasswordPlaceholder')}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={resetPassword.isPending}>
|
||||
{resetPassword.isPending ? 'Zapisywanie…' : 'Ustaw nowe hasło'}
|
||||
{resetPassword.isPending ? t('resetPassword.saving') : t('resetPassword.submit')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -21,9 +21,11 @@ import { useTheme } from '../theme/ThemeContext.jsx';
|
||||
import { useInstallPrompt } from '../pwa/useInstallPrompt.js';
|
||||
import { useConfirm } from '../components/ConfirmDialogProvider.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
import Switch from '../components/Switch.jsx';
|
||||
import PasswordField from '../components/PasswordField.jsx';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.jsx';
|
||||
|
||||
const CURRENCIES = ['PLN', 'EUR', 'USD', 'GBP'];
|
||||
const ICON_CHOICES = [
|
||||
@@ -34,21 +36,19 @@ const ICON_CHOICES = [
|
||||
'movie', 'wifi', 'local_hospital', 'directions_bike', 'cleaning_services',
|
||||
];
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'light', label: 'Jasny', icon: 'light_mode' },
|
||||
{ value: 'dark', label: 'Ciemny', icon: 'dark_mode' },
|
||||
{ value: 'system', label: 'Systemowy', icon: 'contrast' },
|
||||
];
|
||||
|
||||
async function downloadCsv() {
|
||||
const res = await fetch('/api/stats/export.csv', {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
// Blob URLs don't carry the response's Content-Disposition, so the (already
|
||||
// language-appropriate) filename has to be read back out of the header explicitly.
|
||||
const disposition = res.headers.get('content-disposition') || '';
|
||||
const filename = disposition.match(/filename="([^"]+)"/)?.[1] || 'export.csv';
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'wydatki.csv';
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ async function downloadCsv() {
|
||||
function CopyField({ value, big }) {
|
||||
const inputRef = useRef(null);
|
||||
const [status, setStatus] = useState(null); // 'copied' | 'failed'
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function handleCopy() {
|
||||
if (!value) return;
|
||||
@@ -91,25 +92,24 @@ function CopyField({ value, big }) {
|
||||
value={value || ''}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<button type="button" className="invite-code-copy-btn" onClick={handleCopy} aria-label="Kopiuj">
|
||||
<button type="button" className="invite-code-copy-btn" onClick={handleCopy} aria-label={t('common.copy')}>
|
||||
<Icon name={status === 'copied' ? 'check' : 'content_copy'} />
|
||||
</button>
|
||||
</div>
|
||||
{status === 'copied' && <p className="invite-code-hint success">Skopiowano do schowka!</p>}
|
||||
{status === 'failed' && (
|
||||
<p className="invite-code-hint error">Nie udało się skopiować automatycznie — zaznacz pole powyżej i skopiuj ręcznie.</p>
|
||||
)}
|
||||
{status === 'copied' && <p className="invite-code-hint success">{t('common.copied')}</p>}
|
||||
{status === 'failed' && <p className="invite-code-hint error">{t('common.copyFailed')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCode({ code }) {
|
||||
const { t } = useTranslation();
|
||||
const inviteLink = code ? `${window.location.origin}/join/${code}` : '';
|
||||
return (
|
||||
<div>
|
||||
<CopyField value={code} big />
|
||||
<p className="switch-desc" style={{ marginTop: 12 }}>
|
||||
Albo wyślij link, który od razu przeniesie do rejestracji i dołączy do gospodarstwa:
|
||||
{t('settings.households.inviteLinkHint')}
|
||||
</p>
|
||||
<CopyField value={inviteLink} />
|
||||
</div>
|
||||
@@ -118,37 +118,31 @@ function InviteCode({ code }) {
|
||||
|
||||
function InstallSection() {
|
||||
const { canInstall, isInstalled, isIOS, isSecureContext, promptInstall } = useInstallPrompt();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Aplikacja</h3>
|
||||
<h3>{t('settings.install.title')}</h3>
|
||||
{isInstalled && (
|
||||
<p className="switch-desc">
|
||||
<Icon name="check_circle" /> Aplikacja jest zainstalowana na tym urządzeniu
|
||||
<Icon name="check_circle" /> {t('settings.install.installed')}
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled && canInstall && (
|
||||
<button className="btn-primary btn-full" onClick={promptInstall}>
|
||||
<Icon name="install_mobile" /> Zainstaluj aplikację
|
||||
<Icon name="install_mobile" /> {t('settings.install.installButton')}
|
||||
</button>
|
||||
)}
|
||||
{!isInstalled && isIOS && (
|
||||
<p className="switch-desc">
|
||||
Dotknij <Icon name="ios_share" className="inline-icon" /> Udostępnij, a potem „Dodaj do ekranu
|
||||
początkowego”.
|
||||
<Icon name="ios_share" className="inline-icon" /> {t('settings.install.iosPrompt')}
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled && !canInstall && !isIOS && !isSecureContext && (
|
||||
<p className="switch-desc">
|
||||
Instalacja wymaga bezpiecznego połączenia — otwórz aplikację pod adresem zaczynającym się od{' '}
|
||||
<strong>https://</strong>, a nie przez lokalny adres IP.
|
||||
</p>
|
||||
<p className="switch-desc">{t('settings.install.secureContextRequired', { https: 'https://' })}</p>
|
||||
)}
|
||||
{!isInstalled && !canInstall && !isIOS && isSecureContext && (
|
||||
<p className="switch-desc">
|
||||
Przeglądarka jeszcze nie zaproponowała instalacji. Odśwież stronę po chwili korzystania z aplikacji albo
|
||||
użyj menu przeglądarki (⋮) i wybierz „Zainstaluj aplikację” / „Dodaj do ekranu głównego”.
|
||||
</p>
|
||||
<p className="switch-desc">{t('settings.install.notOfferedYet')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -161,6 +155,7 @@ function AccountSection() {
|
||||
const updateProfile = useUpdateProfile();
|
||||
const deleteAccount = useDeleteAccount();
|
||||
const confirmDialog = useConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [nameSaved, setNameSaved] = useState(false);
|
||||
@@ -177,9 +172,9 @@ function AccountSection() {
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
const ok = await confirmDialog({
|
||||
title: 'Usunąć swoje konto?',
|
||||
message: 'Tej operacji nie da się cofnąć.',
|
||||
confirmLabel: 'Usuń konto',
|
||||
title: t('settings.account.deleteConfirmTitle'),
|
||||
message: t('settings.account.deleteConfirmMessage'),
|
||||
confirmLabel: t('settings.account.deleteConfirmLabel'),
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteAccount.mutateAsync();
|
||||
@@ -189,19 +184,19 @@ function AccountSection() {
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Konto</h3>
|
||||
<h3>{t('settings.account.title')}</h3>
|
||||
<div className="field">
|
||||
<label>Imię</label>
|
||||
<label>{t('settings.account.nameLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={name || me.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={handleNameBlur}
|
||||
/>
|
||||
{nameSaved && <p className="invite-code-hint success">Zapisano</p>}
|
||||
{nameSaved && <p className="invite-code-hint success">{t('common.saved')}</p>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>E-mail</label>
|
||||
<label>{t('settings.account.emailLabel')}</label>
|
||||
<input type="email" value={me.email} readOnly disabled />
|
||||
</div>
|
||||
<button
|
||||
@@ -210,7 +205,7 @@ function AccountSection() {
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteAccount.isPending}
|
||||
>
|
||||
<Icon name="delete_forever" /> Usuń konto
|
||||
<Icon name="delete_forever" /> {t('settings.account.deleteAccountButton')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -224,15 +219,18 @@ function HouseholdsSection() {
|
||||
const removeMember = useRemoveMember();
|
||||
const deleteHousehold = useDeleteHousehold();
|
||||
const confirmDialog = useConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function handleRemoveMember(m) {
|
||||
const isSelf = m.id === user?.id;
|
||||
const ok = await confirmDialog({
|
||||
title: isSelf ? 'Opuścić to gospodarstwo?' : `Usunąć ${m.name} z gospodarstwa?`,
|
||||
title: isSelf
|
||||
? t('settings.households.leaveConfirmTitle')
|
||||
: t('settings.households.removeMemberConfirmTitle', { name: m.name }),
|
||||
message: isSelf
|
||||
? 'Będziesz musiał(a) dołączyć ponownie kodem zaproszenia, żeby wrócić.'
|
||||
: 'Ta osoba straci dostęp do gospodarstwa (historia wydatków zostanie zachowana).',
|
||||
confirmLabel: isSelf ? 'Opuść' : 'Usuń',
|
||||
? t('settings.households.leaveConfirmMessage')
|
||||
: t('settings.households.removeMemberConfirmMessage'),
|
||||
confirmLabel: isSelf ? t('settings.households.leaveConfirmLabel') : t('settings.households.removeMemberConfirmLabel'),
|
||||
});
|
||||
if (!ok) return;
|
||||
await removeMember.mutateAsync({ householdId: activeHousehold.id, userId: m.id });
|
||||
@@ -240,9 +238,9 @@ function HouseholdsSection() {
|
||||
|
||||
async function handleDeleteHousehold() {
|
||||
const ok = await confirmDialog({
|
||||
title: `Usunąć gospodarstwo „${activeHousehold.name}”?`,
|
||||
message: 'Usunie to całą historię wydatków wszystkich członków. Tej operacji nie da się cofnąć.',
|
||||
confirmLabel: 'Usuń gospodarstwo',
|
||||
title: t('settings.households.deleteHouseholdConfirmTitle', { name: activeHousehold.name }),
|
||||
message: t('settings.households.deleteHouseholdConfirmMessage'),
|
||||
confirmLabel: t('settings.households.deleteHouseholdConfirmLabel'),
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteHousehold.mutateAsync(activeHousehold.id);
|
||||
@@ -250,31 +248,35 @@ function HouseholdsSection() {
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Twoje gospodarstwa</h3>
|
||||
<h3>{t('settings.households.title')}</h3>
|
||||
{households.map((h) => (
|
||||
<div className="household-row" key={h.id}>
|
||||
<div className="household-row-main">
|
||||
<div>{h.name}</div>
|
||||
<div className="switch-desc">{h.members.length} {h.members.length === 1 ? 'osoba' : 'osoby/osób'}</div>
|
||||
<div className="switch-desc">
|
||||
{h.members.length === 1
|
||||
? t('settings.households.memberCountOne', { count: h.members.length })
|
||||
: t('settings.households.memberCountOther', { count: h.members.length })}
|
||||
</div>
|
||||
</div>
|
||||
{h.id === activeHouseholdId ? (
|
||||
<span className="badge-active">Aktywne</span>
|
||||
<span className="badge-active">{t('settings.households.active')}</span>
|
||||
) : (
|
||||
<button className="btn-secondary" onClick={() => switchHousehold(h.id)}>
|
||||
Przełącz
|
||||
{t('settings.households.switch')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Link to="/onboarding" className="btn-secondary btn-full" style={{ marginTop: 12 }}>
|
||||
<Icon name="add" /> Utwórz lub dołącz do gospodarstwa
|
||||
<Icon name="add" /> {t('settings.households.createOrJoin')}
|
||||
</Link>
|
||||
|
||||
{activeHousehold && (
|
||||
<>
|
||||
<hr className="section-divider" />
|
||||
<div className="field">
|
||||
<label>Nazwa aktywnego gospodarstwa</label>
|
||||
<label>{t('settings.households.activeNameLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={activeHousehold.name}
|
||||
@@ -285,7 +287,7 @@ function HouseholdsSection() {
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Waluta</label>
|
||||
<label>{t('settings.households.currencyLabel')}</label>
|
||||
<select
|
||||
value={activeHousehold.currency}
|
||||
onChange={(e) => updateHousehold.mutate({ id: activeHousehold.id, currency: e.target.value })}
|
||||
@@ -296,25 +298,25 @@ function HouseholdsSection() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h4>Członkowie</h4>
|
||||
<h4>{t('settings.households.membersHeading')}</h4>
|
||||
{activeHousehold.members.map((m) => (
|
||||
<div className="member-row" key={m.id}>
|
||||
<Icon name="account_circle" style={{ fontSize: '28px' }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>{m.name} {m.id === user?.id && '(Ty)'}</div>
|
||||
<div>{m.name} {m.id === user?.id && t('settings.households.youSuffix')}</div>
|
||||
<div className="meta" style={{ color: 'var(--muted)', fontSize: '0.8rem' }}>{m.email}</div>
|
||||
</div>
|
||||
<button className="icon-btn" onClick={() => handleRemoveMember(m)} aria-label="Usuń">
|
||||
<button className="icon-btn" onClick={() => handleRemoveMember(m)} aria-label={t('settings.households.removeAria')}>
|
||||
<Icon name={m.id === user?.id ? 'logout' : 'person_remove'} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p>Zaproś kolejną osobę tym kodem:</p>
|
||||
<p>{t('settings.households.inviteAnother')}</p>
|
||||
<InviteCode code={activeHousehold.inviteCode} />
|
||||
<button className="btn-secondary btn-full" onClick={() => regenerateInvite.mutate(activeHousehold.id)}>
|
||||
Wygeneruj nowy kod
|
||||
{t('settings.households.regenerateCode')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -324,7 +326,7 @@ function HouseholdsSection() {
|
||||
onClick={handleDeleteHousehold}
|
||||
disabled={deleteHousehold.isPending}
|
||||
>
|
||||
<Icon name="delete_forever" /> Usuń gospodarstwo
|
||||
<Icon name="delete_forever" /> {t('settings.households.deleteHouseholdButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -344,6 +346,13 @@ export default function Settings() {
|
||||
const updateNotifications = useUpdateNotifications();
|
||||
const changePassword = useChangePassword();
|
||||
const confirmDialog = useConfirm();
|
||||
const { t, tError } = useTranslation();
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'light', label: t('settings.appearance.light'), icon: 'light_mode' },
|
||||
{ value: 'dark', label: t('settings.appearance.dark'), icon: 'dark_mode' },
|
||||
{ value: 'system', label: t('settings.appearance.system'), icon: 'contrast' },
|
||||
];
|
||||
|
||||
const [editingCategoryId, setEditingCategoryId] = useState(null);
|
||||
const [catName, setCatName] = useState('');
|
||||
@@ -369,9 +378,9 @@ export default function Settings() {
|
||||
|
||||
async function handleDeleteCategory(c) {
|
||||
const ok = await confirmDialog({
|
||||
title: `Usunąć kategorię „${c.name}”?`,
|
||||
message: 'Istniejące wydatki zachowają swoją historię, ale stracą przypisaną kategorię.',
|
||||
confirmLabel: 'Usuń',
|
||||
title: t('settings.categories.deleteConfirmTitle', { name: c.name }),
|
||||
message: t('settings.categories.deleteConfirmMessage'),
|
||||
confirmLabel: t('settings.categories.deleteConfirmLabel'),
|
||||
});
|
||||
if (!ok) return;
|
||||
deleteCategory.mutate(c.id);
|
||||
@@ -393,7 +402,7 @@ export default function Settings() {
|
||||
setPasswordError('');
|
||||
setPasswordSuccess(false);
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('Nowe hasła nie są takie same');
|
||||
setPasswordError(t('settings.security.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -404,20 +413,20 @@ export default function Settings() {
|
||||
setPasswordSuccess(true);
|
||||
setTimeout(() => setPasswordSuccess(false), 3000);
|
||||
} catch (err) {
|
||||
setPasswordError(err.message);
|
||||
setPasswordError(tError(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Ustawienia</h1>
|
||||
<h1 className="page-title">{t('settings.pageTitle')}</h1>
|
||||
|
||||
<InstallSection />
|
||||
|
||||
<AccountSection />
|
||||
|
||||
<div className="card">
|
||||
<h3>Wygląd</h3>
|
||||
<h3>{t('settings.appearance.title')}</h3>
|
||||
<div className="theme-toggle">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -432,12 +441,14 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="card">
|
||||
<h3>Powiadomienia</h3>
|
||||
<h3>{t('settings.notifications.title')}</h3>
|
||||
<div className="switch-row">
|
||||
<div className="switch-label">
|
||||
<span>Powiadomienia mailowe</span>
|
||||
<span className="switch-desc">Dostaniesz e-mail, gdy ktoś w gospodarstwie doda nowy wydatek</span>
|
||||
<span>{t('settings.notifications.emailLabel')}</span>
|
||||
<span className="switch-desc">{t('settings.notifications.emailDesc')}</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!me?.emailNotifications}
|
||||
@@ -450,31 +461,31 @@ export default function Settings() {
|
||||
<HouseholdsSection />
|
||||
|
||||
<div className="card">
|
||||
<h3>Bezpieczeństwo</h3>
|
||||
<h3>{t('settings.security.title')}</h3>
|
||||
<form onSubmit={handlePasswordSubmit} className="expense-form">
|
||||
<div className="field">
|
||||
<label>Bieżące hasło</label>
|
||||
<label>{t('settings.security.currentPasswordLabel')}</label>
|
||||
<PasswordField value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nowe hasło</label>
|
||||
<label>{t('settings.security.newPasswordLabel')}</label>
|
||||
<PasswordField value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Powtórz nowe hasło</label>
|
||||
<label>{t('settings.security.confirmPasswordLabel')}</label>
|
||||
<PasswordField value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
|
||||
</div>
|
||||
{passwordError && <p className="form-error">{passwordError}</p>}
|
||||
{passwordSuccess && <p className="invite-code-hint success">Hasło zostało zmienione</p>}
|
||||
{passwordSuccess && <p className="invite-code-hint success">{t('settings.security.passwordChanged')}</p>}
|
||||
<button type="submit" className="btn-primary btn-full" disabled={changePassword.isPending}>
|
||||
{changePassword.isPending ? 'Zapisywanie…' : 'Zmień hasło'}
|
||||
{changePassword.isPending ? t('common.saving') : t('settings.security.changePasswordButton')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{activeHouseholdId && (
|
||||
<div className="card">
|
||||
<h3>Kategorie</h3>
|
||||
<h3>{t('settings.categories.title')}</h3>
|
||||
{(categories || []).map((c) => (
|
||||
<div className="category-manage-row" key={c.id}>
|
||||
<button
|
||||
@@ -484,7 +495,7 @@ export default function Settings() {
|
||||
<Icon name={c.icon} className="icon" />
|
||||
<span className="name">{c.name}</span>
|
||||
</button>
|
||||
<button className="icon-btn" onClick={() => deleteCategory.mutate(c.id)}>
|
||||
<button className="icon-btn" onClick={() => deleteCategory.mutate(c.id)} aria-label={t('settings.categories.deleteAria')}>
|
||||
<Icon name="delete" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -492,7 +503,7 @@ export default function Settings() {
|
||||
|
||||
<form onSubmit={handleCategorySubmit} style={{ marginTop: 16 }}>
|
||||
<div className="field">
|
||||
<label>Ikona</label>
|
||||
<label>{t('settings.categories.iconLabel')}</label>
|
||||
<div className="category-grid">
|
||||
{ICON_CHOICES.map((icon) => (
|
||||
<div
|
||||
@@ -506,10 +517,10 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 12 }}>
|
||||
<label>Nazwa kategorii</label>
|
||||
<label>{t('settings.categories.nameLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="np. Zwierzęta"
|
||||
placeholder={t('settings.categories.namePlaceholder')}
|
||||
value={catName}
|
||||
onChange={(e) => setCatName(e.target.value)}
|
||||
/>
|
||||
@@ -517,11 +528,11 @@ export default function Settings() {
|
||||
<div className="modal-actions" style={{ marginTop: 12 }}>
|
||||
{editingCategoryId && (
|
||||
<button type="button" className="btn-secondary" onClick={cancelEditCategory}>
|
||||
Anuluj
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="btn-primary">
|
||||
{editingCategoryId ? 'Zapisz zmiany' : 'Dodaj kategorię'}
|
||||
{editingCategoryId ? t('common.saveChanges') : t('settings.categories.addButton')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -529,12 +540,12 @@ export default function Settings() {
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h3>Dane</h3>
|
||||
<button className="btn-secondary btn-full" onClick={downloadCsv}>Pobierz CSV</button>
|
||||
<h3>{t('settings.data.title')}</h3>
|
||||
<button className="btn-secondary btn-full" onClick={downloadCsv}>{t('settings.data.downloadCsv')}</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<button className="btn-secondary btn-full" onClick={logout}>Wyloguj się</button>
|
||||
<button className="btn-secondary btn-full" onClick={logout}>{t('common.logout')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useBalance, useSettleUp, useSettlements, useCreateManualSettlement } from '../api/queries.js';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import PayerToggle from '../components/PayerToggle.jsx';
|
||||
import SettlementListItem from '../components/SettlementListItem.jsx';
|
||||
import SettlementEditModal from '../components/SettlementEditModal.jsx';
|
||||
@@ -16,6 +17,7 @@ export default function Settlements() {
|
||||
const { data: settlements } = useSettlements();
|
||||
const settleUp = useSettleUp();
|
||||
const createManual = useCreateManualSettlement();
|
||||
const { t, tError } = useTranslation();
|
||||
|
||||
const members = household?.members || [];
|
||||
const currency = household?.currency || 'PLN';
|
||||
@@ -65,7 +67,7 @@ export default function Settlements() {
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
if (!fromUserId || !toUserId || !amount || Number(amount) <= 0) {
|
||||
setError('Wybierz obie osoby i podaj kwotę większą od zera');
|
||||
setError(t('settlements.selectBothAndAmount'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -74,27 +76,27 @@ export default function Settlements() {
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 2500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setError(tError(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Rozliczenia</h1>
|
||||
<h1 className="page-title">{t('settlements.pageTitle')}</h1>
|
||||
|
||||
<div className="card settlement-tile">
|
||||
{!balance ? (
|
||||
<p>Ładowanie…</p>
|
||||
<p>{t('common.loading')}</p>
|
||||
) : balance.settled ? (
|
||||
<>
|
||||
<p>Rozliczenia</p>
|
||||
<p>{t('settlements.settlementsTitle')}</p>
|
||||
<p className="amount settled">
|
||||
Jesteście na czysto! <Icon name="celebration" />
|
||||
{t('settlements.allSettled')} <Icon name="celebration" />
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Sugerowane rozliczenie (dotknij, aby wypełnić formularz poniżej)</p>
|
||||
<p>{t('settlements.suggestedTitle')}</p>
|
||||
<div className="settlement-transactions">
|
||||
{balance.transactions.map((tx, i) => (
|
||||
<button key={i} type="button" className="amount owed suggestion-btn" onClick={() => applySuggestion(tx)}>
|
||||
@@ -107,21 +109,21 @@ export default function Settlements() {
|
||||
disabled={settleUp.isPending}
|
||||
onClick={() => settleUp.mutate()}
|
||||
>
|
||||
{settleUp.isPending ? 'Rozliczanie…' : 'Rozlicz wszystko automatycznie'}
|
||||
{settleUp.isPending ? t('settlements.settling') : t('settlements.settleAllButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Zapisz płatność</h3>
|
||||
<h3>{t('settlements.recordPaymentTitle')}</h3>
|
||||
<form onSubmit={handleSubmit} className="expense-form">
|
||||
<div className="field">
|
||||
<label>Kto płaci?</label>
|
||||
<label>{t('settlements.whoPaysLabel')}</label>
|
||||
<PayerToggle members={members} payerId={fromUserId} onChange={handleFromChange} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Komu?</label>
|
||||
<label>{t('settlements.toWhomLabel')}</label>
|
||||
<PayerToggle members={toOptions} payerId={toUserId} onChange={setToUserId} />
|
||||
</div>
|
||||
<input
|
||||
@@ -134,18 +136,18 @@ export default function Settlements() {
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
{success && <p className="invite-code-hint success">Zapisano płatność!</p>}
|
||||
{success && <p className="invite-code-hint success">{t('settlements.savedNotice')}</p>}
|
||||
<button type="submit" className="btn-primary btn-full" disabled={createManual.isPending}>
|
||||
{createManual.isPending ? 'Zapisywanie…' : 'Zapisz rozliczenie'}
|
||||
{createManual.isPending ? t('settlements.saving') : t('settlements.saveSettlementButton')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Historia rozliczeń</h3>
|
||||
{!settlements && <p>Ładowanie…</p>}
|
||||
<h3>{t('settlements.historyTitle')}</h3>
|
||||
{!settlements && <p>{t('common.loading')}</p>}
|
||||
{settlements && settlements.length === 0 && (
|
||||
<p className="empty-state">Brak zapisanych rozliczeń</p>
|
||||
<p className="empty-state">{t('settlements.emptyState')}</p>
|
||||
)}
|
||||
{(settlements || []).slice(0, 10).map((s) => (
|
||||
<SettlementListItem
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMonthly, useSummary } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||
import MonthlyBarChart from '../components/MonthlyBarChart.jsx';
|
||||
import PayerComparisonChart from '../components/PayerComparisonChart.jsx';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
@@ -8,37 +9,38 @@ export default function Stats() {
|
||||
const { activeHousehold: household } = useHouseholdContext();
|
||||
const { data: monthly } = useMonthly();
|
||||
const { data: summary } = useSummary();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const members = household?.members || [];
|
||||
const currency = household?.currency || 'PLN';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Statystyki</h1>
|
||||
<h1 className="page-title">{t('stats.pageTitle')}</h1>
|
||||
|
||||
<div className="card">
|
||||
<h3>Wydatki miesiąc do miesiąca</h3>
|
||||
{!monthly ? <p>Ładowanie…</p> : <MonthlyBarChart data={monthly} currency={currency} />}
|
||||
<h3>{t('stats.monthOverMonthTitle')}</h3>
|
||||
{!monthly ? <p>{t('common.loading')}</p> : <MonthlyBarChart data={monthly} currency={currency} />}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Kto więcej konsumuje (ten miesiąc)</h3>
|
||||
<h3>{t('stats.comparisonTitle')}</h3>
|
||||
{!summary ? (
|
||||
<p>Ładowanie…</p>
|
||||
<p>{t('common.loading')}</p>
|
||||
) : (
|
||||
<PayerComparisonChart members={members} byShare={summary.byShare} currency={currency} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Kategorie od najdroższej</h3>
|
||||
<h3>{t('stats.categoriesTitle')}</h3>
|
||||
{summary && (
|
||||
<div>
|
||||
{summary.byCategory.length === 0 && <p className="empty-state">Brak wydatków w tym miesiącu</p>}
|
||||
{summary.byCategory.length === 0 && <p className="empty-state">{t('charts.noExpensesThisMonth')}</p>}
|
||||
{summary.byCategory.map((c) => (
|
||||
<div key={c.categoryId || 'none'} className="category-manage-row">
|
||||
<Icon name={c.icon || 'inventory_2'} className="icon" />
|
||||
<span className="name">{c.name || 'Bez kategorii'}</span>
|
||||
<span className="name">{c.name || t('charts.noCategory')}</span>
|
||||
<strong>{c.total.toFixed(2)} {currency}</strong>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const THEME_KEY = 'ktoco_theme';
|
||||
const THEME_KEY = 'whowhat_theme';
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
function applyTheme(theme) {
|
||||
|
||||
@@ -9,9 +9,9 @@ export default defineConfig({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['icons/icon-192.png', 'icons/icon-512.png'],
|
||||
manifest: {
|
||||
name: 'KtoCo - Wydatki wspólne',
|
||||
short_name: 'KtoCo',
|
||||
description: 'Dzielenie wydatków dla par i współlokatorów',
|
||||
name: 'WhoWhat - Shared Expenses',
|
||||
short_name: 'WhoWhat',
|
||||
description: 'Split expenses with your partner or roommates',
|
||||
theme_color: '#4f46e5',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
|
||||
Reference in New Issue
Block a user