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