add multi lang
This commit is contained in:
2026-07-11 20:03:15 +02:00
parent f0813894a8
commit ae3b435b51
116 changed files with 1543 additions and 484 deletions

View File

@@ -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 />;
}

View File

@@ -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;
}

View File

@@ -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 }) =>

View File

@@ -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 (

View File

@@ -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) => (

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View 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>
);
}

View File

@@ -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 (

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View 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;
}

View 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';

View 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."
}

View File

@@ -0,0 +1,3 @@
{
"loading": "Loading…"
}

View File

@@ -0,0 +1,7 @@
{
"home": "Home",
"history": "History",
"settlements": "Settlements",
"stats": "Stats",
"addExpense": "Add expense"
}

View File

@@ -0,0 +1,5 @@
{
"noCategory": "No category",
"noExpensesThisMonth": "No expenses this month",
"noHistoricalData": "No historical data"
}

View 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?"
}

View File

@@ -0,0 +1,5 @@
{
"defaultTitle": "Are you sure?",
"defaultConfirm": "Delete",
"defaultCancel": "Cancel"
}

View 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"
}

View File

@@ -0,0 +1,5 @@
{
"title": "Something went wrong",
"subtitle": "An unexpected error occurred. Try refreshing the page.",
"refresh": "Refresh"
}

View 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"
}

View 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."
}

View File

@@ -0,0 +1,3 @@
{
"yourShare": "Your share"
}

View 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"
}

View File

@@ -0,0 +1,7 @@
{
"pageTitle": "History",
"allMonths": "All months",
"allCategories": "All categories",
"allPayers": "All payers",
"emptyState": "No entries match the filters"
}

View 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"
}

View File

@@ -0,0 +1,5 @@
{
"title": "Joining household",
"waiting": "One moment…",
"goToApp": "Go to the app"
}

View File

@@ -0,0 +1,3 @@
{
"label": "Language"
}

View 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"
}

View File

@@ -0,0 +1,4 @@
{
"offline": "No connection — expenses are saved locally",
"syncing": "Syncing {{count}} expenses…"
}

View 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"
}

View File

@@ -0,0 +1,4 @@
{
"show": "Show password",
"hide": "Hide password"
}

View 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"
}

View 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"
}

View 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"
}
}

View File

@@ -0,0 +1,3 @@
{
"settings": "Settings"
}

View 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."
}

View File

@@ -0,0 +1,3 @@
{
"settlement": "Settlement"
}

View 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"
}

View 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"
}

View File

@@ -0,0 +1,6 @@
{
"pageTitle": "Stats",
"monthOverMonthTitle": "Month-over-month expenses",
"comparisonTitle": "Who's spending more (this month)",
"categoriesTitle": "Categories, highest first"
}

View 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."
}

View File

@@ -0,0 +1,3 @@
{
"loading": "Ładowanie…"
}

View File

@@ -0,0 +1,7 @@
{
"home": "Start",
"history": "Historia",
"settlements": "Rozliczenia",
"stats": "Statystyki",
"addExpense": "Dodaj wydatek"
}

View File

@@ -0,0 +1,5 @@
{
"noCategory": "Bez kategorii",
"noExpensesThisMonth": "Brak wydatków w tym miesiącu",
"noHistoricalData": "Brak danych historycznych"
}

View 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?"
}

View File

@@ -0,0 +1,5 @@
{
"defaultTitle": "Czy na pewno?",
"defaultConfirm": "Usuń",
"defaultCancel": "Anuluj"
}

View 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"
}

View 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ż"
}

View 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"
}

View 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ąć."
}

View File

@@ -0,0 +1,3 @@
{
"yourShare": "Twoja część"
}

View 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"
}

View 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"
}

View 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"
}

View File

@@ -0,0 +1,5 @@
{
"title": "Dołączanie do gospodarstwa",
"waiting": "Chwileczkę…",
"goToApp": "Przejdź do aplikacji"
}

View File

@@ -0,0 +1,3 @@
{
"label": "Język"
}

View 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ę"
}

View File

@@ -0,0 +1,4 @@
{
"offline": "Brak połączenia — wydatki zapisują się lokalnie",
"syncing": "Synchronizowanie {{count}} wydatków…"
}

View 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"
}

View File

@@ -0,0 +1,4 @@
{
"show": "Pokaż hasło",
"hide": "Ukryj hasło"
}

View 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ę"
}

View 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"
}

View 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"
}
}

View File

@@ -0,0 +1,3 @@
{
"settings": "Ustawienia"
}

View 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."
}

View File

@@ -0,0 +1,3 @@
{
"settlement": "Rozliczenie"
}

View 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"
}

View 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ść"
}

View 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"
}

View File

@@ -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>
);

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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>
);

View File

@@ -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' ? (

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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>
))}

View File

@@ -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) {