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,20 +1,20 @@
# Skopiuj ten plik do .env i uzupełnij wartości: cp .env.example .env
# Copy this file to .env and fill in the values: cp .env.example .env
# --- Bezpieczeństwo ---
# Wygeneruj losowy sekret np. poleceniem: openssl rand -hex 32
# --- Security ---
# Generate a random secret with, e.g.: openssl rand -hex 32
JWT_SECRET=change-me-to-a-random-secret
# --- Adres aplikacji ---
FRONTEND_URL=https://twoja-domena.pl
# --- App URL ---
FRONTEND_URL=https://example.com
# --- Domena (routing Traefik) ---
DOMAIN=twoja-domena.pl
# --- Domain (Traefik routing) ---
DOMAIN=example.com
# --- Sieć zewnętrzna Traefik ---
# --- External Traefik network ---
TRAEFIK_NETWORK=traefik_public
# --- SMTP (powiadomienia mailowe, reset hasła) ---
# Pozostaw SMTP_HOST puste, aby wyłączyć wysyłkę e-maili.
# --- SMTP (email notifications, password reset) ---
# Leave SMTP_HOST empty to disable sending emails.
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kacper
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

192
README.md
View File

@@ -1,149 +1,165 @@
# KtoCo — wspólne wydatki dla grup, par i współlokatorów
# WhoWhat — shared expenses for groups, couples and roommates
Aplikacja webowa (PWA) do dzielenia się wydatkami w gospodarstwie domowym — kto ile wydał, kto komu jest winien, z histor, statystykami i rozliczeniami. Obsługuje dowolną liczbę osób w gospodarstwie i dowolną liczbę gospodarstw na użytkownika. Mobile-first, instalowalna na telefonie, działa częściowo offline.
A web app (PWA) for splitting expenses within a household — who paid what, who owes whom, with history, stats and settlements. Supports any number of people per household and any number of households per user. Mobile-first, installable on your phone, works partially offline. Available in Polish and English, with more languages easy to add.
## Funkcje
## Features
- **Dashboard** — kafelki „kto komu ile jest winien" (automatycznie uproszczone do minimalnej liczby przelewów dla całej grupy) z przyciskiem „Rozlicz się", wykres kołowy wydatków wg kategorii, podsumowanie miesiąca.
- **Dodawanie wydatku** — duże pole kwoty, kategorie z ikonami, wybór płacącego, trzy tryby podziału: po równo (między wszystkich członków), dokładny podział, całość na jedną osobę.
- **Historia** — lista wydatków z filtrami (miesiąc/kategoria/płacący), edycja i usuwanie.
- **Statystyki** — wydatki miesiąc do miesiąca, porównanie konsumpcji członków gospodarstwa, ranking kategorii.
- **Wiele gospodarstw** — użytkownik może należeć do wielu gospodarstw jednocześnie i przełączać się między nimi (Ustawienia → „Twoje gospodarstwa"); każde gospodarstwo może mieć dowolną liczbę członków, dołączanych tym samym kodem zaproszenia.
- **Ustawienia** — przełącznik i zarządzanie gospodarstwami (zmiana nazwy/waluty, usuwanie członka, usunięcie całego gospodarstwa, zaproszenia), własne kategorie (z edycją nazwy/ikony), motyw jasny/ciemny/systemowy, powiadomienia mailowe, eksport CSV.
- **Konto** — rejestracja e-mail/hasło, logowanie, zmiana nazwy, zmiana hasła, przypomnienie/reset hasła mailem, usunięcie konta.
- **PWA / offline** — instalowalna na ekranie głównym telefonu, cache widoków, wydatki dodane offline trafiają do kolejki i synchronizują się automatycznie po powrocie sieci.
- **Dashboard** — "who owes whom" tiles (automatically simplified to the minimum number of transfers for the whole group) with a "Settle up" button, a pie chart of expenses by category, and a month summary.
- **Add expense** — large amount field, categories with icons, payer selection, three split modes: equally (among all members), exact amounts, or the full amount to one person.
- **History** — expense list with filters (month/category/payer), edit and delete.
- **Stats** — month-over-month expenses, comparison of household members' spending, category ranking.
- **Multiple households** — a user can belong to several households at once and switch between them (Settings → "Your households"); each household can have any number of members, who join with the same invite code.
- **Settings** — switch and manage households (rename/change currency, remove a member, delete a household, invites), custom categories (rename/re-icon), light/dark/system theme, **language switcher (Polish/English)**, email notifications, CSV export.
- **Account** — email/password registration, login, rename, change password, password reminder/reset by email, account deletion.
- **PWA / offline** — installable on your phone's home screen, cached views, expenses added offline are queued and sync automatically once the connection is back.
## Stack technologiczny
## Tech stack
| Warstwa | Technologia |
| Layer | Technology |
|---|---|
| Frontend | React (Vite), react-router-dom, TanStack Query, Recharts, vite-plugin-pwa, Material Symbols (Google Fonts) |
| Backend | Node.js + Express, better-sqlite3, JWT (jsonwebtoken), bcryptjs, nodemailer |
| Baza danych | SQLite (plik na bind mouncie `./sqlite`) |
| Infrastruktura | Docker Compose, nginx (serwuje frontend + proxy `/api`), opcjonalnie Traefik (TLS + routing domenowy) |
| Database | SQLite (a file on a bind mount at `./sqlite`) |
| Infrastructure | Docker Compose, nginx (serves the frontend + proxies `/api`), optionally Traefik (TLS + domain routing) |
## Struktura projektu
## Internationalization (i18n)
The UI ships in Polish and English out of the box, switchable at any time from Settings → Language (persisted to `localStorage`, and to the user's account once logged in, so transactional emails and CSV exports match their preference too). Backend API errors are returned as stable machine-readable codes (e.g. `invalid_credentials`) and translated client-side — the server never hardcodes user-facing language.
Frontend translations live in `frontend/src/i18n/locales/<lang>/<namespace>.json`, one JSON file per component/page, auto-discovered at build time (no registration step). **To add a new language:** copy the `locales/en/` directory to `locales/<code>/`, translate every value, and add `{ code, label }` to `frontend/src/i18n/languages.js`. Backend-rendered content (emails, default category names, CSV headers) lives in `backend/src/i18n.js` — add the new language code there too if you want those translated as well.
## Project structure
```
ktoco/
├── docker-compose.yaml # jedyny plik potrzebny do uruchomienia całości
├── .env # konfiguracja (sekrety, domena, SMTP) — NIE commitować
├── .env.example # szablon konfiguracji do skopiowania
├── sqlite/ # bind mount — tu leży plik app.db (trwałość danych)
whowhat/
├── docker-compose.yaml # the only file needed to run everything
├── .env # configuration (secrets, domain, SMTP) — do NOT commit
├── .env.example # configuration template to copy
├── sqlite/ # bind mount — app.db lives here (data persistence)
├── backend/
│ ├── Dockerfile
│ └── src/
│ ├── index.js # Express app, montowanie routerów
│ ├── db/ # schema.sql + połączenie better-sqlite3
│ ├── middleware/auth.js # weryfikacja JWT
│ ├── index.js # Express app, router mounting
│ ├── i18n.js # server-rendered translations (emails, CSV, default categories)
│ ├── db/ # schema.sql + better-sqlite3 connection
│ ├── middleware/auth.js # JWT verification
│ ├── routes/ # auth, households, categories, expenses, settlements, stats
│ └── utils/ # obliczanie salda, mailer (nodemailer), pomocnicze household
│ └── utils/ # balance calculation, mailer (nodemailer), household helpers
└── frontend/
├── Dockerfile # multi-stage: build (node) -> serve (nginx)
├── nginx.conf # proxy /api -> backend:3000
├── vite.config.js # konfiguracja PWA (manifest, service worker)
├── nginx.conf # proxies /api -> backend:3000
├── vite.config.js # PWA config (manifest, service worker)
└── src/
├── i18n/ # I18nContext, per-namespace locale JSON files
├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Login, Register, ...
├── components/ # BottomNav, wykresy, formularze, Icon, Switch, ...
├── api/ # klient fetch + hooki React Query
├── auth/ # kontekst autoryzacji (JWT w localStorage)
├── household/ # kontekst aktywnego gospodarstwa (lista + przełączanie)
├── theme/ # kontekst motywu jasny/ciemny/systemowy
└── offline/ # kolejka IndexedDB + synchronizacja po powrocie sieci
├── components/ # BottomNav, charts, forms, Icon, Switch, LanguageSwitcher, ...
├── api/ # fetch client + React Query hooks
├── auth/ # auth context (JWT in localStorage)
├── household/ # active household context (list + switching)
├── theme/ # light/dark/system theme context
└── offline/ # IndexedDB queue + sync on reconnect
```
## Uruchomienie
## Running it
Wymagany jest tylko Docker (z pluginem Compose).
Docker (with the Compose plugin) is the only requirement.
```bash
cp .env.example .env
# uzupełnij .env (patrz sekcja niżej) — nie trzeba edytować docker-compose.yaml
# fill in .env (see below) — no need to edit docker-compose.yaml
sudo docker compose up -d --build
```
Aplikacja będzie dostępna pod `http://localhost:8856` (oraz pod domeną z Traefika, jeśli skonfigurowana — patrz niżej).
The app will be available at `http://localhost:8856` (and at your Traefik domain, if configured — see below).
## Konfiguracja (`.env`)
## Configuration (`.env`)
Cała konfiguracja wdrożeniowa znajduje się w `.env``docker-compose.yaml` nie wymaga edycji.
All deployment configuration lives in `.env``docker-compose.yaml` doesn't need editing.
| Zmienna | Opis | Domyślnie |
| Variable | Description | Default |
|---|---|---|
| `JWT_SECRET` | Sekret do podpisywania tokenów logowania. Wygeneruj: `openssl rand -hex 32` | — (wymagany) |
| `FRONTEND_URL` | Publiczny adres aplikacji, używany w linkach w mailach (np. reset hasła) | `https://ktoco.kzbikowski.pl` |
| `DOMAIN` | Domena, pod którą Traefik wystawia aplikację | `ktoco.kzbikowski.pl` |
| `TRAEFIK_NETWORK` | Nazwa istniejącej zewnętrznej sieci Docker, do której podłączony jest Traefik | `traefik_public` |
| `SMTP_HOST` | Adres serwera SMTP. Puste = wysyłka maili wyłączona (tylko log w konsoli) | — (opcjonalne) |
| `SMTP_PORT` | Port SMTP | `587` |
| `SMTP_SECURE` | `true` dla połączenia SSL/TLS od razu (port 465), inaczej `false` (STARTTLS) | `false` |
| `SMTP_USER` / `SMTP_PASS` | Dane logowania do SMTP | — |
| `SMTP_FROM` | Adres nadawcy w wysyłanych mailach | `SMTP_USER` |
| `JWT_SECRET` | Secret used to sign login tokens. Generate with: `openssl rand -hex 32` | — (required) |
| `FRONTEND_URL` | Public URL of the app, used in email links (e.g. password reset) | `https://example.com` |
| `DOMAIN` | Domain Traefik should expose the app on | `example.com` |
| `TRAEFIK_NETWORK` | Name of the existing external Docker network Traefik is attached to | `traefik_public` |
| `SMTP_HOST` | SMTP server address. Empty = email sending disabled (console log only) | — (optional) |
| `SMTP_PORT` | SMTP port | `587` |
| `SMTP_SECURE` | `true` for immediate SSL/TLS (port 465), otherwise `false` (STARTTLS) | `false` |
| `SMTP_USER` / `SMTP_PASS` | SMTP login credentials | — |
| `SMTP_FROM` | Sender address on outgoing emails | `SMTP_USER` |
Bez skonfigurowanego SMTP aplikacja działa normalnie — funkcje „reset hasła" i „powiadomienia mailowe" po prostu nie wysyłają realnych maili (backend loguje w konsoli, że wysyłkę pominięto).
Without SMTP configured the app works normally — "password reset" and "email notifications" simply don't send real emails (the backend logs to the console that sending was skipped).
## Dane / trwałość
## Data / persistence
Baza SQLite leży w `./sqlite/app.db` na hoście (bind mount, nie nazwany wolumen Dockera) — łatwo ją skopiować, zbackupować albo podejrzeć narzędziem `sqlite3` bez wchodzenia do kontenera.
The SQLite database lives at `./sqlite/app.db` on the host (a bind mount, not a named Docker volume) — easy to copy, back up, or inspect with the `sqlite3` CLI without entering the container.
## Wdrożenie za Traefikiem
## Deploying behind Traefik
Serwis `frontend` jest podłączony do zewnętrznej sieci `traefik_public` (nazwa konfigurowalna przez `TRAEFIK_NETWORK`) i ma etykiety Traefika (routing po domenie z `.env`, TLS przez `tls-resolver`). Warunek: sieć `traefik_public` musi już istnieć na hoście (tworzy ją zwykle stack samego Traefika):
The `frontend` service is attached to the external `traefik_public` network (name configurable via `TRAEFIK_NETWORK`) and carries Traefik labels (domain routing from `.env`, TLS via `tls-resolver`). Prerequisite: the `traefik_public` network must already exist on the host (normally created by the Traefik stack itself):
```bash
docker network create traefik_public # tylko jeśli jeszcze nie istnieje
docker network create traefik_public # only if it doesn't exist yet
```
Port `8856` frontend jest dodatkowo opublikowany bezpośrednio na hostaprzydatne przy testach lokalnych równolegle z dostępem przez Traefik.
The frontend's port `8856` is also published directly on the host — useful for local testing alongside access through Traefik.
## Model danych (SQLite)
## Data model (SQLite)
- `users`konta (e-mail, hash hasła, preferencja powiadomień mailowych)
- `households`gospodarstwa domowe (nazwa, waluta)
- `household_members`członkowie gospodarstwa (dowolna liczba osób; użytkownik może być w wielu gospodarstwach naraz)
- `invites`kody zaproszeń do gospodarstwa (ważne 7 dni, wielokrotnego użytku — nie wygasają po jednym dołączeniu)
- `password_resets`jednorazowe tokeny resetu hasła (ważne 1h)
- `categories`kategorie wydatków (nazwa, ikona Material Symbols, kolor)
- `expenses`wydatki (kwota, płacący, kategoria, data, typ podziału)
- `expense_shares` — finalny podział wydatku między członków gospodarstwa (niezależnie od typu podziału zawsze sumuje się do kwoty wydatku)
- `settlements`historia rozliczeń („Rozlicz się")
- `users`accounts (email, password hash, email notification preference, UI language)
- `households`households (name, currency)
- `household_members`household membership (any number of people; a user can be in several households at once)
- `invites`household invite codes (valid 7 days, reusable — they don't expire after a single join)
- `password_resets`one-time password reset tokens (valid 1h)
- `categories`expense categories (name, Material Symbols icon, color)
- `expenses`expenses (amount, payer, category, date, split type)
- `expense_shares` the final split of an expense across household members (always sums to the expense amount regardless of split type)
- `settlements`settlement history ("Settle up")
Saldo per osoba liczone jest jako: `(suma zapłacona przez osobę) (suma jej udziałów w wydatkach) (netto rozliczeń)`. Do prezentacji „kto komu ile jest winien" salda są upraszczane zachłannym algorytmem (`backend/src/utils/balance.js: simplifyDebts`), który dla N osób generuje minimalną liczbę przelewów rozliczających wszystkich (zamiast osobnego długu między każdą parą).
Per-person balance is computed as: `(amount they paid) (sum of their expense shares) (net settlements)`. For the "who owes whom" display, balances are simplified with a greedy algorithm (`backend/src/utils/balance.js: simplifyDebts`) that produces the minimum number of transfers to settle everyone (instead of a separate debt between every pair).
## Wiele gospodarstw — jak to działa
New rows in `users` get a `language` column (`'pl'` or `'en'`, default `'pl'`); on an existing database this is added automatically on startup via `ALTER TABLE ... ADD COLUMN` — no manual migration, and no existing rows are touched or deleted.
Użytkownik może należeć do wielu gospodarstw. Ponieważ każdy request do zasobów powiązanych z gospodarstwem (wydatki, kategorie, saldo, statystyki, rozliczenia) musi wiedzieć, którego gospodarstwa dotyczy, frontend wysyła nagłówek `X-Household-Id: <id aktywnego gospodarstwa>` przy każdym takim żądaniu (ustawiany automatycznie przez `frontend/src/household/HouseholdContext.jsx` po przełączeniu gospodarstwa w Ustawieniach). Backend weryfikuje w `requireHousehold` middleware, że zalogowany użytkownik faktycznie jest członkiem podanego gospodarstwa.
## Multiple households — how it works
Usunięcie ostatniego członka z gospodarstwa automatycznie kasuje samo gospodarstwo (wraz z całą historią wydatków — kasowanie kaskadowe przez klucze obce). Usunięcie konta użytkownika, który ma współdzieloną historię finansową z innymi (wydatki/udziały/rozliczenia), nie usuwa go fizycznie z bazy (zepsułoby to historię widoczną dla reszty gospodarstwa) — konto jest wtedy anonimizowane (nazwa → „Usunięte konto", e-mail zastąpiony unikalnym nieistniejącym adresem, hasło unieważnione). Świeże konto bez żadnej historii jest usuwane w całości.
A user can belong to multiple households. Since every request for household-scoped resources (expenses, categories, balance, stats, settlements) needs to know which household it's about, the frontend sends an `X-Household-Id: <active household id>` header on every such request (set automatically by `frontend/src/household/HouseholdContext.jsx` whenever the active household is switched in Settings). The backend's `requireHousehold` middleware verifies the logged-in user is actually a member of the given household.
## API (skrót)
Removing the last member from a household automatically deletes the household itself (along with its entire expense history — cascading deletes via foreign keys). Deleting a user account that has shared financial history with others (expenses/shares/settlements) doesn't physically remove it from the database (that would break the history visible to the rest of the household) — instead the account is anonymized (name → "Deleted account", email replaced with a unique non-existent address, password invalidated). A fresh account with no history is deleted outright.
Wszystkie endpointy poza `/auth/register`, `/auth/login`, `/auth/forgot-password`, `/auth/reset-password` i `/health` wymagają nagłówka `Authorization: Bearer <token>`. Endpointy gospodarstwa/kategorii/wydatków/rozliczeń/statystyk dodatkowo wymagają `X-Household-Id: <id>`.
## API (overview)
| Grupa | Endpointy |
Every endpoint except `/auth/register`, `/auth/login`, `/auth/forgot-password`, `/auth/reset-password` and `/health` requires an `Authorization: Bearer <token>` header. Household/category/expense/settlement/stats endpoints additionally require `X-Household-Id: <id>`.
Error responses are `{ "error": "<code>" }`, where `<code>` is a stable snake_case identifier (e.g. `invalid_credentials`, `household_not_found`) meant to be translated client-side — see [`frontend/src/i18n/locales/en/errors.json`](frontend/src/i18n/locales/en/errors.json) for the full list.
| Group | Endpoints |
|---|---|
| Auth | `POST /auth/register`, `/login`, `/change-password`, `/forgot-password`, `/reset-password`, `GET /auth/me`, `PUT /auth/me`, `PUT /auth/me/notifications`, `DELETE /auth/me` |
| Gospodarstwa | `GET /households` (lista Twoich), `GET/PUT/DELETE /households/:id`, `POST /households`, `POST /households/:id/invite`, `POST /households/join`, `DELETE /households/:id/members/:userId` |
| Kategorie | `GET/POST/PUT/DELETE /categories[/:id]` |
| Wydatki | `GET/POST/PUT/DELETE /expenses[/:id]` (filtry: `month`, `categoryId`, `payerId`) |
| Rozliczenia | `GET/POST /settlements` (POST rozlicza od razu wszystkie uproszczone przelewy) |
| Statystyki | `GET /stats/balance`, `/summary`, `/monthly`, `/export.csv` |
| Auth | `POST /auth/register`, `/login`, `/change-password`, `/forgot-password`, `/reset-password`, `GET /auth/me`, `PUT /auth/me`, `PUT /auth/me/notifications`, `PUT /auth/me/language`, `DELETE /auth/me` |
| Households | `GET /households` (yours), `GET/PUT/DELETE /households/:id`, `POST /households`, `POST /households/:id/invite`, `POST /households/join`, `DELETE /households/:id/members/:userId` |
| Categories | `GET/POST/PUT/DELETE /categories[/:id]` |
| Expenses | `GET/POST/PUT/DELETE /expenses[/:id]` (filters: `month`, `categoryId`, `payerId`) |
| Settlements | `GET/POST /settlements` (POST immediately settles all simplified transfers) |
| Stats | `GET /stats/balance`, `/summary`, `/monthly`, `/export.csv` |
## Tryb offline (PWA)
## Offline mode (PWA)
Service worker (Workbox, przez `vite-plugin-pwa`) cache'uje powłokę aplikacji i ostatnio pobrane dane GET z API (strategia `NetworkFirst`), więc appka otwiera się i pokazuje dane nawet bez sieci. Nowy wydatek dodany offline trafia do kolejki w IndexedDB (`frontend/src/offline/`) i zostaje automatycznie wysłany po wykryciu powrotu połączenia (`online` event) — widoczny jest wtedy baner z liczbą oczekujących wpisów.
A service worker (Workbox, via `vite-plugin-pwa`) caches the app shell and recently fetched GET data from the API (`NetworkFirst` strategy), so the app opens and shows data even without a connection. A new expense added offline is queued in IndexedDB (`frontend/src/offline/`) and sent automatically once the connection comes back (`online` event) — a banner then shows the number of pending entries.
## Znane ograniczenia
## Known limitations
- Kopiowanie kodu zaproszenia przez `navigator.clipboard` wymaga bezpiecznego kontekstu (HTTPS lub `localhost`) — na zwykłym HTTP w sieci lokalnej przeglądarka może to zablokować; dlatego kod jest zawsze dostępny też jako zaznaczalne pole tekstowe (ręczne kopiowanie zawsze działa).
- `schema.sql` używa `CREATE TABLE IF NOT EXISTS`dodanie nowej kolumny do istniejącej tabeli w już działającej bazie wymaga ręcznej migracji (`ALTER TABLE`); przy starcie na czystej bazie schemat tworzy się poprawnie od razu.
- Kod zaproszenia do gospodarstwa nie wygasa po pierwszym użyciu (celowo — pozwala zaprosić dowolną liczbę osób tym samym kodem), tylko po czasie (7 dni) lub ręcznym wygenerowaniu nowego w Ustawieniach.
- Copying the invite code via `navigator.clipboard` requires a secure context (HTTPS or `localhost`) — over plain HTTP on a local network the browser may block it; the code is therefore always also available as a selectable text field (manual copy always works).
- `schema.sql` uses `CREATE TABLE IF NOT EXISTS`adding a new column to an existing table on an already-running database requires either a manual migration (`ALTER TABLE`) or startup logic like the one already in place for `users.language` (see `backend/src/db/db.js`); a fresh database gets the current schema immediately.
- A household invite code doesn't expire after first use (intentionally — it lets you invite any number of people with the same code), only after time (7 days) or a manual regeneration in Settings.
## Rozwój lokalny (bez Dockera)
## Local development (without Docker)
Wymaga Node.js 20+.
Requires Node.js 20+.
```bash
cd backend && npm install && JWT_SECRET=dev DATABASE_PATH=./data/app.db npm start
cd frontend && npm install && npm run dev # serwer dev na :5173, proxy /api -> :3000
cd frontend && npm install && npm run dev # dev server on :5173, proxies /api -> :3000
```
## License
[MIT](LICENSE)

View File

@@ -1,5 +1,5 @@
{
"name": "ktoco-backend",
"name": "whowhat-backend",
"version": "1.0.0",
"private": true,
"type": "commonjs",

View File

@@ -12,14 +12,14 @@ db.pragma('foreign_keys = ON');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
const DEFAULT_CATEGORIES = [
{ name: 'Jedzenie', icon: 'shopping_cart', color: '#22c55e' },
{ name: 'Mieszkanie', icon: 'home', color: '#3b82f6' },
{ name: 'Rachunki', icon: 'bolt', color: '#f59e0b' },
{ name: 'Transport', icon: 'directions_car', color: '#8b5cf6' },
{ name: 'Restauracje', icon: 'restaurant', color: '#ef4444' },
{ name: 'Rozrywka', icon: 'celebration', color: '#ec4899' },
{ name: 'Inne', icon: 'inventory_2', color: '#6b7280' },
];
// `CREATE TABLE IF NOT EXISTS` (above) only creates the table on a fresh database — it
// can't add a column to a `users` table that already exists from before this column was
// introduced. Add it here so upgrading an existing deployment doesn't require a manual
// migration, and existing users/rows are left untouched (ALTER TABLE ADD COLUMN only adds
// the new column with its default; it does not delete or modify any existing data).
const userColumns = db.prepare('PRAGMA table_info(users)').all();
if (!userColumns.some((c) => c.name === 'language')) {
db.exec(`ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT 'pl'`);
}
module.exports = { db, DEFAULT_CATEGORIES };
module.exports = { db };

View File

@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users (
name TEXT NOT NULL,
avatar_emoji TEXT NOT NULL DEFAULT '🙂',
email_notifications INTEGER NOT NULL DEFAULT 1,
language TEXT NOT NULL DEFAULT 'pl',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

84
backend/src/i18n.js Normal file
View File

@@ -0,0 +1,84 @@
// Backend-side translations: API error codes (translated client-side, see
// frontend/src/i18n/locales/*/errors.json), plus content the backend renders itself
// (transactional emails, CSV export, default category seed) where translation must
// happen server-side because the client isn't in the loop.
//
// To add a language: add a key to SUPPORTED_LANGUAGES and fill in EMAIL_TEMPLATES,
// DEFAULT_CATEGORIES and CSV_COLUMNS below for that language code.
const SUPPORTED_LANGUAGES = ['pl', 'en'];
const DEFAULT_LANGUAGE = 'pl';
function normalizeLanguage(lang) {
return SUPPORTED_LANGUAGES.includes(lang) ? lang : DEFAULT_LANGUAGE;
}
const DEFAULT_CATEGORIES = {
pl: [
{ name: 'Jedzenie', icon: 'shopping_cart', color: '#22c55e' },
{ name: 'Mieszkanie', icon: 'home', color: '#3b82f6' },
{ name: 'Rachunki', icon: 'bolt', color: '#f59e0b' },
{ name: 'Transport', icon: 'directions_car', color: '#8b5cf6' },
{ name: 'Restauracje', icon: 'restaurant', color: '#ef4444' },
{ name: 'Rozrywka', icon: 'celebration', color: '#ec4899' },
{ name: 'Inne', icon: 'inventory_2', color: '#6b7280' },
],
en: [
{ name: 'Groceries', icon: 'shopping_cart', color: '#22c55e' },
{ name: 'Housing', icon: 'home', color: '#3b82f6' },
{ name: 'Bills', icon: 'bolt', color: '#f59e0b' },
{ name: 'Transport', icon: 'directions_car', color: '#8b5cf6' },
{ name: 'Dining out', icon: 'restaurant', color: '#ef4444' },
{ name: 'Entertainment', icon: 'celebration', color: '#ec4899' },
{ name: 'Other', icon: 'inventory_2', color: '#6b7280' },
],
};
function getDefaultCategories(lang) {
return DEFAULT_CATEGORIES[normalizeLanguage(lang)];
}
const EMAIL_TEMPLATES = {
pl: {
resetPassword: ({ name, resetLink }) => ({
subject: 'WhoWhat — reset hasła',
text: `Cześć ${name},\n\nAby zresetować hasło, kliknij poniższy link (ważny 1 godzinę):\n${resetLink}\n\nJeśli to nie Ty, zignoruj tę wiadomość.`,
}),
newExpense: ({ actorName, title, amount }) => ({
subject: 'WhoWhat — nowy wydatek',
text: `${actorName} dodał(a) nowy wydatek "${title}" na kwotę ${amount}.`,
}),
},
en: {
resetPassword: ({ name, resetLink }) => ({
subject: 'WhoWhat — password reset',
text: `Hi ${name},\n\nTo reset your password, click the link below (valid for 1 hour):\n${resetLink}\n\nIf this wasn't you, just ignore this email.`,
}),
newExpense: ({ actorName, title, amount }) => ({
subject: 'WhoWhat — new expense',
text: `${actorName} added a new expense "${title}" for ${amount}.`,
}),
},
};
function getEmailTemplates(lang) {
return EMAIL_TEMPLATES[normalizeLanguage(lang)];
}
const CSV_EXPORT = {
pl: { header: ['Data', 'Tytuł', 'Kwota', 'Kategoria', 'Płacił', 'Podział'], filename: 'wydatki.csv' },
en: { header: ['Date', 'Title', 'Amount', 'Category', 'Paid by', 'Split'], filename: 'expenses.csv' },
};
function getCsvExport(lang) {
return CSV_EXPORT[normalizeLanguage(lang)];
}
module.exports = {
SUPPORTED_LANGUAGES,
DEFAULT_LANGUAGE,
normalizeLanguage,
getDefaultCategories,
getEmailTemplates,
getCsvExport,
};

View File

@@ -24,7 +24,7 @@ app.use('/stats', statsRouter);
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Wewnętrzny błąd serwera' });
res.status(500).json({ error: 'internal_server_error' });
});
const PORT = process.env.PORT || 3000;

View File

@@ -14,20 +14,20 @@ function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Missing bearer token' });
return res.status(401).json({ error: 'missing_bearer_token' });
}
let payload;
try {
payload = jwt.verify(token, JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
return res.status(401).json({ error: 'invalid_or_expired_token' });
}
// The JWT signature alone doesn't prove the account still exists (deleted
// account, or — in dev — a wiped database): reject it the same way so the
// client logs out instead of misreading "no accounts" as "no household".
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(payload.sub);
if (!user) {
return res.status(401).json({ error: 'Invalid or expired token' });
return res.status(401).json({ error: 'invalid_or_expired_token' });
}
req.userId = payload.sub;
next();

View File

@@ -5,6 +5,7 @@ const { v4: uuid } = require('uuid');
const { db } = require('../db/db');
const { signToken, requireAuth } = require('../middleware/auth');
const { sendMail } = require('../utils/mailer');
const { normalizeLanguage, getEmailTemplates } = require('../i18n');
const router = express.Router();
@@ -18,21 +19,22 @@ function toPublicUser(user) {
name: user.name,
avatarEmoji: user.avatar_emoji,
emailNotifications: !!user.email_notifications,
language: user.language,
};
}
router.post('/register', (req, res) => {
const { email, password, name } = req.body || {};
const { email, password, name, language } = req.body || {};
if (!email || !password || !name) {
return res.status(400).json({ error: 'email, password i name są wymagane' });
return res.status(400).json({ error: 'missing_registration_fields' });
}
if (String(password).length < 6) {
return res.status(400).json({ error: 'Hasło musi mieć co najmniej 6 znaków' });
return res.status(400).json({ error: 'password_too_short' });
}
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existing) {
return res.status(409).json({ error: 'Konto z tym adresem e-mail już istnieje' });
return res.status(409).json({ error: 'email_already_registered' });
}
const user = {
@@ -40,12 +42,14 @@ router.post('/register', (req, res) => {
email: email.toLowerCase(),
password_hash: bcrypt.hashSync(password, 10),
name,
language: normalizeLanguage(language),
};
db.prepare('INSERT INTO users (id, email, password_hash, name) VALUES (?, ?, ?, ?)').run(
db.prepare('INSERT INTO users (id, email, password_hash, name, language) VALUES (?, ?, ?, ?, ?)').run(
user.id,
user.email,
user.password_hash,
user.name
user.name,
user.language
);
const created = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
@@ -56,12 +60,12 @@ router.post('/register', (req, res) => {
router.post('/login', (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
return res.status(400).json({ error: 'email i password są wymagane' });
return res.status(400).json({ error: 'missing_login_fields' });
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Nieprawidłowy e-mail lub hasło' });
return res.status(401).json({ error: 'invalid_credentials' });
}
const token = signToken(user);
@@ -70,14 +74,14 @@ router.post('/login', (req, res) => {
router.get('/me', requireAuth, (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
if (!user) return res.status(404).json({ error: 'Użytkownik nie znaleziony' });
if (!user) return res.status(404).json({ error: 'user_not_found' });
res.json({ user: toPublicUser(user) });
});
router.put('/me', requireAuth, (req, res) => {
const { name } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'name jest wymagane' });
return res.status(400).json({ error: 'name_required' });
}
db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name.trim(), req.userId);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
@@ -109,9 +113,11 @@ router.delete('/me', requireAuth, (req, res) => {
db.prepare('DELETE FROM households WHERE id = ?').run(householdId);
}
}
// Not translated: this is a placeholder value stored permanently in the database and
// shown to every remaining household member regardless of their own language setting.
db.prepare(
`UPDATE users SET name = 'Usunięte konto', email = ?, password_hash = '', email_notifications = 0 WHERE id = ?`
).run(`deleted-${userId}@ktoco.invalid`, userId);
`UPDATE users SET name = 'Deleted account', email = ?, password_hash = '', email_notifications = 0 WHERE id = ?`
).run(`deleted-${userId}@whowhat.invalid`, userId);
});
anonymize();
@@ -125,20 +131,27 @@ router.put('/me/notifications', requireAuth, (req, res) => {
res.json({ user: toPublicUser(user) });
});
router.put('/me/language', requireAuth, (req, res) => {
const { language } = req.body || {};
db.prepare('UPDATE users SET language = ? WHERE id = ?').run(normalizeLanguage(language), req.userId);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
res.json({ user: toPublicUser(user) });
});
router.post('/change-password', requireAuth, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'currentPassword i newPassword są wymagane' });
return res.status(400).json({ error: 'missing_password_fields' });
}
if (String(newPassword).length < 6) {
return res.status(400).json({ error: 'Nowe hasło musi mieć co najmniej 6 znaków' });
return res.status(400).json({ error: 'password_too_short' });
}
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
// 400, not 401: the JWT is valid (requireAuth already passed) — this is a form
// validation failure, not an auth failure, and must not trigger a global session logout.
return res.status(400).json({ error: 'Bieżące hasło jest nieprawidłowe' });
return res.status(400).json({ error: 'current_password_incorrect' });
}
const passwordHash = bcrypt.hashSync(newPassword, 10);
@@ -148,9 +161,9 @@ router.post('/change-password', requireAuth, (req, res) => {
router.post('/forgot-password', async (req, res) => {
const { email } = req.body || {};
const genericResponse = { message: 'Jeśli konto istnieje, wysłaliśmy e-mail z linkiem do resetu hasła' };
const genericResponse = { message: 'If an account exists, a password reset email has been sent' };
if (!email) {
return res.status(400).json({ error: 'email jest wymagany' });
return res.status(400).json({ error: 'email_required' });
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
@@ -168,11 +181,8 @@ router.post('/forgot-password', async (req, res) => {
);
const resetLink = `${FRONTEND_URL}/reset-password?token=${token}`;
await sendMail({
to: user.email,
subject: 'KtoCo — reset hasła',
text: `Cześć ${user.name},\n\nAby zresetować hasło, kliknij poniższy link (ważny 1 godzinę):\n${resetLink}\n\nJeśli to nie Ty, zignoruj tę wiadomość.`,
});
const { subject, text } = getEmailTemplates(user.language).resetPassword({ name: user.name, resetLink });
await sendMail({ to: user.email, subject, text });
res.json(genericResponse);
});
@@ -180,17 +190,17 @@ router.post('/forgot-password', async (req, res) => {
router.post('/reset-password', (req, res) => {
const { token, password } = req.body || {};
if (!token || !password) {
return res.status(400).json({ error: 'token i password są wymagane' });
return res.status(400).json({ error: 'missing_reset_fields' });
}
if (String(password).length < 6) {
return res.status(400).json({ error: 'Hasło musi mieć co najmniej 6 znaków' });
return res.status(400).json({ error: 'password_too_short' });
}
const reset = db
.prepare(`SELECT * FROM password_resets WHERE token = ? AND used_at IS NULL AND expires_at > datetime('now')`)
.get(token);
if (!reset) {
return res.status(400).json({ error: 'Link do resetu hasła jest nieprawidłowy lub wygasł' });
return res.status(400).json({ error: 'reset_link_invalid_or_expired' });
}
const passwordHash = bcrypt.hashSync(password, 10);

View File

@@ -17,7 +17,7 @@ router.get('/', (req, res) => {
router.post('/', (req, res) => {
const { name, icon, color } = req.body || {};
if (!name) {
return res.status(400).json({ error: 'Nazwa kategorii jest wymagana' });
return res.status(400).json({ error: 'category_name_required' });
}
const category = {
id: uuid(),
@@ -41,7 +41,7 @@ router.put('/:id', (req, res) => {
.prepare('SELECT * FROM categories WHERE id = ? AND household_id = ?')
.get(req.params.id, req.household.id);
if (!existing) {
return res.status(404).json({ error: 'Kategoria nie znaleziona' });
return res.status(404).json({ error: 'category_not_found' });
}
const { name, icon, color } = req.body || {};
db.prepare('UPDATE categories SET name = ?, icon = ?, color = ? WHERE id = ?').run(
@@ -58,7 +58,7 @@ router.delete('/:id', (req, res) => {
.prepare('DELETE FROM categories WHERE id = ? AND household_id = ?')
.run(req.params.id, req.household.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Kategoria nie znaleziona' });
return res.status(404).json({ error: 'category_not_found' });
}
res.status(204).end();
});

View File

@@ -5,6 +5,7 @@ const { requireAuth } = require('../middleware/auth');
const { requireHousehold, getMembers } = require('../utils/households');
const { sendMail } = require('../utils/mailer');
const { round2 } = require('../utils/balance');
const { getEmailTemplates } = require('../i18n');
const router = express.Router();
router.use(requireAuth, requireHousehold);
@@ -15,7 +16,7 @@ function computeShares(splitType, amount, payerId, members, shares) {
const memberIds = members.map((m) => m.id);
if (splitType === 'equal') {
if (memberIds.length === 0) {
throw new Error('Gospodarstwo domowe nie ma członków do podziału wydatku');
throw new Error('household_has_no_members');
}
const base = Math.floor((amount / memberIds.length) * 100) / 100;
const shares = Object.fromEntries(memberIds.map((id) => [id, base]));
@@ -28,21 +29,21 @@ function computeShares(splitType, amount, payerId, members, shares) {
if (splitType === 'exact' || splitType === 'full') {
if (!shares || typeof shares !== 'object') {
throw new Error('Pole "shares" jest wymagane dla wybranego typu podziału');
throw new Error('shares_required');
}
const sum = Object.values(shares).reduce((acc, v) => acc + Number(v), 0);
if (Math.abs(sum - amount) > EPSILON) {
throw new Error('Suma udziałów musi być równa kwocie wydatku');
throw new Error('shares_must_sum_to_amount');
}
for (const userId of Object.keys(shares)) {
if (!memberIds.includes(userId)) {
throw new Error('Udział przypisany do osoby spoza gospodarstwa domowego');
throw new Error('share_assigned_to_non_member');
}
}
return shares;
}
throw new Error('Nieznany typ podziału');
throw new Error('unknown_split_type');
}
function attachShares(expense) {
@@ -78,12 +79,12 @@ router.get('/', (req, res) => {
router.post('/', (req, res) => {
const { amount, title, categoryId, expenseDate, payerId, splitType, shares } = req.body || {};
if (!amount || !title || !expenseDate || !payerId || !splitType) {
return res.status(400).json({ error: 'amount, title, expenseDate, payerId i splitType są wymagane' });
return res.status(400).json({ error: 'missing_expense_fields' });
}
const members = getMembers(req.household.id);
if (!members.some((m) => m.id === payerId)) {
return res.status(400).json({ error: 'payerId musi być członkiem gospodarstwa domowego' });
return res.status(400).json({ error: 'payer_must_be_member' });
}
let computedShares;
@@ -131,17 +132,18 @@ router.post('/', (req, res) => {
const actor = db.prepare('SELECT name FROM users WHERE id = ?').get(req.userId);
const notifyTargets = db
.prepare(
`SELECT u.email, u.name FROM users u
`SELECT u.email, u.name, u.language FROM users u
JOIN household_members hm ON hm.user_id = u.id
WHERE hm.household_id = ? AND u.id != ? AND u.email_notifications = 1`
)
.all(req.household.id, req.userId);
for (const target of notifyTargets) {
sendMail({
to: target.email,
subject: 'KtoCo — nowy wydatek',
text: `${actor?.name || 'Ktoś'} dodał(a) nowy wydatek "${expense.title}" na kwotę ${expense.amount.toFixed(2)}.`,
const { subject, text } = getEmailTemplates(target.language).newExpense({
actorName: actor?.name || (target.language === 'en' ? 'Someone' : 'Ktoś'),
title: expense.title,
amount: expense.amount.toFixed(2),
});
sendMail({ to: target.email, subject, text });
}
const created = db.prepare('SELECT * FROM expenses WHERE id = ?').get(expense.id);
@@ -153,7 +155,7 @@ router.put('/:id', (req, res) => {
.prepare('SELECT * FROM expenses WHERE id = ? AND household_id = ?')
.get(req.params.id, req.household.id);
if (!existing) {
return res.status(404).json({ error: 'Wydatek nie znaleziony' });
return res.status(404).json({ error: 'expense_not_found' });
}
const { amount, title, categoryId, expenseDate, payerId, splitType, shares } = req.body || {};
@@ -163,7 +165,7 @@ router.put('/:id', (req, res) => {
const finalSplitType = splitType || existing.split_type;
if (!members.some((m) => m.id === finalPayerId)) {
return res.status(400).json({ error: 'payerId musi być członkiem gospodarstwa domowego' });
return res.status(400).json({ error: 'payer_must_be_member' });
}
let computedShares;
@@ -206,7 +208,7 @@ router.delete('/:id', (req, res) => {
.prepare('DELETE FROM expenses WHERE id = ? AND household_id = ?')
.run(req.params.id, req.household.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Wydatek nie znaleziony' });
return res.status(404).json({ error: 'expense_not_found' });
}
res.status(204).end();
});

View File

@@ -1,9 +1,10 @@
const express = require('express');
const crypto = require('crypto');
const { v4: uuid } = require('uuid');
const { db, DEFAULT_CATEGORIES } = require('../db/db');
const { db } = require('../db/db');
const { requireAuth } = require('../middleware/auth');
const { getHouseholdsForUser, getHouseholdById, isMember, getMembers } = require('../utils/households');
const { getDefaultCategories } = require('../i18n');
const router = express.Router();
router.use(requireAuth);
@@ -32,16 +33,18 @@ router.get('/', (req, res) => {
router.get('/:id', (req, res) => {
if (!isMember(req.params.id, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
const household = getHouseholdById(req.params.id);
if (!household) return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
if (!household) return res.status(404).json({ error: 'household_not_found' });
res.json({ household: serializeHousehold(household) });
});
router.post('/', (req, res) => {
const { name, currency } = req.body || {};
const household = { id: uuid(), name: name || 'Nasze gospodarstwo', currency: currency || 'PLN' };
const requester = db.prepare('SELECT language FROM users WHERE id = ?').get(req.userId);
const defaultName = requester?.language === 'en' ? 'Our household' : 'Nasze gospodarstwo';
const household = { id: uuid(), name: name || defaultName, currency: currency || 'PLN' };
const createHousehold = db.transaction(() => {
db.prepare('INSERT INTO households (id, name, currency) VALUES (?, ?, ?)').run(
@@ -50,7 +53,7 @@ router.post('/', (req, res) => {
household.currency
);
db.prepare('INSERT INTO household_members (household_id, user_id) VALUES (?, ?)').run(household.id, req.userId);
for (const cat of DEFAULT_CATEGORIES) {
for (const cat of getDefaultCategories(requester?.language)) {
db.prepare('INSERT INTO categories (id, household_id, name, icon, color) VALUES (?, ?, ?, ?, ?)').run(
uuid(),
household.id,
@@ -73,10 +76,10 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => {
if (!isMember(req.params.id, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
const household = getHouseholdById(req.params.id);
if (!household) return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
if (!household) return res.status(404).json({ error: 'household_not_found' });
const { name, currency } = req.body || {};
db.prepare('UPDATE households SET name = ?, currency = ? WHERE id = ?').run(
@@ -89,7 +92,7 @@ router.put('/:id', (req, res) => {
router.post('/:id/invite', (req, res) => {
if (!isMember(req.params.id, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
db.prepare(`UPDATE invites SET used_at = datetime('now') WHERE household_id = ? AND used_at IS NULL`).run(
req.params.id
@@ -106,17 +109,17 @@ router.post('/:id/invite', (req, res) => {
router.post('/join', (req, res) => {
const { code } = req.body || {};
if (!code) {
return res.status(400).json({ error: 'Kod zaproszenia jest wymagany' });
return res.status(400).json({ error: 'invite_code_required' });
}
const invite = db
.prepare(`SELECT * FROM invites WHERE code = ? AND used_at IS NULL AND expires_at > datetime('now')`)
.get(code.toUpperCase());
if (!invite) {
return res.status(404).json({ error: 'Kod zaproszenia jest nieprawidłowy lub wygasł' });
return res.status(404).json({ error: 'invite_code_invalid_or_expired' });
}
if (isMember(invite.household_id, req.userId)) {
return res.status(409).json({ error: 'Jesteś już członkiem tego gospodarstwa' });
return res.status(409).json({ error: 'already_a_member' });
}
db.prepare('INSERT INTO household_members (household_id, user_id) VALUES (?, ?)').run(
@@ -129,7 +132,7 @@ router.post('/join', (req, res) => {
router.delete('/:id/members/:userId', (req, res) => {
if (!isMember(req.params.id, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
db.prepare('DELETE FROM household_members WHERE household_id = ? AND user_id = ?').run(
req.params.id,
@@ -145,7 +148,7 @@ router.delete('/:id/members/:userId', (req, res) => {
router.delete('/:id', (req, res) => {
if (!isMember(req.params.id, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
db.prepare('DELETE FROM households WHERE id = ?').run(req.params.id);
res.json({ deleted: true });

View File

@@ -20,7 +20,7 @@ router.post('/', (req, res) => {
const current = computeSettlement(req.household.id, members);
if (current.settled) {
return res.status(409).json({ error: 'Jesteście już rozliczeni' });
return res.status(409).json({ error: 'already_settled' });
}
const insert = db.transaction(() => {
@@ -37,20 +37,20 @@ router.post('/', (req, res) => {
function validateManualSettlement(req, res, { fromUserId, toUserId, amount }) {
if (!fromUserId || !toUserId || !amount) {
res.status(400).json({ error: 'fromUserId, toUserId i amount są wymagane' });
res.status(400).json({ error: 'missing_settlement_fields' });
return false;
}
if (fromUserId === toUserId) {
res.status(400).json({ error: 'Płacący i odbiorca muszą być różnymi osobami' });
res.status(400).json({ error: 'payer_and_recipient_must_differ' });
return false;
}
if (Number(amount) <= 0) {
res.status(400).json({ error: 'Kwota musi być większa od zera' });
res.status(400).json({ error: 'amount_must_be_positive' });
return false;
}
const memberIds = getMembers(req.household.id).map((m) => m.id);
if (!memberIds.includes(fromUserId) || !memberIds.includes(toUserId)) {
res.status(400).json({ error: 'Obie osoby muszą być członkami gospodarstwa' });
res.status(400).json({ error: 'both_must_be_members' });
return false;
}
return true;
@@ -79,7 +79,7 @@ router.put('/:id', (req, res) => {
.prepare('SELECT * FROM settlements WHERE id = ? AND household_id = ?')
.get(req.params.id, req.household.id);
if (!existing) {
return res.status(404).json({ error: 'Rozliczenie nie znalezione' });
return res.status(404).json({ error: 'settlement_not_found' });
}
const fromUserId = req.body?.fromUserId ?? existing.from_user_id;
@@ -102,7 +102,7 @@ router.delete('/:id', (req, res) => {
.prepare('DELETE FROM settlements WHERE id = ? AND household_id = ?')
.run(req.params.id, req.household.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Rozliczenie nie znalezione' });
return res.status(404).json({ error: 'settlement_not_found' });
}
res.status(204).end();
});

View File

@@ -3,6 +3,7 @@ const { db } = require('../db/db');
const { requireAuth } = require('../middleware/auth');
const { requireHousehold, getMembers } = require('../utils/households');
const { computeSettlement } = require('../utils/balance');
const { getCsvExport } = require('../i18n');
const router = express.Router();
router.use(requireAuth, requireHousehold);
@@ -75,8 +76,10 @@ router.get('/export.csv', (req, res) => {
)
.all(req.household.id);
const requester = db.prepare('SELECT language FROM users WHERE id = ?').get(req.userId);
const { header, filename } = getCsvExport(requester?.language);
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const header = ['Data', 'Tytuł', 'Kwota', 'Kategoria', 'Płacił', 'Podział'];
const lines = [header.join(',')];
for (const r of rows) {
lines.push(
@@ -85,7 +88,7 @@ router.get('/export.csv', (req, res) => {
}
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="wydatki.csv"');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(lines.join('\n'));
});

View File

@@ -35,14 +35,14 @@ function getMembers(householdId) {
function requireHousehold(req, res, next) {
const householdId = req.headers['x-household-id'];
if (!householdId) {
return res.status(400).json({ error: 'Nie wybrano gospodarstwa (brak nagłówka X-Household-Id)' });
return res.status(400).json({ error: 'household_not_selected' });
}
if (!isMember(householdId, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
const household = getHouseholdById(householdId);
if (!household) {
return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
return res.status(404).json({ error: 'household_not_found' });
}
req.household = household;
next();

View File

@@ -13,13 +13,13 @@ const transporter = SMTP_HOST
async function sendMail({ to, subject, text }) {
if (!transporter) {
console.log(`[mailer] SMTP nieskonfigurowane — pomijam wysyłkę do ${to}: "${subject}"`);
console.log(`[mailer] SMTP not configured — skipping send to ${to}: "${subject}"`);
return;
}
try {
await transporter.sendMail({ from: SMTP_FROM || SMTP_USER, to, subject, text });
} catch (err) {
console.error('[mailer] Błąd wysyłki e-mail:', err.message);
console.error('[mailer] Failed to send email:', err.message);
}
}

View File

@@ -5,7 +5,7 @@ services:
JWT_SECRET: ${JWT_SECRET:-please-change-this-secret-in-.env}
DATABASE_PATH: /app/data/app.db
PORT: 3000
FRONTEND_URL: ${FRONTEND_URL:-https://ktoco.kzbikowski.pl}
FRONTEND_URL: ${FRONTEND_URL:-https://example.com}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
@@ -30,10 +30,10 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network=traefik_public
- traefik.http.routers.ktoco.rule=Host(`${DOMAIN:-ktoco.kzbikowski.pl}`)
- traefik.http.routers.ktoco.entrypoints=websecure
- traefik.http.routers.ktoco.tls.certresolver=tls-resolver
- traefik.http.routers.ktoco.tls=true
- traefik.http.routers.whowhat.rule=Host(`${DOMAIN:-example.com}`)
- traefik.http.routers.whowhat.entrypoints=websecure
- traefik.http.routers.whowhat.tls.certresolver=tls-resolver
- traefik.http.routers.whowhat.tls=true
restart: unless-stopped
networks:

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#4f46e5" />
<title>KtoCo - Wydatki wspólne</title>
<title>WhoWhat - Shared Expenses</title>
<link rel="icon" href="/icons/icon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -14,7 +14,7 @@
/>
<script>
(function () {
var t = localStorage.getItem('ktoco_theme');
var t = localStorage.getItem('whowhat_theme');
if (t === 'light' || t === 'dark') {
document.documentElement.setAttribute('data-theme', t);
}

View File

@@ -1,5 +1,5 @@
{
"name": "ktoco-frontend",
"name": "whowhat-frontend",
"version": "1.0.0",
"private": true,
"type": "module",

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';
import { useTranslation } from '../i18n/I18nContext.jsx';
export default function BottomNav() {
const { t } = useTranslation();
const items = [
{ to: '/', label: 'Start', icon: 'home', end: true },
{ to: '/history', label: 'Historia', icon: 'receipt_long' },
{ to: '/', label: t('bottomNav.home'), icon: 'home', end: true },
{ to: '/history', label: t('bottomNav.history'), icon: 'receipt_long' },
];
const rightItems = [
{ to: '/settlements', label: 'Rozliczenia', icon: 'payments' },
{ to: '/stats', label: 'Statystyki', icon: 'bar_chart' },
{ to: '/settlements', label: t('bottomNav.settlements'), icon: 'payments' },
{ to: '/stats', label: t('bottomNav.stats'), icon: 'bar_chart' },
];
export default function BottomNav() {
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) => {
const confirmAction = useCallback(
(options) => {
return new Promise((resolve) => {
setState({
title: options.title || 'Czy na pewno?',
title: options.title || t('confirmDialog.defaultTitle'),
message: options.message || '',
confirmLabel: options.confirmLabel || 'Usuń',
cancelLabel: options.cancelLabel || 'Anuluj',
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"
}

Some files were not shown because too many files have changed in this diff Show More