From 2d08db787f072953fc18a403f8196f0b845b6add Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 20:47:29 +0200 Subject: [PATCH] Fix account isolation, catalog state, API validation and pricing regressions --- .github/workflows/ci.yml | 2 +- .nvmrc | 1 + ARCHITEKTURA_I_TECHNOLOGIE.md | 2 + AUDIT_FIXES.md | 64 +++ AUDYT_I_PLAN_ROZWOJU.md | 2 + README.md | 238 ++-------- README.pl.md | 91 ++-- index.html | 13 +- package-lock.json | 59 +++ package.json | 8 +- src/app/api/bn/lookup/route.ts | 5 +- src/app/api/bn/search/route.ts | 9 +- src/app/api/books/lookup/route.ts | 5 +- src/app/api/books/search/route.ts | 5 +- src/app/layout.tsx | 2 +- src/app/page.tsx | 576 +++++-------------------- src/components/AddBookModal.tsx | 20 +- src/components/AuthModal.tsx | 351 +++------------ src/components/AuthorSearchModal.tsx | 19 +- src/components/BarcodeScannerModal.tsx | 182 +++----- src/components/BookModal.tsx | 7 +- src/components/EmptyLibraryHero.tsx | 26 +- src/components/MissingRadar.tsx | 12 +- src/components/Navbar.tsx | 8 +- src/components/SeriesCard.tsx | 5 +- src/components/UserBanner.tsx | 6 +- src/hooks/useAccount.ts | 82 ++++ src/lib/api/bn.ts | 14 +- src/lib/api/bookProviders.ts | 34 +- src/lib/api/validation.ts | 14 + src/lib/library/catalog.ts | 53 +++ src/lib/pricing/priceEngine.ts | 41 +- src/lib/supabase/shelfSync.ts | 6 +- supabase/schema.sql | 34 +- tailwind.config.ts | 8 +- tests/bookProviders.test.ts | 3 +- tests/catalog.test.ts | 36 ++ tests/home.test.ts | 68 +++ tests/priceEngine.test.ts | 14 +- tests/regressions.test.ts | 68 +++ tests/rls.test.ts | 40 ++ 41 files changed, 1001 insertions(+), 1232 deletions(-) create mode 100644 .nvmrc create mode 100644 AUDIT_FIXES.md create mode 100644 src/hooks/useAccount.ts create mode 100644 src/lib/api/validation.ts create mode 100644 src/lib/library/catalog.ts create mode 100644 tests/catalog.test.ts create mode 100644 tests/home.test.ts create mode 100644 tests/regressions.test.ts create mode 100644 tests/rls.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b77aaa..0c925f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: ".nvmrc" cache: "npm" - name: Install dependencies diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/ARCHITEKTURA_I_TECHNOLOGIE.md b/ARCHITEKTURA_I_TECHNOLOGIE.md index bca3a39..cc84ea9 100644 --- a/ARCHITEKTURA_I_TECHNOLOGIE.md +++ b/ARCHITEKTURA_I_TECHNOLOGIE.md @@ -1,3 +1,5 @@ +> Dokument koncepcyjny. Bieżące funkcje i ograniczenia opisuje [README.pl.md](README.pl.md). Podane koszty i limity nie są aktualizowane automatycznie. + # 🛠️ Architektura i Wybór Technologii dla TomeStack Dokument wyjaśniający wybór stosu technologicznego (Tech Stack) dla pełnej wersji aplikacji **TomeStack**, uwzględniający specyfikę polskiego rynku wydawniczego, wygodę kolekcjonerów oraz koszty utrzymania. diff --git a/AUDIT_FIXES.md b/AUDIT_FIXES.md new file mode 100644 index 0000000..5c7c4f9 --- /dev/null +++ b/AUDIT_FIXES.md @@ -0,0 +1,64 @@ +# TomeStack — poprawki po przeglądzie repozytorium + +Baza: `14ccdb90723e63d9461a2c0008d9269ca170eb83` (`main`). +Zakres: kod aplikacji Next.js, komponenty, trasy API, dostawcy danych, katalog, +Supabase/SQL, ceny, prototyp HTML, testy, konfiguracja i dokumentacja. + +## Poprawione problemy + +| Problem | Zmiana | +|---|---| +| Jawne hasła i pozorowane logowanie | Supabase Auth dla kont online. Bez konfiguracji: jawnie lokalny profil bez hasła. Stary rejestr haseł jest usuwany. | +| Dostęp do cudzych rekordów przez `user-*` | RLS oparty na `auth.uid()`, rola `authenticated`, jawne `WITH CHECK`; brak publicznego odczytu e-maili profili. | +| Wspólna półka i reset po logowaniu | Osobne klucze katalogu i profilu; odtwarzanie oznaczeń i migracja poprzedniej aktywnej sesji. | +| Nadpisanie zapisu pustą listą podczas startu | Zapis dopiero po odczycie; uszkodzony zapis pozostaje nietknięty, UI pokazuje błąd. | +| Gubienie oznaczeń przy imporcie wielu książek | Ustalenie ID przed aktualizacją; funkcyjne aktualizacje stanu użytkownika i bieżąca referencja katalogu. | +| Ponowne dodanie usuwało własność lub wskazywało nieistniejące wydanie | Import jest idempotentny i zwraca identyfikator rzeczywistego, istniejącego wydania. | +| Łączenie różnych autorów/cykli | Seria rozpoznawana po autorze oraz nazwie; import bibliografii odnajduje istniejące dzieło w jego serii. | +| Utrata numeracji/odwołań przy automatycznym scalaniu | Odczyt nie usuwa ID ani nie renumeruje tomów. | +| Wishlist automatycznie oznaczana jako posiadana | Nowa pozycja wishlist jest śledzona bez oznaczenia własności. | +| Spóźnione odczyty chmury i wyszukiwania | Ignorowanie odpowiedzi po zmianie profilu, zamknięciu lub nowszym zapytaniu; odczyt nie nadpisuje nowszej lokalnej mutacji. | +| Nieprawdziwe komunikaty o pełnej synchronizacji | UI opisuje lokalny zapis; nieudane zapisy chmurowe pokazują błąd. | +| Fikcyjne ceny, ISBN i lata importowanych książek | Brak automatycznych ofert; brakujące metadane pozostają nieznane. Kuratorowany fallback udostępnia tytuł i autora bez niezweryfikowanego ISBN/okładki. | +| Gubienie tytułów bez polskich znaków | Usunięty filtr uznający takie tytuły za niepożądane angielskie wpisy. | +| Nadmierne skracanie tytułów i nazwisk | Zachowanie znaczących podtytułów, slashy w tytułach i pełnych imion autora; poprawiony odczyt wydawcy i roku. | +| API akceptujące dowolny limit i kod | ISBN-10/13 z sumą kontrolną, `limit` 1–40, maks. 200 znaków zapytania, błędne dane zwracają 400. | +| Koszyk liczący kilka wydań jako kilka książek | Najtańsza oferta danego sklepu na książkę, ścisły filtr oprawy, odrzucenie brakujących/błędnych cen. | +| Błędne kwoty i koszty wysyłki | Separatory tysięcy i groszy, rozpoznanie jawnego kosztu dostawy, osobna liczba książek z ceną. | +| Filtr oprawy ignorujący wydania i zakładka ignorująca filtry | Uwzględnienie posiadanego/dostępnego wydania; przekazanie filtrów do listy wszystkich książek. | +| Nieotwierające się profile importowanych autorów | Bibliografia modalna tworzona z aktualnego katalogu. | +| Skaner przyjmujący obce kody i duplikujący callback | Walidacja ISBN, pojedyncze wykrycie, anulowanie timera, sekwencyjne start/stop i cleanup po zakończeniu startu. | +| Globalny skaner gubiący ISBN formularza | Kod przekazywany do formularza; użytkownik zatwierdza dodanie zamiast automatycznego zapisu w tle. | +| Uszkodzone polskie teksty i brakujące style | UTF-8, zdefiniowane kolory brand, właściwa zmienna fontu, język dokumentu, zawijanie nagłówka. | +| XSS i pozorowany zapis starego prototypu | Escaping nazwy w HTML; widoczne oznaczenie demo; przycisk nie twierdzi, że zapisał książkę. | +| Nieprzenośny runner testów | `node --import tsx --test tests/*.test.ts`; CI korzysta z Node 22 z `.nvmrc`. | + +## Weryfikacja + +- `npm test`: **30 testów**, w tym scenariusze React: import masowy, ponowny import, + status czytania, wishlist, ponowne uruchomienie, wylogowanie i powrót do profilu. +- Testy dostawców używają kontrolowanych odpowiedzi zamiast polegać na sieci. +- Polityki RLS wykonane na lokalnym PostgreSQL/PGlite: dwie tożsamości, rola + anonimowa, próby dostępu do cudzych danych, podmiany właściciela i prefiksu + `user-*`; skrypt SQL wykonany dwukrotnie. +- `npm run typecheck`, `npm run lint`, `npm run build`. +- Środowisko lokalne: Node 24.19.0. Workflow sprawdza Node 22. + +## Wdrożenie i pozostające ograniczenia + +**Przed uruchomieniem kont online zastosuj poprawiony `supabase/schema.sql` w +istniejącym projekcie Supabase i skonfiguruj Auth.** Nie wykonano zmian w żadnej +zewnętrznej bazie. Stare `user-*` rekordy pozostają do świadomej migracji po +potwierdzeniu właściciela; nie zostały automatycznie przypisane do nowych kont. + +Nie testowano logowania/e-maili na rzeczywistym projekcie Supabase, kamery na +fizycznym urządzeniu ani pełnego E2E/układu w przeglądarce. Testy React sprawdzają +stan i logikę, a nie rzeczywisty layout i uprawnienia sprzętowe. + +Pełna synchronizacja katalogu i statusów między urządzeniami, trwała kolejka +ponawiania zapisów/rozwiązywanie konfliktów, rate limiting publicznych API, +kontraktowe testy rzeczywistych dostawców, live ceny, PWA i pełna dostępność są +nadal poza aktualną implementacją. Oprawa bez metadanych ma domyślną wartość do +weryfikacji; dane demonstracyjne nadal nie są bazą zweryfikowanych wydań. + +Poprawki nie stanowią potwierdzenia gotowości całej aplikacji do produkcji. diff --git a/AUDYT_I_PLAN_ROZWOJU.md b/AUDYT_I_PLAN_ROZWOJU.md index 13041cc..ef18fef 100644 --- a/AUDYT_I_PLAN_ROZWOJU.md +++ b/AUDYT_I_PLAN_ROZWOJU.md @@ -1,5 +1,7 @@ # TomeStack — audyt techniczny i plan rozwoju +> Raport historyczny dla wskazanego commitu. Aktualne poprawki i ograniczenia: [AUDIT_FIXES.md](AUDIT_FIXES.md). + **Data audytu:** 8 września 2026 **Audytowany commit bazowy:** `079773d` diff --git a/README.md b/README.md index aecbe3b..6db5c17 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,64 @@ -# 📚 TomeStack – The Series Completionist & Book Edition Tracker +# TomeStack -[![GitHub License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Prototype](https://img.shields.io/badge/prototype-v2.2%20Auth%20Ready-emerald.svg)](index.html) -[![Status](https://img.shields.io/badge/status-planning%20%26%20prototyping-amber.svg)](#roadmap) -[![Language](https://img.shields.io/badge/language-PL%20%2F%20EN-purple.svg)](#-multilingual-support) +Personal book catalog and series tracker built with Next.js, React and TypeScript. -> **Never lose track of a series again.** -> TomeStack is a modern personal book catalog and series completion tracker designed specifically for book collectors and avid readers. It tracks owned volumes, distinguishes between publisher editions and binding types (hardcover vs. paperback), uncovers missing tomes across your collections, compares real-time bookstore deals, and presents full author bibliographies. +[Polski](README.pl.md) · [Audit fixes](AUDIT_FIXES.md) · [Architecture proposal](ARCHITEKTURA_I_TECHNOLOGIE.md) -*Read this document in Polish: [README.pl.md](README.pl.md) | Szczegóły architektury: [ARCHITEKTURA_I_TECHNOLOGIE.md](ARCHITEKTURA_I_TECHNOLOGIE.md)* +## Run the application ---- +Use Node.js 22 (see `.nvmrc`). -## 🎯 The Problem TomeStack Solves - -Most book cataloging apps (like Goodreads or StoryGraph) are reading logs focused on social reviews and annual reading goals. They fail collectors who care about physical shelves and complete book series: - -1. **Broken Series Tracking:** Standard platforms don't alert you when a new volume in a cycle is published or clearly visualize which exact books are missing from your series (e.g., owning books 1, 2, 4, 5 of an 8-book saga). -2. **Ignored Book Editions & Bindings:** Collectors care deeply about print formats—whether a book is Hardcover (`📖 Twarda`) or Paperback (`📕 Miękka`), the publisher imprint, and year of print. -3. **Scattered Author Bibliographies:** Finding an author's complete release order (including spin-offs, short-story anthologies, and side prequels) usually requires tedious cross-referencing between Wikipedia and Polish databases like LubimyCzytać or Biblioteka Narodowa. -4. **Bookstore Price Hunting:** When you identify a missing volume, checking prices across multiple retailers (Empik, Świat Książki, TaniaKsiążka, Allegro) is repetitive and time-consuming. - -**TomeStack solves all of these in a single, unified experience.** - ---- - -## ✨ Key Features - -### 👤 1. Multi-User Authentication & Personal Shelves -* Every user has an independent, private bookshelf with their own collection statistics. -* **Instant Account Switcher:** Switch seamlessly between demo collector profiles (*Kamil* – fantasy enthusiast, *Anna* – classic literature collector, or *Guest* mode) to test different collection configurations. -* Completion rates and missing volume alerts recalculate instantly upon account switching. - -### 📈 2. Series Completion Progress & Statistics -* Visual percentage progress bars for every saga (e.g., *The Witcher: 6/8 volumes [75%]*). -* Real-time indicators of owned vs. unowned volumes in canonical reading order. -* Global collection metrics: Total Volumes, Owned Volumes, Missing Volumes, and Average Series Completion Rate. - -### 🎯 3. Missing Volumes Radar (Completionist Mode) -* A dedicated dashboard that aggregates every unowned volume across all your active series. -* Prioritize finishing sagas that are already 70%+ completed. -* Direct store action buttons to find and acquire missing books immediately. - -### 📖 4. Edition Explorer & Format Filtering -* Filter your entire library or individual volumes by binding format: - * **📖 Hardcover (`Twarda oprawa`)** - * **📕 Paperback (`Miękka oprawa`)** -* Inspect precise edition metadata: Year of publication, Publisher, Translator, Page count, and ISBN-13. - -### 💰 5. Live Bookstore Price Comparison -* Automated price aggregation across major Polish book retailers: - * **Świat Książki** - * **TaniaKsiążka.pl** - * **Empik** - * **Allegro** -* Highlights the cheapest available price with direct purchase links and binding badges. - -### ✍️ 6. Full Author Bibliographies -* Click on any author name (e.g., *Andrzej Sapkowski*, *J.R.R. Tolkien*, *Frank Herbert*) to open a dedicated bibliography overlay. -* View all chronological cycles, standalone novels, companion guidebooks, and anthologies. -* Visual badge indicators showing which of the author's books you already own and what is left to explore. - -### 📱 7. Barcode / ISBN Camera Scanner -* Fast, in-browser optical barcode scanning using mobile cameras (ZXing / html5-qrcode). -* Scan the back cover of any physical book in a bookstore or home library to instantly pull metadata and add it to your shelf. - -### 🌐 8. Multilingual Support (PL / EN) -* One-click language switcher toggling the entire interface between Polish and English. - ---- +```sh +npm ci +cp .env.example .env.local +npm run dev +``` -## 🖥️ Interactive Prototype Walkthrough +Open http://localhost:3000. For a production build, run `npm run build` followed by `npm start`. -TomeStack includes a fully functioning, interactive single-page application prototype (`index.html`) demonstrating the complete design system and user experience. +## Available now -### How to Run the Prototype Locally: -1. Clone this repository: - ```bash - git clone https://github.com/MatthiasLew/TomeStack.git - cd TomeStack - ``` -2. Open [`index.html`](index.html) directly in any modern web browser: - * **Windows (PowerShell):** `Start-Process index.html` - * **macOS:** `open index.html` - * **Linux:** `xdg-open index.html` -3. Try switching between demo users (*Kamil* vs *Anna* vs *Guest*), filtering by hardcover editions, clicking author names, and viewing missing volume purchase links. +- Catalog books, editions, authors and series; track ownership and reading status. +- Import available book metadata from Biblioteka Narodowa, Open Library and Google Books. +- ISBN camera scanner with a manual completion form; camera access requires HTTPS or localhost. +- Binding/status filters, hidden books and a missing-volume radar. +- Separate browser storage for each local profile or authenticated account. +- Optional Supabase email/password authentication and ownership-map synchronization. ---- +Without Supabase configuration, the application offers **local profiles without passwords**. Anyone using the same browser can open them by name. They are not secure online accounts. Guest collections remain separate from profile collections. -## 🏗️ Production Architecture & Tech Stack +## Optional Supabase setup -``` -┌─────────────────────────────────────────────────────────────┐ -│ FRONTEND & PWA │ -│ Next.js 14+ (App Router) + React + Tailwind CSS + Lucide │ -│ • In-browser camera ISBN barcode scanner (html5-qrcode) │ -│ • PWA support (installable on iOS & Android home screens) │ -│ • Server-Side Rendering (SSR) for fast author & book SEO │ -└──────────────────────────────┬──────────────────────────────┘ - │ (REST / Next.js Server Actions) -┌──────────────────────────────▼──────────────────────────────┐ -│ BACKEND & DATABASE │ -│ Supabase (Managed PostgreSQL) │ -│ • Relational schema: Works ➔ Editions ➔ User Shelf Items │ -│ • Row Level Security (RLS) ensuring strict user privacy │ -│ • Fast indexing on ISBN-10, ISBN-13, and Author EAN │ -└──────────────────────────────┬──────────────────────────────┘ - │ (Metadata ingestion & price feeds) -┌──────────────────────────────▼──────────────────────────────┐ -│ EXTERNAL APIS & INTEGRATIONS │ -│ 1. National Library of Poland API (data.bn.org.pl) │ -│ Official registry of all Polish ISBNs, covers & prints │ -│ 2. Google Books API & Open Library API (Global editions) │ -│ 3. Price Aggregation Feeds (Ceneo API / Allegro REST) │ -└─────────────────────────────────────────────────────────────┘ -``` +1. Create a Supabase project and run `supabase/schema.sql` in its SQL editor. +2. Set `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` in `.env.local`. +3. Configure email authentication and the site's redirect URL in Supabase Auth. If email confirmation is enabled, confirm the address before signing in. +4. Restart/rebuild the application after setting public environment variables. -### Relational Data Model (Core Entities) - -```mermaid -erDiagram - AUTHOR ||--o{ WORK : writes - SERIES ||--o{ WORK : contains - WORK ||--|{ EDITION : has - EDITION ||--o{ PRICE_OFFER : priced_at - USER ||--o{ USER_BOOK : owns - EDITION ||--o{ USER_BOOK : logged_as - - AUTHOR { - uuid id PK - string name - string nationality - string bio - } - SERIES { - uuid id PK - string title - int total_volumes - uuid author_id FK - } - WORK { - uuid id PK - string title - int series_order - uuid series_id FK - uuid author_id FK - } - EDITION { - uuid id PK - uuid work_id FK - string isbn13 - string publisher - int publication_year - string format "HARDCOVER | PAPERBACK | EBOOK" - string cover_url - } - PRICE_OFFER { - uuid id PK - uuid edition_id FK - string store_name - decimal price_pln - string store_url - datetime updated_at - } - USER_BOOK { - uuid id PK - uuid user_id FK - uuid edition_id FK - string reading_status "READ | READING | PLAN_TO_READ" - datetime acquired_at - } -``` +The SQL script is rerunnable and replaces the old unsafe RLS policies. Existing installations must **apply the updated script**; changing this repository does not change an existing database. Legacy `user-*` records remain in the database but are inaccessible to normal clients. They must not be assigned to new accounts automatically, because the old IDs were not authenticated. ---- +Only the `book_id → edition_id` ownership map currently syncs to Supabase. The catalog, reading status and visibility preferences remain in this browser. This is **not yet full cross-device collection synchronization**. Failed cloud writes are shown in the UI; there is no durable retry queue or conflict resolution. -## 🛠️ Developer Tooling & AI-Assisted Workflow +Old browser sessions are migrated to a local profile using their existing profile name, preserving the active user's catalog and ownership. The old plaintext password registry is removed. Old passwords do not create Supabase accounts. The original legacy catalog is retained as a backup; it is not shared with new accounts. -This project is integrated with specialized autonomous developer tooling: +## Data and prototype limitations -* **[ai-dev-cli-tools](https://github.com/MatthiasLew/ai-dev-cli-tools):** Provides deterministic project scanning, context packaging, lightweight test validation, and telemetry tracking. -* **[freelance-dev-suite](https://github.com/MatthiasLew/freelance-dev-suite):** Manages project intake, scope boundaries, task verification, and automated quality gates. +- `index.html` is an **archived UI prototype**, not the current application. Its account switcher and prices are simulated, and its add-book button does not save data. +- Demo collections contain illustrative prices. No live retailer price feed is connected. Imported/manual books receive no fabricated offers. +- Curated author seeds and legacy demo metadata are not verified edition records. Search results are limited and do not guarantee a complete bibliography. Check ISBN, publisher and cover against the physical edition. +- Shipping calculations are estimates; unknown shipping uses 9.99 PLN, and combined lowest prices exclude delivery. +- PWA/offline installation, sharing, full translations/accessibility, distributed API rate limiting and production monitoring remain future work. -### Running Project Checks -```bash -# Run deterministic scanner -ai-dev scan --project . +## Checks -# Build compact context report for coding agents -ai-dev context build --project . +```sh +npm test +npm run typecheck +npm run lint +npm run build ``` ---- - -## 🗺️ Roadmap - -- [x] **v0.1 – Conceptual Architecture & Plan:** Defined relational database model, Polish API sources (BN), and pricing aggregation architecture. -- [x] **v0.2 – Interactive Prototype (v2.2):** Built standalone Tailwind SPA prototype with multi-user simulation, missing radar, author bibliography modal, and bookstore comparison. -- [ ] **v1.0 – Next.js & Supabase Foundation:** - - Initialize Next.js 14 App Router project with TypeScript & Tailwind CSS. - - Set up Supabase PostgreSQL schemas with Row Level Security. - - Supabase Auth (Email + Google OAuth). -- [ ] **v1.1 – Polish National Library (`data.bn.org.pl`) Ingestion:** - - Automated lookup and cataloging by ISBN-13. - - Fetching verified publication metadata and covers. -- [ ] **v1.2 – ISBN Barcode Scanner:** - - Integrated mobile camera scanner in web browser. -- [ ] **v1.3 – Price Hunting Aggregator:** - - Background cron jobs fetching live bookstore prices for unowned series items. -- [ ] **v2.0 – Community & Social Shelves:** - - Shareable public shelf links with OpenGraph cards for Discord/Facebook. - - Friend collection comparison (see what books your friends can lend you). - ---- +Tests cover catalog/state regressions, mocked provider responses, ISBN/limit validation, basket calculations and PostgreSQL RLS. RLS is executed with PGlite against isolated test data, not a live Supabase project. React state tests do not replace browser or physical-camera testing. -## 📄 License +## License -This project is open-source under the [MIT License](LICENSE). +MIT. See [LICENSE](LICENSE). diff --git a/README.pl.md b/README.pl.md index 3129ad3..b4ca702 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1,40 +1,51 @@ -# 📚 TomeStack – The Series Completionist & Edition Tracker - -> Twoja osobista biblioteczka online, która śledzi polskie i zagraniczne wydania, wykrywa brakujące tomy w seriach, pozwala przeglądać pełne bibliografie autorów oraz porównuje ceny w księgarniach internetowych. - ---- - -## 🖥️ Jak uruchomić prototyp (Mockup)? - -1. Przejdź do folderu: - ```text - C:\Users\Praca\Desktop\Plany\Biblioteka online\ - ``` -2. Kliknij dwukrotnie w plik: - 👉 **`index.html`** -3. Strona uruchomi się bezpośrednio w Twojej przeglądarce internetowej! - ---- - -## ✨ Kluczowe Funkcje Aplikacji - -* 👤 **Prywatne konta i osobiste biblioteczki (NOWOŚĆ w v2.2):** - * Każdy użytkownik loguje się i posiada **swoją własną, niezależną półkę**. - * W prototypie możesz w ułamku sekundy przełączać się między kontami demonstracyjnymi: - * **👤 Kamil** (kolekcja fantasy: Wiedźmin 75%, Diuna, itp.) - * **👤 Anna** (fanka klasyki: Harry Potter 100%, Tolkien 100%) - * **👁️ Gość (Wylogowany)** (przeglądanie katalogu z zachętą do rejestracji) - * Statystyki ukończenia serii i radar braków przeliczają się natychmiast po zmianie użytkownika! -* 📖 **Wybór formatu oprawy (Twarda vs Miękka):** - * Filtrowanie całej biblioteki i pojedynczych tomów pod kątem typu oprawy (`📖 Twarda` / `📕 Miękka`). - * Wybór dokładnego wydania posiadanego na półce (rok, wydawnictwo, ISBN, okładka). -* 💰 **Porównywarka Cen w Księgarniach Online:** Bezpośrednie porównanie ofert w polskich księgarniach (*Świat Książki, TaniaKsiążka.pl, Empik, Allegro*) z filtrowaniem według typu oprawy, oznaczeniem najtańszej oferty i bezpośrednim linkiem do zakupu. -* 🎯 **Radar Braków (Completionist Mode):** Szybka lista wszystkich brakujących tomów ze wszystkich Twoich serii. -* ✍️ **Profil Autora i Pełna Bibliografia:** Kliknij nazwisko dowolnego autora, aby zobaczyć wszystkie jego cykle, książki samodzielne oraz stopień skompletowania jego twórczości przez zalogowanego użytkownika. -* 🌐 **Bilingual (PL / EN):** Błyskawiczny przełącznik języka w prawym górnym rogu. - ---- - -## 🛠️ Architektura i Baza Danych: -Szczegółowe wyjaśnienie doboru technologii (Next.js, PostgreSQL/Supabase, API Biblioteki Narodowej) znajdziesz w: -👉 [**`ARCHITEKTURA_I_TECHNOLOGIE.md`**](file:///C:/Users/Praca/Desktop/Plany/Biblioteka%20online/ARCHITEKTURA_I_TECHNOLOGIE.md). +# TomeStack + +Katalog książek, wydań i serii w Next.js + React + TypeScript. + +## Uruchomienie + +Node.js 22: + +```sh +npm ci +cp .env.example .env.local +npm run dev +``` + +Otwórz http://localhost:3000. W PowerShell plik środowiska skopiujesz poleceniem `Copy-Item .env.example .env.local`. + +**Uruchamiaj aplikację Next.js.** `index.html` jest archiwalną demonstracją interfejsu; jego logowanie i ceny są symulowane, a przycisk dodawania nie zapisuje książek. + +## Konta i przechowywanie + +Bez konfiguracji Supabase korzystasz z **profilu lokalnego bez hasła**. Każdy, kto korzysta z tej samej przeglądarki, może otworzyć profil po nazwie. Ponowne wpisanie tej samej nazwy przywraca jego kolekcję. + +Dla kont online ustaw `NEXT_PUBLIC_SUPABASE_URL` i `NEXT_PUBLIC_SUPABASE_ANON_KEY`, uruchom `supabase/schema.sql` w Supabase i skonfiguruj logowanie e-mail. Jeśli projekt wymaga potwierdzenia adresu, potwierdź e-mail przed logowaniem. + +**Istniejąca baza wymaga ponownego uruchomienia poprawionego skryptu SQL.** Sam commit nie zmienia wdrożonych reguł RLS. Skrypt blokuje dawny wyjątek `user-*`, a stare rekordy pozostawia do ręcznej, zweryfikowanej migracji. + +Do chmury trafia tylko oznaczenie posiadanych książek i wydań. Katalog, statusy czytania i ukrywanie pozostają w przeglądarce. Pełna synchronizacja między urządzeniami nie jest jeszcze gotowa; nie ma też trwałej kolejki ponawiania nieudanych zapisów. + +Poprzednia aktywna sesja jest przenoszona do profilu lokalnego o dotychczasowej nazwie. Jawne hasła zapisane przez starą wersję są usuwane. Konta Supabase trzeba utworzyć oddzielnie; starych identyfikatorów nie można uznać za potwierdzoną tożsamość. + +## Funkcje i ograniczenia + +- Dodawanie książek i wydań, import z katalogów, status czytania, filtry, radar braków. +- Skanowanie ISBN wymaga dostępu do aparatu i HTTPS lub localhost. Po skanowaniu dane można uzupełnić w formularzu. +- Ceny w kolekcjach demo są poglądowe. Brak integracji z aktualnymi ofertami sklepów. +- Importowane książki nie otrzymują fikcyjnych cen, ISBN ani roku wydania. +- Dane kuratorowane/demo wymagają weryfikacji względem konkretnego wydania. Wynik wyszukiwania nie gwarantuje pełnej bibliografii. +- PWA, pełna synchronizacja, pełne tłumaczenia/dostępność i ograniczanie ruchu API pozostają do rozwinięcia. + +## Weryfikacja + +```sh +npm test +npm run typecheck +npm run lint +npm run build +``` + +Testy używają atrap odpowiedzi zewnętrznych API, testów stanu React i lokalnego silnika PostgreSQL (PGlite) do sprawdzenia izolacji RLS. + +Szczegóły: [raport poprawek](AUDIT_FIXES.md), [README EN](README.md), [proponowana architektura](ARCHITEKTURA_I_TECHNOLOGIE.md). diff --git a/index.html b/index.html index ec65be3..33ea7df 100644 --- a/index.html +++ b/index.html @@ -78,6 +78,8 @@ + +
@@ -320,7 +322,7 @@

Logowanie do Biblioteczki

- +
@@ -704,6 +706,11 @@

Dodaj nową książ } // --- AUTHENTICATION LOGIC & SWITCHER --- + function escapeHtml(value) { + const element = document.createElement('span'); + element.textContent = String(value); + return element.innerHTML; + } function renderAuthNav() { const widget = document.getElementById('authNavWidget'); const t = i18n[currentLang]; @@ -714,7 +721,7 @@

Dodaj nową książ
@@ -1828,7 +1835,7 @@

${series.seriesName}

alert("Wpisz przynajmniej tytuł i autora!"); return; } - alert(`Dodano "${title}" (${author}, oprawa: ${fmt === 'hardcover' ? 'Twarda' : 'Miękka'})! Zapisano na Twojej półce.`); + alert("To jest demonstracja interfejsu. Aby zapisać książkę, uruchom aplikację Next.js opisaną w README."); closeAddModal(); } diff --git a/package-lock.json b/package-lock.json index cfe29d1..9d1e569 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,15 +16,21 @@ "react-dom": "^18" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.8", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/react-test-renderer": "^18.3.1", "eslint": "^8", "eslint-config-next": "14.2.35", "postcss": "^8", + "react-test-renderer": "^18.3.1", "tailwindcss": "^3.4.1", "tsx": "^4.23.13", "typescript": "^5" + }, + "engines": { + "node": ">=22 <25" } }, "node_modules/@alloc/quick-lru": { @@ -38,6 +44,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz", + "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1070,6 +1083,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "^18" + } + }, "node_modules/@typescript-eslint/parser": { "version": "8.70.0", "dev": true, @@ -5464,6 +5487,42 @@ "dev": true, "license": "MIT" }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.3.1", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-test-renderer/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.2", "dev": true, diff --git a/package.json b/package.json index d27220a..3a418a0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "tsx --test tests/**/*.test.ts" + "test": "node --import tsx --test tests/*.test.ts" }, "dependencies": { "@supabase/supabase-js": "^2.116.0", @@ -19,14 +19,20 @@ "react-dom": "^18" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.8", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/react-test-renderer": "^18.3.1", "eslint": "^8", "eslint-config-next": "14.2.35", "postcss": "^8", + "react-test-renderer": "^18.3.1", "tailwindcss": "^3.4.1", "tsx": "^4.23.13", "typescript": "^5" + }, + "engines": { + "node": ">=22 <25" } } diff --git a/src/app/api/bn/lookup/route.ts b/src/app/api/bn/lookup/route.ts index f4d3098..a227080 100644 --- a/src/app/api/bn/lookup/route.ts +++ b/src/app/api/bn/lookup/route.ts @@ -1,3 +1,4 @@ +import { isValidIsbn } from "@/lib/api/validation"; import { NextRequest, NextResponse } from "next/server"; import { fetchBnByIsbn } from "@/lib/api/bn"; @@ -5,9 +6,9 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const isbn = searchParams.get("isbn"); - if (!isbn) { + if (!isbn || !isValidIsbn(isbn)) { return NextResponse.json( - { error: "Parametr 'isbn' jest wymagany." }, + { error: "Wymagany jest poprawny ISBN-10 lub ISBN-13." }, { status: 400 } ); } diff --git a/src/app/api/bn/search/route.ts b/src/app/api/bn/search/route.ts index 202d598..d6010e0 100644 --- a/src/app/api/bn/search/route.ts +++ b/src/app/api/bn/search/route.ts @@ -1,12 +1,15 @@ +import { parseSearchLimit } from "@/lib/api/validation"; import { NextRequest, NextResponse } from "next/server"; import { fetchBnByQuery } from "@/lib/api/bn"; export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - const title = searchParams.get("title") || undefined; - const author = searchParams.get("author") || undefined; + const title = searchParams.get("title")?.trim() || undefined; + const author = searchParams.get("author")?.trim() || undefined; const limitParam = searchParams.get("limit"); - const limit = limitParam ? parseInt(limitParam, 10) : 5; + const limit = parseSearchLimit(limitParam, 5); + if ((title?.length || 0) > 200 || (author?.length || 0) > 200) return NextResponse.json({ error: "Query is too long (max 200 characters)" }, { status: 400 }); + if (limit === null) return NextResponse.json({ error: "limit must be an integer from 1 to 40" }, { status: 400 }); if (!title && !author) { return NextResponse.json( diff --git a/src/app/api/books/lookup/route.ts b/src/app/api/books/lookup/route.ts index e02214b..dbe51d0 100644 --- a/src/app/api/books/lookup/route.ts +++ b/src/app/api/books/lookup/route.ts @@ -1,3 +1,4 @@ +import { isValidIsbn } from "@/lib/api/validation"; import { NextRequest, NextResponse } from "next/server"; import { unifiedLookupByIsbn } from "@/lib/api/bookProviders"; @@ -5,9 +6,9 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const isbn = searchParams.get("isbn"); - if (!isbn || !isbn.trim()) { + if (!isbn || !isValidIsbn(isbn)) { return NextResponse.json( - { error: "Parametr 'isbn' jest wymagany." }, + { error: "Wymagany jest poprawny ISBN-10 lub ISBN-13." }, { status: 400 } ); } diff --git a/src/app/api/books/search/route.ts b/src/app/api/books/search/route.ts index a71a092..61c45c8 100644 --- a/src/app/api/books/search/route.ts +++ b/src/app/api/books/search/route.ts @@ -1,3 +1,4 @@ +import { parseSearchLimit } from "@/lib/api/validation"; import { NextRequest, NextResponse } from "next/server"; import { unifiedSearchByQuery, unifiedSearchByAuthor } from "@/lib/api/bookProviders"; @@ -5,7 +6,9 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const q = searchParams.get("q") || searchParams.get("query") || ""; const author = searchParams.get("author") || ""; - const limit = parseInt(searchParams.get("limit") || "16", 10); + const limit = parseSearchLimit(searchParams.get("limit"), 16); + if ((q?.length || 0) > 200 || (author?.length || 0) > 200) return NextResponse.json({ error: "Query is too long (max 200 characters)" }, { status: 400 }); + if (limit === null) return NextResponse.json({ error: "limit must be an integer from 1 to 40" }, { status: 400 }); if (!q.trim() && !author.trim()) { return NextResponse.json( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0248a8b..e1a230c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -24,7 +24,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/src/app/page.tsx b/src/app/page.tsx index 94ab5e6..f831af8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,10 +7,7 @@ import { StatusFilter, ActiveTab, Book, - BookEdition, Series, - UserAccount, - BindingFormat, ReadingStatus, } from "@/types"; import { @@ -34,7 +31,7 @@ import { EmptyLibraryHero } from "@/components/EmptyLibraryHero"; import { canonicalizeBookTitle, cleanDisplayTitle, - CURATED_AUTHOR_BIBLIOGRAPHIES, + UnifiedBookMetadata, } from "@/lib/api/bookProviders"; import { loadUserShelfFromCloud, @@ -42,168 +39,55 @@ import { removeUserBookFromCloud, } from "@/lib/supabase/shelfSync"; -/** - * Consolidates series books so that any duplicates representing the same literary work - * are automatically merged into a single card with multiple editions. - * Automatically enriches missing or broken covers with verified high-res covers. - */ -function consolidateSeriesList(list: Series[]): Series[] { - // Build lookup of verified high-res covers from initial database & curated bibliographies - const knownCoverMap = new Map(); - - for (const s of initialSeriesDatabase) { - for (const b of s.books) { - if (b.cover && !b.cover.includes("/b/isbn/")) { - const k = canonicalizeBookTitle(cleanDisplayTitle(b.title)); - if (k && !knownCoverMap.has(k)) { - knownCoverMap.set(k, b.cover); - } - } - } - } - - for (const authorWorks of Object.values(CURATED_AUTHOR_BIBLIOGRAPHIES)) { - for (const w of authorWorks) { - if (w.coverUrl && !w.coverUrl.includes("/b/isbn/")) { - const k = canonicalizeBookTitle(cleanDisplayTitle(w.title)); - if (k && !knownCoverMap.has(k)) { - knownCoverMap.set(k, w.coverUrl); - } - } - } - } - - return list.map((series) => { - const canonicalMap = new Map(); - - for (const book of series.books) { - const displayTitle = cleanDisplayTitle(book.title); - const key = canonicalizeBookTitle(displayTitle); - if (!key) continue; - - const verifiedCover = knownCoverMap.get(key); - - const existing = canonicalMap.get(key); - if (!existing) { - let cover = book.cover; - if ((!cover || cover.includes("/b/isbn/")) && verifiedCover) { - cover = verifiedCover; - } - - canonicalMap.set(key, { - ...book, - title: displayTitle, - cover: cover || "", - }); - } else { - // Merge editions (deduplicating by ISBN or format/publisher) - const existingIsbns = new Set(existing.editions.map((e) => e.isbn).filter(Boolean)); - const mergedEditions = [...existing.editions]; - for (const ed of book.editions) { - if (!ed.isbn || !existingIsbns.has(ed.isbn)) { - mergedEditions.push(ed); - if (ed.isbn) existingIsbns.add(ed.isbn); - } - } - - // Merge prices (deduplicating by store + formatType) - const mergedPrices = [...existing.prices]; - for (const p of book.prices) { - if (!mergedPrices.some((mp) => mp.store === p.store && mp.formatType === p.formatType)) { - mergedPrices.push(p); - } - } - - // Prefer verified cover, existing cover, or incoming cover - let cover = existing.cover || book.cover; - if ((!cover || cover.includes("/b/isbn/")) && verifiedCover) { - cover = verifiedCover; - } - - // Keep shorter / cleaner title - const currentTitle = cleanDisplayTitle(existing.title); - const candidateTitle = cleanDisplayTitle(book.title); - const title = candidateTitle.length < currentTitle.length ? candidateTitle : currentTitle; - - canonicalMap.set(key, { - ...existing, - title, - cover: cover || "", - editions: mergedEditions, - prices: mergedPrices, - }); - } - } +import { useAccount, shelfKey } from "@/hooks/useAccount"; +import { addBookToCatalog, AddBookData } from "@/lib/library/catalog"; +import { supabase } from "@/lib/supabase/client"; - const consolidatedBooks = Array.from(canonicalMap.values()).map((b, idx) => ({ - ...b, - volume: idx + 1, - })); - - return { - ...series, - books: consolidatedBooks, - }; - }); +// Preserve book and edition IDs referenced by saved user state. +function consolidateSeriesList(list: Series[]): Series[] { + return list.map(series => ({ ...series, books: series.books.map(book => ({ ...book, title: cleanDisplayTitle(book.title) })) })); } export default function Home() { - const [lang, setLang] = useState("pl"); - const [currentUser, setCurrentUser] = useState(null); - const [isAuthLoaded, setIsAuthLoaded] = useState(false); - const [seriesList, setSeriesList] = useState([]); - - // Load user session from localStorage on startup - React.useEffect(() => { - try { - const stored = localStorage.getItem("tomestack_user"); - if (stored) { - const parsed = JSON.parse(stored); - setCurrentUser(parsed); - } else { - // Automatically prompt sign-in on first visit so each user has their own private shelf - setIsAuthOpen(true); - } - } catch { - setIsAuthOpen(true); - } finally { - setIsAuthLoaded(true); - } - }, []); - - // Save session when user changes - React.useEffect(() => { - if (!isAuthLoaded) return; - if (currentUser) { - localStorage.setItem("tomestack_user", JSON.stringify(currentUser)); - } else { - localStorage.removeItem("tomestack_user"); - } - }, [currentUser, isAuthLoaded]); + const account = useAccount(); + if (!account.loaded) return
Ładowanie… / Loading…
; + return ; +} - // Load user's saved books from localStorage on startup and auto-consolidate duplicates +function LibraryHome({ currentUser, setCurrentUser, logout, error }: ReturnType) { + const [lang, setLang] = useState("pl"); + const [seriesList, setSeriesState] = useState([]); + const seriesRef = React.useRef([]); + const [shelfLoaded, setShelfLoaded] = useState(false); + const [storageError, setStorageError] = useState(''); + const mutationVersion = React.useRef(0); + const mounted = React.useRef(true); + React.useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); + const setSeriesList = (update: React.SetStateAction) => { + const next = typeof update === 'function' ? update(seriesRef.current) : update; + seriesRef.current = next; + setSeriesState(next); + }; + const storageKey = shelfKey(currentUser?.id); React.useEffect(() => { try { - const storedSeries = localStorage.getItem("tomestack_user_shelf_v4"); - if (storedSeries) { - const parsed = JSON.parse(storedSeries); - if (Array.isArray(parsed)) { - setSeriesList(consolidateSeriesList(parsed)); - } - } - } catch { - // ignore - } - }, []); - - // Save user's shelf whenever books/authors are added or removed + const parsed = JSON.parse(localStorage.getItem(storageKey) || '[]'); + if (!Array.isArray(parsed) || !parsed.every(s => typeof s.seriesId === 'string' && typeof s.author === 'string' && typeof s.seriesName === 'string' && Array.isArray(s.books) && s.books.every((b: Book) => typeof b.title === 'string' && Array.isArray(b.editions) && Array.isArray(b.prices)))) throw new Error('Invalid shelf'); + const list = consolidateSeriesList(parsed); + seriesRef.current = list; + setSeriesState(list); + setShelfLoaded(true); + } catch { setStorageError('Nie można odczytać półki. Zachowano oryginalny zapis. / Could not read shelf; original data preserved.'); } + }, [storageKey]); React.useEffect(() => { - try { - localStorage.setItem("tomestack_user_shelf_v4", JSON.stringify(seriesList)); - } catch { - // ignore - } - }, [seriesList]); + if (!shelfLoaded) return; + try { localStorage.setItem(storageKey, JSON.stringify(seriesList)); } + catch { setStorageError('Nie udało się zapisać półki. / Could not save shelf.'); } + }, [seriesList, shelfLoaded, storageKey]); + React.useEffect(() => { document.documentElement.lang = lang; }, [lang]); + const reportSync = (ok: boolean) => { + if (!ok && supabase && mounted.current) setStorageError('Zmiana jest lokalna: zapis w chmurze nie powiódł się. / Change is local: cloud save failed.'); + }; // Handler to load demo library on user request const handleLoadDemoData = () => { @@ -213,7 +97,11 @@ export default function Home() { // Handler to clear library back to empty state const handleClearLibrary = () => { setSeriesList([]); - localStorage.removeItem("tomestack_user_shelf_v4"); + mutationVersion.current++; + if (currentUser) { + Object.keys(currentUser.ownedBooks).forEach(id => { void removeUserBookFromCloud(currentUser.id, id).then(reportSync); }); + setCurrentUser(prev => prev ? { ...prev, ownedBooks: {}, readingStatus: {}, hiddenBooks: {}, hiddenSeries: {} } : null); + } }; // Filters & Tabs @@ -230,7 +118,8 @@ export default function Home() { } | null>(null); const [activeAuthorName, setActiveAuthorName] = useState(null); const [isAddBookOpen, setIsAddBookOpen] = useState(false); - const [isAuthOpen, setIsAuthOpen] = useState(false); + const [scannedIsbn, setScannedIsbn] = useState(""); + const [isAuthOpen, setIsAuthOpen] = useState(!currentUser); const [isScannerOpen, setIsScannerOpen] = useState(false); const [isAuthorSearchOpen, setIsAuthorSearchOpen] = useState(false); const [authorSearchInitialQuery, setAuthorSearchInitialQuery] = useState(""); @@ -310,14 +199,15 @@ export default function Home() { const currentUserId = currentUser?.id; React.useEffect(() => { if (!currentUserId) return; - loadUserShelfFromCloud(currentUserId).then((cloudShelf) => { - if (cloudShelf && Object.keys(cloudShelf).length > 0) { - setCurrentUser((prev) => - prev ? { ...prev, ownedBooks: { ...(prev.ownedBooks || {}), ...cloudShelf } } : null - ); + let cancelled = false; + const version = mutationVersion.current; + loadUserShelfFromCloud(currentUserId).then(cloudShelf => { + if (!cancelled && cloudShelf !== null && version === mutationVersion.current) { + setCurrentUser(prev => prev?.id === currentUserId ? { ...prev, ownedBooks: cloudShelf } : prev); } }); - }, [currentUserId]); + return () => { cancelled = true; }; + }, [currentUserId, setCurrentUser]); const handleToggleOwned = (bookId: string, defaultEditionId: string) => { if (!currentUser) { @@ -328,14 +218,14 @@ export default function Home() { const currentOwned = { ...(currentUser.ownedBooks || {}) }; if (currentOwned[bookId]) { delete currentOwned[bookId]; - removeUserBookFromCloud(currentUser.id, bookId); + void removeUserBookFromCloud(currentUser.id, bookId).then(reportSync); } else { currentOwned[bookId] = defaultEditionId; - saveUserBookToCloud(currentUser.id, bookId, defaultEditionId); + void saveUserBookToCloud(currentUser.id, bookId, defaultEditionId).then(reportSync); } - const updatedUser = { ...currentUser, ownedBooks: currentOwned }; - setCurrentUser(updatedUser); + mutationVersion.current++; + setCurrentUser(prev => prev ? { ...prev, ownedBooks: currentOwned } : prev); }; const handleSelectEdition = (bookId: string, editionId: string) => { @@ -344,12 +234,9 @@ export default function Home() { return; } - const currentOwned = { ...(currentUser.ownedBooks || {}) }; - currentOwned[bookId] = editionId; - saveUserBookToCloud(currentUser.id, bookId, editionId); - - const updatedUser = { ...currentUser, ownedBooks: currentOwned }; - setCurrentUser(updatedUser); + mutationVersion.current++; + void saveUserBookToCloud(currentUser.id, bookId, editionId).then(reportSync); + setCurrentUser(prev => prev ? { ...prev, ownedBooks: { ...prev.ownedBooks, [bookId]: editionId } } : prev); }; // Reading status handlers @@ -359,18 +246,12 @@ export default function Home() { return; } - const currentStatuses = { ...(currentUser.readingStatus || {}) }; - if (status === "unread") { - delete currentStatuses[bookId]; - } else { - currentStatuses[bookId] = status; - } - - const updatedUser = { - ...currentUser, - readingStatus: currentStatuses, - }; - setCurrentUser(updatedUser); + setCurrentUser(prev => { + if (!prev) return prev; + const statuses = { ...prev.readingStatus }; + if (status === 'unread') delete statuses[bookId]; else statuses[bookId] = status; + return { ...prev, readingStatus: statuses }; + }); }; // Hidden books / series handlers @@ -414,252 +295,50 @@ export default function Home() { return hiddenBooksCount + hiddenSeriesCount; }, [currentUser]); - const handleAddBook = (data: { - title: string; - author: string; - series: string; - formatType: BindingFormat; - isbn?: string; - cover?: string; - readingStatus?: ReadingStatus; - }) => { - const cleanTitle = cleanDisplayTitle(data.title); - const workKey = canonicalizeBookTitle(cleanTitle); - - let targetBookId = `book-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`; - const newEditionId = `ed-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`; - - const newEdition: BookEdition = { - id: newEditionId, - formatType: data.formatType, - publisher: data.series || "Wydawnictwo", - year: new Date().getFullYear(), - format: data.formatType === "hardcover" ? "Oprawa twarda" : "Oprawa miękka", - isbn: data.isbn || "9780000000000", - }; - - setSeriesList((prev) => { - const targetSeriesName = data.series || `Twórczość: ${data.author}`; - const existing = prev.find( - (s) => - s.seriesName.toLowerCase() === targetSeriesName.toLowerCase() || - s.author.toLowerCase().trim() === data.author.toLowerCase().trim() - ); - - if (existing) { - // Check if this book (by canonical key) is already in the series! - const existingBookIndex = existing.books.findIndex( - (b) => canonicalizeBookTitle(b.title) === workKey - ); - - if (existingBookIndex !== -1) { - // MERGE INTO EXISTING BOOK CARD AS AN EDITION! - const existingBook = existing.books[existingBookIndex]; - targetBookId = existingBook.id; - - const editionExists = existingBook.editions.some( - (e) => (data.isbn && e.isbn === data.isbn) || (e.formatType === data.formatType && e.publisher === newEdition.publisher) - ); - - const updatedEditions = editionExists - ? existingBook.editions - : [...existingBook.editions, newEdition]; - - const updatedCover = existingBook.cover || data.cover; - - const updatedBooks = existing.books.map((b, idx) => - idx === existingBookIndex - ? { - ...b, - cover: updatedCover, - editions: updatedEditions, - } - : b - ); - - return prev.map((s) => - s.seriesId === existing.seriesId ? { ...s, books: updatedBooks } : s - ); - } - - // Otherwise, add as a new book in the series - const newBook: Book = { - id: targetBookId, - title: cleanTitle, - volume: existing.books.length + 1, - formatType: data.formatType, - cover: data.cover || (data.isbn ? `https://covers.openlibrary.org/b/isbn/${data.isbn}-L.jpg?default=false` : undefined), - prices: [ - { - store: "Księgarnia", - formatType: data.formatType, - format: data.formatType === "hardcover" ? "Twarda oprawa" : "Miękka oprawa", - price: "39,90 zł", - shipping: "Dostępne", - isBest: true, - url: "https://www.swiatksiazki.pl", - }, - ], - editions: [newEdition], - }; - - return prev.map((s) => - s.seriesId === existing.seriesId - ? { ...s, books: [...s.books, newBook] } - : s - ); - } else { - // Create new series with new book - const newSeriesId = `series-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`; - const newBook: Book = { - id: targetBookId, - title: cleanTitle, - volume: 1, - formatType: data.formatType, - cover: data.cover || (data.isbn ? `https://covers.openlibrary.org/b/isbn/${data.isbn}-L.jpg?default=false` : undefined), - prices: [ - { - store: "Księgarnia", - formatType: data.formatType, - format: data.formatType === "hardcover" ? "Twarda oprawa" : "Miękka oprawa", - price: "39,90 zł", - shipping: "Dostępne", - isBest: true, - url: "https://www.swiatksiazki.pl", - }, - ], - editions: [newEdition], - }; - - return [ - ...prev, - { - seriesId: newSeriesId, - seriesName: targetSeriesName, - author: data.author, - books: [newBook], - }, - ]; - } - }); - - // Auto mark as owned / reading status if user is active + const handleAddBook = (data: AddBookData) => { + if (!shelfLoaded || !data.title.trim() || !data.author.trim()) return; + const result = addBookToCatalog(seriesRef.current, data); + setSeriesList(result.seriesList); if (currentUser) { - handleToggleOwned(targetBookId, newEditionId); - if (data.readingStatus) { - handleUpdateReadingStatus(targetBookId, data.readingStatus); - } + mutationVersion.current++; + setCurrentUser(prev => { + if (!prev) return prev; + const readingStatus = { ...prev.readingStatus }; + if (data.readingStatus && data.readingStatus !== 'unread') readingStatus[result.bookId] = data.readingStatus; + const ownedBooks = { ...prev.ownedBooks }; + if (data.readingStatus !== 'wishlist') ownedBooks[result.bookId] = result.editionId; + return { ...prev, ownedBooks, readingStatus }; + }); + if (data.readingStatus !== 'wishlist') void saveUserBookToCloud(currentUser.id, result.bookId, result.editionId).then(reportSync); } }; const handleImportAuthorBibliography = async (authorName: string) => { + if (!shelfLoaded) return; setLoadingAuthorBio(authorName); try { const res = await fetch(`/api/books/search?author=${encodeURIComponent(authorName)}&limit=30`); - if (!res.ok) return; + if (!res.ok) throw new Error("Nie udało się pobrać książek. / Could not fetch books."); const json = await res.json(); + if (!mounted.current) return; const booksToAdd = json.data || []; if (booksToAdd.length === 0) return; - setSeriesList((prev) => { - const authorLower = authorName.toLowerCase().trim(); - const existingSeries = prev.find( - (s) => s.author.toLowerCase().trim() === authorLower - ); - - const targetSeriesName = existingSeries?.seriesName || `Dzieła i powieści (${authorName})`; - const targetSeriesId = - existingSeries?.seriesId || - `series-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`; - - // Map existing books by canonical work key - const existingBooks = existingSeries ? [...existingSeries.books] : []; - const canonicalBookMap = new Map(); - existingBooks.forEach((b) => { - const key = canonicalizeBookTitle(b.title); - if (key) canonicalBookMap.set(key, b); - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - booksToAdd.forEach((b: any) => { - const cleanedTitle = cleanDisplayTitle(b.title); - const workKey = canonicalizeBookTitle(cleanedTitle); - if (!workKey) return; - - const existingBook = canonicalBookMap.get(workKey); - const newEditionId = `ed-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; - const edition: BookEdition = { - id: newEditionId, - formatType: b.formatType || "paperback", - publisher: b.publisher || "Wydawnictwo", - year: b.publicationYear || new Date().getFullYear(), - format: b.formatType === "hardcover" ? "Oprawa twarda" : "Oprawa miękka", - isbn: b.isbn || "9780000000000", - }; - - if (existingBook) { - // MERGE AS EDITION INTO EXISTING BOOK! - const editionExists = existingBook.editions.some( - (ed) => (b.isbn && ed.isbn === b.isbn) || (ed.formatType === b.formatType && ed.publisher === b.publisher) - ); - if (!editionExists) { - existingBook.editions.push(edition); - } - if (!existingBook.cover && b.coverUrl) { - existingBook.cover = b.coverUrl; - } - } else { - // NEW BOOK CARD - const newBookId = `book-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; - const newBook: Book = { - id: newBookId, - title: cleanedTitle, - volume: canonicalBookMap.size + 1, - formatType: b.formatType || "paperback", - cover: b.coverUrl, - prices: [ - { - store: "Księgarnia", - formatType: b.formatType || "paperback", - format: b.formatType === "hardcover" ? "Twarda oprawa" : "Miękka oprawa", - price: "29,90 zł", - shipping: "Dostępne", - isBest: true, - url: "https://www.swiatksiazki.pl", - }, - ], - editions: [edition], - }; - canonicalBookMap.set(workKey, newBook); - } - }); - - const finalBooks = Array.from(canonicalBookMap.values()).map((b, idx) => ({ - ...b, - volume: idx + 1, - })); - - if (existingSeries) { - return prev.map((s) => - s.seriesId === existingSeries.seriesId - ? { ...s, books: finalBooks } - : s - ); - } else { - return [ - ...prev, - { - seriesId: targetSeriesId, - seriesName: targetSeriesName, - author: authorName, - books: finalBooks, - }, - ]; - } - }); + let next = seriesRef.current; + for (const book of booksToAdd as UnifiedBookMetadata[]) { + const author = book.author || authorName; + const existing = next.find(s => s.author.toLowerCase().trim() === author.toLowerCase().trim() + && s.books.some(b => canonicalizeBookTitle(b.title) === canonicalizeBookTitle(book.title))); + next = addBookToCatalog(next, { + title: book.title, author, series: existing?.seriesName || `Twórczość: ${author}`, + formatType: book.formatType, isbn: book.isbn, cover: book.coverUrl, + publisher: book.publisher, publicationYear: book.publicationYear, + }).seriesList; + } + setSeriesList(next); } catch (err) { - console.error("Error importing author bibliography:", err); + if (mounted.current) setStorageError(err instanceof Error ? err.message : "Import failed"); } finally { setLoadingAuthorBio(null); } @@ -728,14 +407,15 @@ export default function Home() { onToggleLang={handleToggleLang} currentUser={currentUser} onOpenAuth={() => setIsAuthOpen(true)} - onLogout={() => setCurrentUser(null)} - onOpenAddBook={() => setIsAddBookOpen(true)} + onLogout={() => { void logout(); }} + onOpenAddBook={() => { setScannedIsbn(""); setIsAddBookOpen(true); }} onOpenScanner={() => setIsScannerOpen(true)} onOpenAuthorSearch={() => setIsAuthorSearchOpen(true)} searchQuery={searchQuery} onSearchChange={setSearchQuery} /> + {(storageError || error) &&

{storageError || error}

} {/* User Banner */} s.author === activeAuthorName).map(s => ({ name: s.seriesName, seriesId: s.seriesId, bookIds: s.books.map(b => b.id) })) }} seriesList={seriesList} currentUser={currentUser} lang={lang} @@ -1060,6 +742,7 @@ export default function Home() { {/* Add Book Modal */} {isAddBookOpen && ( setIsAddBookOpen(false)} onAddBook={handleAddBook} @@ -1084,19 +767,7 @@ export default function Home() { setIsAuthOpen(false)} - onLogin={(name, email) => { - const id = `user-${email.replace(/[^a-zA-Z0-9]/g, "_")}`; - const user: UserAccount = { - id, - name, - email, - role: lang === "pl" ? "Kolekcjoner" : "Collector", - avatar: `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(name)}`, - ownedBooks: {}, - }; - setCurrentUser(user); - setIsAuthOpen(false); - }} + onLogin={user => { setCurrentUser(user); setIsAuthOpen(false); }} isForcedModal={!currentUser} /> )} @@ -1106,31 +777,10 @@ export default function Home() { setIsScannerOpen(false)} - onDetected={(scannedIsbn) => { + onDetected={(isbn) => { setIsScannerOpen(false); - // Open AddBookModal with pre-queried or pre-filled ISBN + setScannedIsbn(isbn); setIsAddBookOpen(true); - setTimeout(() => { - // Also trigger lookup endpoint to automatically inject new volume - fetch(`/api/books/lookup?isbn=${encodeURIComponent(scannedIsbn)}`) - .then((r) => r.json()) - .then((json) => { - if (json && json.data) { - const b = json.data; - handleAddBook({ - title: b.title || `ISBN ${scannedIsbn}`, - author: b.author || "Nieznany autor", - series: lang === "pl" ? "Zeskanowane książki" : "Scanned books", - formatType: b.formatType || "hardcover", - isbn: scannedIsbn, - }); - setIsAddBookOpen(false); - } - }) - .catch(() => { - // Fallback: AddBookModal stays open for manual completion - }); - }, 200); }} /> )} diff --git a/src/components/AddBookModal.tsx b/src/components/AddBookModal.tsx index 6045775..578e79a 100644 --- a/src/components/AddBookModal.tsx +++ b/src/components/AddBookModal.tsx @@ -6,6 +6,7 @@ import { X, BookOpen, Search, Loader2, Camera } from "lucide-react"; import { BarcodeScannerModal } from "./BarcodeScannerModal"; interface AddBookModalProps { + initialIsbn?: string; lang: Language; onClose: () => void; onAddBook: (bookData: { @@ -14,11 +15,15 @@ interface AddBookModalProps { series: string; formatType: BindingFormat; isbn?: string; + cover?: string; + publisher?: string; + publicationYear?: number; }) => void; } export const AddBookModal: React.FC = ({ lang, + initialIsbn = "", onClose, onAddBook, }) => { @@ -26,7 +31,8 @@ export const AddBookModal: React.FC = ({ const [author, setAuthor] = useState(""); const [series, setSeries] = useState(""); const [formatType, setFormatType] = useState("hardcover"); - const [isbn, setIsbn] = useState(""); + const [isbn, setIsbn] = useState(initialIsbn); + const [metadata, setMetadata] = useState<{ cover?: string; publisher?: string; publicationYear?: number }>({}); const [showScanner, setShowScanner] = useState(false); // Multi-Provider Book Fetch State @@ -53,6 +59,7 @@ export const AddBookModal: React.FC = ({ if (res.ok && json.data) { const b = json.data; + setMetadata({ cover: b.coverUrl, publisher: b.publisher, publicationYear: b.publicationYear }); setTitle(b.title || title); setAuthor(b.author || author); setFormatType(b.formatType || formatType); @@ -88,6 +95,7 @@ export const AddBookModal: React.FC = ({ if (res.ok && json.data && json.data.length > 0) { const b = json.data[0]; + setMetadata({ cover: b.coverUrl, publisher: b.publisher, publicationYear: b.publicationYear }); setTitle(b.title || title); setAuthor(b.author || author); if (b.isbn) setIsbn(b.isbn); @@ -129,8 +137,9 @@ export const AddBookModal: React.FC = ({ if (!title.trim() || !author.trim()) return; onAddBook({ - title, - author, + ...metadata, + title: title.trim(), + author: author.trim(), series: series || (lang === "pl" ? "Książki samodzielne" : "Standalone"), formatType, isbn, @@ -140,7 +149,7 @@ export const AddBookModal: React.FC = ({ return (
-
+
@@ -210,7 +219,7 @@ export const AddBookModal: React.FC = ({ setIsbn(e.target.value)} + onChange={(e) => { setIsbn(e.target.value); setMetadata({}); }} placeholder="np. 9788375780635" className="w-full bg-gray-800 border border-gray-700 rounded-lg p-2.5 pr-24 text-sm text-white focus:outline-none focus:border-brand-500 font-mono" /> @@ -313,6 +322,7 @@ export const AddBookModal: React.FC = ({ .then((json) => { if (json && json.data) { const b = json.data; + setMetadata({ cover: b.coverUrl, publisher: b.publisher, publicationYear: b.publicationYear }); setTitle(b.title || ""); setAuthor(b.author || ""); setFormatType(b.formatType || "hardcover"); diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 85c5dee..10cab77 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -1,314 +1,63 @@ "use client"; - -import React, { useState } from "react"; -import { Language } from "@/types"; -import { User, Lock, Mail, ArrowRight, UserPlus, KeyRound } from "lucide-react"; +import React, { useState } from 'react'; +import type { Language, UserAccount } from '@/types'; +import { supabase } from '@/lib/supabase/client'; +import { createAccount, readAccount } from '@/hooks/useAccount'; interface AuthModalProps { lang: Language; onClose?: () => void; - onLogin: (name: string, email: string) => void; + onLogin: (user: UserAccount) => void; isForcedModal?: boolean; } - -export const AuthModal: React.FC = ({ - lang, - onClose, - onLogin, - isForcedModal = false, -}) => { - const [mode, setMode] = useState<"login" | "register">("register"); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [error, setError] = useState(null); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - - const cleanEmail = email.trim().toLowerCase(); - if (!cleanEmail || !cleanEmail.includes("@")) { - setError(lang === "pl" ? "Wpisz poprawny adres e-mail." : "Please enter a valid email address."); - return; - } - - if (password.length < 6) { - setError(lang === "pl" ? "Hasło musi mieć co najmniej 6 znaków." : "Password must be at least 6 characters."); - return; - } - - // Load registered accounts database from localStorage - let registeredUsers: Record = {}; +export function AuthModal({ lang, onClose, onLogin }: AuthModalProps) { + const pl = lang === 'pl'; + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + if (busy) return; + setBusy(true); setMessage(''); try { - const stored = localStorage.getItem("tomestack_registered_users"); - if (stored) { - registeredUsers = JSON.parse(stored); - } - } catch { - registeredUsers = {}; - } - - if (mode === "register") { - if (!name.trim()) { - setError(lang === "pl" ? "Podaj swoje imię lub pseudonim." : "Please enter your name or nickname."); - return; - } - if (password !== confirmPassword) { - setError(lang === "pl" ? "Hasła nie są identyczne." : "Passwords do not match."); + if (!supabase) { + const cleanName = name.trim(); + if (!cleanName) return; + const id = `local-${encodeURIComponent(cleanName.toLowerCase())}`; + onLogin(readAccount(id) || createAccount(id, cleanName)); + onClose?.(); return; } - - // Check if user already exists - if (registeredUsers[cleanEmail]) { - setError( - lang === "pl" - ? "Konto z tym adresem e-mail już istnieje. Przełącz się na logowanie." - : "An account with this email already exists. Please sign in." - ); + const credentials = { email: email.trim().toLowerCase(), password }; + const { data, error } = mode === 'register' + ? await supabase.auth.signUp({ ...credentials, options: { data: { name: name.trim() } } }) + : await supabase.auth.signInWithPassword(credentials); + if (error) throw error; + if (!data.session) { + setMessage(pl ? 'Sprawdź e-mail i potwierdź rejestrację przed zalogowaniem.' : 'Check your email to confirm your registration before signing in.'); return; } - - // Save new registered user - registeredUsers[cleanEmail] = { - name: name.trim(), - email: cleanEmail, - password, - }; - localStorage.setItem("tomestack_registered_users", JSON.stringify(registeredUsers)); - - onLogin(name.trim(), cleanEmail); - if (onClose) onClose(); - } else { - // Login mode - const existing = registeredUsers[cleanEmail]; - if (existing) { - if (existing.password !== password) { - setError(lang === "pl" ? "Nieprawidłowe hasło." : "Invalid password."); - return; - } - onLogin(existing.name, cleanEmail); - } else { - // Allow first-time login as quick-register if not present - const derivedName = cleanEmail.split("@")[0]; - const formattedName = derivedName.charAt(0).toUpperCase() + derivedName.slice(1); - registeredUsers[cleanEmail] = { - name: formattedName, - email: cleanEmail, - password, - }; - localStorage.setItem("tomestack_registered_users", JSON.stringify(registeredUsers)); - onLogin(formattedName, cleanEmail); - } - - if (onClose) onClose(); - } + // The auth subscription establishes the account from the actual session. + onClose?.(); + } catch (error) { + setMessage(error instanceof Error ? error.message : (pl ? 'Logowanie nie powiodło się.' : 'Sign in failed.')); + } finally { setBusy(false); } }; - - return ( -
-
- {/* Modal Top Bar */} -
-
-
- {mode === "register" ? : } -
-
-

- {mode === "register" - ? (lang === "pl" ? "Stwórz konto kolekcjonera" : "Create Collector Account") - : (lang === "pl" ? "Zaloguj się do biblioteki" : "Sign In to Library")} -

-

- {mode === "register" - ? (lang === "pl" ? "Załóż własną, prywatną półkę" : "Set up your private book shelf") - : (lang === "pl" ? "Dostęp do Twojej zapisanej kolekcji" : "Access your saved collection")} -

-
-
- {!isForcedModal && onClose && ( - - )} -
- - {/* Tab Switcher: Logowanie vs Rejestracja */} -
- - -
- - {/* Form Fields */} -
-
- {mode === "register" && ( -
- -
- { - setName(e.target.value); - setError(null); - }} - placeholder={lang === "pl" ? "np. Maciej" : "e.g. Alex"} - required - className="w-full bg-gray-800 border border-gray-700 rounded-xl p-2.5 pl-9 text-sm text-white focus:outline-none focus:border-brand-500 transition" - /> - -
-
- )} - -
- -
- { - setEmail(e.target.value); - setError(null); - }} - placeholder="twoj.email@example.com" - required - className="w-full bg-gray-800 border border-gray-700 rounded-xl p-2.5 pl-9 text-sm text-white focus:outline-none focus:border-brand-500 transition" - /> - -
-
- -
- -
- { - setPassword(e.target.value); - setError(null); - }} - placeholder="••••••••" - required - minLength={6} - className="w-full bg-gray-800 border border-gray-700 rounded-xl p-2.5 pl-9 text-sm text-white focus:outline-none focus:border-brand-500 transition" - /> - -
-
- - {mode === "register" && ( -
- -
- { - setConfirmPassword(e.target.value); - setError(null); - }} - placeholder="••••••••" - required - minLength={6} - className="w-full bg-gray-800 border border-gray-700 rounded-xl p-2.5 pl-9 text-sm text-white focus:outline-none focus:border-brand-500 transition" - /> - -
-
- )} - - {error && ( -

- {error} -

- )} - - -
- - {/* Bottom Switcher Link */} -
- {mode === "register" ? ( -

- {lang === "pl" ? "Masz już konto?" : "Already have an account?"}{" "} - -

- ) : ( -

- {lang === "pl" ? "Nie masz jeszcze konta?" : "Don't have an account yet?"}{" "} - -

- )} -
-
-
-
- ); -}; \ No newline at end of file + const field = 'w-full bg-gray-800 border border-gray-700 rounded-xl p-3 text-white'; + return
+
+

{!supabase ? (pl ? 'Profil lokalny' : 'Local profile') : mode === 'login' ? (pl ? 'Zaloguj się' : 'Sign in') : (pl ? 'Utwórz konto' : 'Create account')}

+ {!supabase &&

{pl ? 'Kolekcja będzie zapisana w tej przeglądarce. Profil lokalny nie jest chroniony hasłem. Wpisz tę samą nazwę, aby wrócić do swojej półki.' : 'Your collection is stored in this browser. Local profiles are not password protected. Enter the same name to reopen your shelf.'}

} +
+ {(!supabase || mode === 'register') && } + {supabase && <>} + {message &&

{message}

} + +
+ {supabase && } +
+
; +} diff --git a/src/components/AuthorSearchModal.tsx b/src/components/AuthorSearchModal.tsx index 9f89abb..c8e0fcc 100644 --- a/src/components/AuthorSearchModal.tsx +++ b/src/components/AuthorSearchModal.tsx @@ -29,6 +29,8 @@ interface AuthorSearchModalProps { isbn?: string; cover?: string; readingStatus?: ReadingStatus; + publisher?: string; + publicationYear?: number; }) => void; } @@ -55,6 +57,8 @@ export const AuthorSearchModal: React.FC = ({ const [books, setBooks] = useState([]); const [searchedAuthor, setSearchedAuthor] = useState(""); const [addedIds, setAddedIds] = useState>(new Set()); + const requestId = React.useRef(0); + React.useEffect(() => () => { requestId.current++; }, []); const [error, setError] = useState(null); React.useEffect(() => { @@ -69,6 +73,8 @@ export const AuthorSearchModal: React.FC = ({ const authorName = authorToSearch.trim(); if (!authorName) return; + const id = ++requestId.current; + setBooks([]); setLoading(true); setError(null); setSearchedAuthor(authorName); @@ -79,6 +85,7 @@ export const AuthorSearchModal: React.FC = ({ throw new Error(lang === "pl" ? "Błąd pobierania danych autora." : "Error fetching author data."); } const json = await res.json(); + if (id !== requestId.current) return; setBooks(json.data || []); if ((json.data || []).length === 0) { setError( @@ -88,11 +95,12 @@ export const AuthorSearchModal: React.FC = ({ ); } } catch (err) { + if (id !== requestId.current) return; setError( err instanceof Error ? err.message : (lang === "pl" ? "Nie udało się połączyć z API." : "API connection failed.") ); } finally { - setLoading(false); + if (id === requestId.current) setLoading(false); } }; @@ -128,6 +136,8 @@ export const AuthorSearchModal: React.FC = ({ formatType: b.formatType, isbn: b.isbn, cover: b.coverUrl, + publisher: b.publisher, + publicationYear: b.publicationYear, readingStatus: status, }); setAddedIds((prev) => new Set(prev).add(key)); @@ -140,6 +150,7 @@ export const AuthorSearchModal: React.FC = ({ const cleanTitle = cleanDisplayTitle(b.title); const seriesName = detectSeriesName(cleanTitle, author); const key = `${cleanTitle}-${b.isbn || "no-isbn"}`; + if (nextSet.has(key)) return; nextSet.add(key); onAddBookToShelf({ title: cleanTitle, @@ -148,6 +159,8 @@ export const AuthorSearchModal: React.FC = ({ formatType: b.formatType, isbn: b.isbn, cover: b.coverUrl, + publisher: b.publisher, + publicationYear: b.publicationYear, readingStatus: "unread", }); }); @@ -172,8 +185,8 @@ export const AuthorSearchModal: React.FC = ({

{lang === "pl" - ? "API wczyta całą bibliografię autora z prawdziwymi okładkami z BN i Open Library" - : "API loads full author bibliography with real covers without barcodes"} + ? "Wyszukaj dostępne książki autora. Wyniki mogą nie obejmować całej bibliografii." + : "Search available author books. Results may not include the full bibliography."}

diff --git a/src/components/BarcodeScannerModal.tsx b/src/components/BarcodeScannerModal.tsx index 68e9584..341f574 100644 --- a/src/components/BarcodeScannerModal.tsx +++ b/src/components/BarcodeScannerModal.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState, useCallback } from "react"; import { Html5Qrcode, Html5QrcodeSupportedFormats } from "html5-qrcode"; import { Camera, X, RefreshCw, AlertCircle, CheckCircle2, Zap } from "lucide-react"; +import { isValidIsbn, normalizeIsbn } from "@/lib/api/validation"; import { Language } from "@/types"; interface BarcodeScannerModalProps { @@ -23,130 +24,83 @@ export const BarcodeScannerModal: React.FC = ({ const [selectedCameraId, setSelectedCameraId] = useState(null); const scannerRef = useRef(null); + const callbacks = useRef({ onDetected, onClose }); + callbacks.current = { onDetected, onClose }; + const generation = useRef(0); + const queue = useRef>(Promise.resolve()); + const detected = useRef(false); + const timer = useRef | null>(null); const readerElementId = "tome-barcode-reader"; - const handleScanSuccess = useCallback((decodedText: string) => { - // Sanitize alphanumeric ISBN - const clean = decodedText.replace(/[^0-9X]/gi, ""); - if (clean.length >= 10) { - setDetectedIsbn(clean); - // Play brief success sound / feedback if available - try { - if ("vibrate" in navigator) { - navigator.vibrate(100); - } - } catch { - // vibration not supported - } - - // Stop scanner and notify parent - if (scannerRef.current && scannerRef.current.isScanning) { - scannerRef.current.stop().catch(() => {}); - } - setTimeout(() => { - onDetected(clean); - onClose(); - }, 700); - } - }, [onDetected, onClose]); - - const startScanning = useCallback(async (cameraId?: string) => { + const startScanning = useCallback((cameraId?: string) => { + const version = generation.current; setCameraError(null); setDetectedIsbn(null); - - try { - if (!scannerRef.current) { - scannerRef.current = new Html5Qrcode(readerElementId, { - formatsToSupport: [ - Html5QrcodeSupportedFormats.EAN_13, - Html5QrcodeSupportedFormats.EAN_8, - Html5QrcodeSupportedFormats.CODE_128, - Html5QrcodeSupportedFormats.UPC_A, - ], - verbose: false, - }); - } - - // If scanner is already active, stop before restarting - if (scannerRef.current.isScanning) { - await scannerRef.current.stop(); - } - - const cameraConfig = cameraId - ? { deviceId: { exact: cameraId } } - : { facingMode: "environment" }; - - await scannerRef.current.start( - cameraConfig, - { + detected.current = false; + queue.current = queue.current.catch(() => {}).then(async () => { + if (version !== generation.current) return; + const scanner = scannerRef.current || new Html5Qrcode(readerElementId, { + formatsToSupport: [Html5QrcodeSupportedFormats.EAN_13, Html5QrcodeSupportedFormats.CODE_128], verbose: false, + }); + scannerRef.current = scanner; + if (scanner.isScanning) await scanner.stop(); + if (version !== generation.current) return; + try { + await scanner.start(cameraId ? { deviceId: { exact: cameraId } } : { facingMode: "environment" }, { fps: 15, - qrbox: { width: 280, height: 160 }, - aspectRatio: 1.333333, - }, - handleScanSuccess, - () => { - // Frame scanned without barcode match, continue silently + qrbox: (width, height) => ({ width: Math.min(280, Math.floor(width * 0.9)), height: Math.min(160, Math.floor(height * 0.7)) }), + }, text => { + if (version !== generation.current || detected.current || !isValidIsbn(text)) return; + detected.current = true; + const isbn = normalizeIsbn(text); + setDetectedIsbn(isbn); + setIsScanning(false); + queue.current = queue.current.then(async () => { if (scanner.isScanning) await scanner.stop(); }).catch(() => {}); + timer.current = setTimeout(() => { + if (version !== generation.current) return; + callbacks.current.onDetected(isbn); + callbacks.current.onClose(); + }, 700); + }, () => {}); + if (version !== generation.current) { + if (scanner.isScanning) await scanner.stop(); + return; } - ); - - setIsScanning(true); - } catch (err: unknown) { - console.error("Camera startup error:", err); - const msg = - err instanceof Error - ? err.message - : "Nie udało się uruchomić kamery. Upewnij się, że przyznano uprawnienia."; - setCameraError(msg); - setIsScanning(false); - } - }, [handleScanSuccess]); + setIsScanning(true); + } catch (error) { + if (version !== generation.current) return; + setCameraError(error instanceof Error ? error.message : String(error)); + setIsScanning(false); + } + }); + }, []); - // Enumerate cameras and auto-start useEffect(() => { - let isMounted = true; - - Html5Qrcode.getCameras() - .then((devices) => { - if (!isMounted) return; - if (devices && devices.length > 0) { - setCameras(devices); - // Prefer back camera (environment) if available - const backCam = devices.find((d) => - d.label.toLowerCase().includes("back") || - d.label.toLowerCase().includes("tył") || - d.label.toLowerCase().includes("environment") - ); - const chosen = backCam ? backCam.id : devices[0].id; - setSelectedCameraId(chosen); - startScanning(chosen); - } else { - startScanning(); - } - }) - .catch(() => { - if (!isMounted) return; - // Fallback to start with facingMode environment - startScanning(); - }); - + const version = ++generation.current; + Html5Qrcode.getCameras().then(devices => { + if (version !== generation.current) return; + setCameras(devices); + const camera = devices.find(d => /back|tył|environment/i.test(d.label)) || devices[0]; + setSelectedCameraId(camera?.id || null); + startScanning(camera?.id); + }).catch(() => { if (version === generation.current) startScanning(); }); return () => { - isMounted = false; - if (scannerRef.current) { - if (scannerRef.current.isScanning) { - scannerRef.current.stop().catch(() => {}); - } - try { - scannerRef.current.clear(); - } catch { - // ignore clear error on unmount - } - } + generation.current = version + 1; + if (timer.current) clearTimeout(timer.current); + queue.current = queue.current.catch(() => {}).then(async () => { + const scanner = scannerRef.current; + if (!scanner) return; + if (scanner.isScanning) await scanner.stop(); + scanner.clear(); + scannerRef.current = null; + }).catch(() => {}); }; }, [startScanning]); - const handleSwitchCamera = (newCamId: string) => { - setSelectedCameraId(newCamId); - startScanning(newCamId); + const handleSwitchCamera = (id: string) => { + if (detected.current) return; + setSelectedCameraId(id); + startScanning(id); }; return ( @@ -248,8 +202,8 @@ export const BarcodeScannerModal: React.FC = ({
{lang === "pl" - ? "Obsługuje formaty EAN-13, EAN-8, UPC, Code-128" - : "Supports EAN-13, EAN-8, UPC, Code-128"} + ? "Obsługuje ISBN w formatach EAN-13 i Code-128" + : "Supports ISBN in EAN-13 and Code-128"}
{/* Section 1: Price Comparison */} + {book.prices.length > 0 &&

{lang === 'pl' ? 'Ceny demonstracyjne — sprawdź aktualną ofertę w sklepie.' : 'Demo prices — check current offers at the store.'}

}
@@ -313,8 +314,8 @@ export const BookModal: React.FC = ({ >
- {ed.publisher} - ({ed.year}) + {ed.publisher || "—"} + ({ed.year || "—"}) {ed.format} @@ -325,7 +326,7 @@ export const BookModal: React.FC = ({ )}

- ISBN: {ed.isbn} + ISBN: {ed.isbn || "—"} {ed.coverDesc && ` • ${ed.coverDesc}`}

diff --git a/src/components/EmptyLibraryHero.tsx b/src/components/EmptyLibraryHero.tsx index 5ff129a..200c489 100644 --- a/src/components/EmptyLibraryHero.tsx +++ b/src/components/EmptyLibraryHero.tsx @@ -14,11 +14,11 @@ interface EmptyLibraryHeroProps { const POPULAR_AUTHORS = [ "George Orwell", - "Stanis�aw Lem", + "Stanisław Lem", "Stephen King", "J.R.R. Tolkien", "Andrzej Sapkowski", - "Remigiusz Mr�z", + "Remigiusz Mróz", ]; export const EmptyLibraryHero: React.FC = ({ @@ -54,13 +54,13 @@ export const EmptyLibraryHero: React.FC = ({

{lang === "pl" - ? "Wpisz poni�ej nazwisko dowolnego autora na �wiecie (np. George Orwell, Stanis�aw Lem, Stephen King), aby wczyta� jego tomy z Biblioteki Narodowej i doda� do swojej p�ki jednym klikni�ciem!" - : "Type any author below (e.g. George Orwell, Stanis�aw Lem, Stephen King) to fetch works from the National Library and add them to your shelf with 1 click!"} + ? "Wpisz poniżej nazwisko dowolnego autora na świecie (np. George Orwell, Stanisław Lem, Stephen King), aby wczytać jego tomy z Biblioteki Narodowej i dodać do swojej półki jednym kliknięciem!" + : "Type any author below (e.g. George Orwell, Stanisław Lem, Stephen King) to fetch works from the National Library and add them to your shelf with 1 click!"}

{/* Direct quick-search input */} -
+
= ({ onClick={handleImport} disabled={Boolean(loadingAuthor && loadingAuthor === quickInput.trim())} className="px-4 py-3 rounded-xl bg-gradient-to-r from-amber-600 to-brand-600 hover:from-amber-500 hover:to-brand-500 text-white text-xs sm:text-sm font-bold transition flex items-center gap-1.5 shadow-lg shadow-brand-900/40 cursor-pointer shrink-0" - title={lang === "pl" ? "Wczytaj ca�� bibliografi� autora" : "Import all books"} + title={lang === "pl" ? "Wczytaj dostępne książki autora" : "Import all books"} > {loadingAuthor === quickInput.trim() ? ( ) : ( ? )} - {lang === "pl" ? "Dodaj ca�� tw�rczo��" : "Import all"} + {lang === "pl" ? "Dodaj książki" : "Import all"}
@@ -100,7 +100,7 @@ export const EmptyLibraryHero: React.FC = ({

{lang === "pl" - ? "? Kliknij autora, aby od razu za�adowa� wszystkie jego ksi��ki do �ledzenia:" + ? "? Kliknij autora, aby od razu załadować wszystkie jego książki do śledzenia:" : "? Click an author to instantly track all their books:"}

@@ -110,7 +110,7 @@ export const EmptyLibraryHero: React.FC = ({ onClick={() => onImportAuthor(author)} disabled={loadingAuthor === author} className="px-3.5 py-2 rounded-xl text-xs font-bold bg-gray-800/90 hover:bg-brand-600 text-gray-200 hover:text-white border border-gray-700 hover:border-brand-500 transition cursor-pointer flex items-center gap-1.5 shadow" - title={lang === "pl" ? `Wczytaj ca�� bibliografi� ${author}` : `Load all books by ${author}`} + title={lang === "pl" ? `Wczytaj dostępne książki ${author}` : `Load all books by ${author}`} > {loadingAuthor === author ? ( @@ -127,12 +127,12 @@ export const EmptyLibraryHero: React.FC = ({
- {lang === "pl" ? "Chcesz zobaczy� przyk�adowe dane?" : "Want to see sample data?"} + {lang === "pl" ? "Chcesz zobaczyć przykładowe dane?" : "Want to see sample data?"}
diff --git a/src/components/MissingRadar.tsx b/src/components/MissingRadar.tsx index 111443b..aa11b58 100644 --- a/src/components/MissingRadar.tsx +++ b/src/components/MissingRadar.tsx @@ -4,6 +4,7 @@ import React, { useState } from "react"; import { Series, Book, FormatFilter, Language, UserAccount } from "@/types"; import { translations } from "@/data/mockData"; import { calculateSeriesBasket } from "@/lib/pricing/priceEngine"; +import { matchesBookFormat } from "@/lib/library/catalog"; import { BookCover } from "./BookCover"; import { Target, @@ -51,7 +52,7 @@ export const MissingRadar: React.FC = ({ if (currentUser?.hiddenBooks?.[b.id]) return; const isOwned = Boolean(currentUser?.ownedBooks && currentUser.ownedBooks[b.id]); if (!isOwned) { - if (formatFilter === "all" || b.formatType === formatFilter) { + if (matchesBookFormat(b, formatFilter)) { missingItems.push({ book: b, series: s }); } } @@ -122,15 +123,15 @@ export const MissingRadar: React.FC = ({
- {lang === "pl" ? "Najniższy łączny koszt zakupu" : "Cherry-picked lowest total"} + {lang === "pl" ? "Suma cen demo bez dostawy" : "Demo prices excluding delivery"}

{basketOptimization.cheapestCombinedPrice.toFixed(2)} zł

{lang === "pl" - ? `Łącznie za ${basketOptimization.totalMissingBooks} brakujące tomy` - : `Total for ${basketOptimization.totalMissingBooks} missing volumes`} + ? `Łącznie za ${basketOptimization.pricedBooksCount} z ${basketOptimization.totalMissingBooks} brakujących tomów` + : `Total for ${basketOptimization.pricedBooksCount} of ${basketOptimization.totalMissingBooks} missing volumes`}
@@ -199,7 +200,8 @@ export const MissingRadar: React.FC = ({ ) : (
{missingItems.map(({ book, series }) => { - const bestOffer = book.prices.find((p) => p.isBest) || book.prices[0]; + const candidate = basketOptimization.breakdown.find(item => item.bookId === book.id)?.bestOffer; + const bestOffer = candidate?.inStock ? candidate : undefined; return (
= ({ return (
-
+
{/* Brand Logo */}
-

+

TomeStack v2.2 React @@ -81,7 +81,7 @@ export const Navbar: React.FC = ({

{/* Right Actions */} -
+
{/* Quick Barcode Scanner Button */} {onOpenScanner && (
) : ( - {lang === "pl" ? "Wszystkie zmiany zapisują się na Twoim koncie" : "All changes saved to your account"} + {lang === "pl" ? "Katalog i statusy czytania zapisują się w tej przeglądarce" : "Catalog and reading status are stored in this browser"} )}
diff --git a/src/hooks/useAccount.ts b/src/hooks/useAccount.ts new file mode 100644 index 0000000..ad7b236 --- /dev/null +++ b/src/hooks/useAccount.ts @@ -0,0 +1,82 @@ +"use client"; +import { useEffect, useState } from 'react'; +import type { UserAccount } from '@/types'; +import { supabase } from '@/lib/supabase/client'; +import type { User } from '@supabase/supabase-js'; + +export function accountKey(id: string) { return `tomestack_account:${encodeURIComponent(id)}`; } +export function shelfKey(id?: string) { return `tomestack_shelf_v5:${encodeURIComponent(id || 'guest')}`; } +export function readAccount(id: string): UserAccount | null { + try { + const value = JSON.parse(localStorage.getItem(accountKey(id)) || 'null'); + return value?.id === id && typeof value.name === 'string' && value.ownedBooks && typeof value.ownedBooks === 'object' ? value : null; + } catch { return null; } +} +export function createAccount(id: string, name: string, email = ''): UserAccount { + return { ...readAccount(id), id, name, email, role: id.startsWith('local-') ? 'Profil lokalny' : 'Kolekcjoner', + avatar: `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(name)}`, + ownedBooks: readAccount(id)?.ownedBooks || {} }; +} +export function useAccount() { + const [currentUser, setCurrentUser] = useState(null); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(''); + useEffect(() => { + let active = true; + // Preserve old local collection as an explicitly local profile, never as a cloud session. + try { + const old = JSON.parse(localStorage.getItem('tomestack_user') || 'null'); + if (old?.id && old?.name && old?.ownedBooks) { + const id = `local-${encodeURIComponent(old.name.trim().toLowerCase())}`; + if (!localStorage.getItem(accountKey(id))) { + localStorage.setItem(accountKey(id), JSON.stringify({ ...old, id, role: 'Profil lokalny' })); + const shelf = localStorage.getItem('tomestack_user_shelf_v4'); + if (shelf) localStorage.setItem(shelfKey(id), shelf); + } + if (!supabase) localStorage.setItem('tomestack_local_session', id); + localStorage.removeItem('tomestack_user'); + } + localStorage.removeItem('tomestack_registered_users'); + } catch { /* Unavailable or malformed local cache is not authentication. */ } + if (!supabase) { + try { + const id = localStorage.getItem('tomestack_local_session'); + if (id?.startsWith('local-')) setCurrentUser(readAccount(id)); + } catch { /* Guest mode remains available. */ } + setLoaded(true); + return; + } + const apply = (user: User | null) => { + if (!active) return; + setCurrentUser(prev => user ? prev?.id === user.id ? prev + : createAccount(user.id, user.user_metadata?.name || user.email?.split('@')[0] || 'Kolekcjoner', user.email) : null); + setLoaded(true); + }; + let authEventReceived = false; + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + authEventReceived = true; + apply(session?.user || null); + }); + supabase.auth.getUser().then(({ data }) => { if (!authEventReceived) apply(data.user); }) + .catch(() => { if (!authEventReceived) apply(null); }); + return () => { active = false; subscription.unsubscribe(); }; + }, []); + useEffect(() => { + if (!loaded) return; + try { + if (currentUser) localStorage.setItem(accountKey(currentUser.id), JSON.stringify(currentUser)); + if (!supabase) { + if (currentUser) localStorage.setItem('tomestack_local_session', currentUser.id); + else localStorage.removeItem('tomestack_local_session'); + } + } catch { setError('Nie udało się zapisać danych w przeglądarce. / Browser storage failed.'); } + }, [currentUser, loaded]); + const logout = async () => { + if (supabase) { + const { error } = await supabase.auth.signOut(); + if (error) { setError(error.message); return; } + } + setCurrentUser(null); + }; + return { currentUser, setCurrentUser, loaded, logout, error }; +} diff --git a/src/lib/api/bn.ts b/src/lib/api/bn.ts index 4faa7b2..d3768a7 100644 --- a/src/lib/api/bn.ts +++ b/src/lib/api/bn.ts @@ -35,8 +35,7 @@ export function cleanAuthor(rawAuthor?: string): string { if (commaIdx !== -1) { const lastName = cleaned.substring(0, commaIdx).trim(); const firstName = cleaned.substring(commaIdx + 1).replace(/\.$/, "").trim(); - const simpleFirst = firstName.split(/\s+/)[0] || firstName; - return `${simpleFirst} ${lastName}`.trim(); + return `${firstName} ${lastName}`.trim(); } return cleaned.replace(/\.$/, "").trim(); } @@ -49,7 +48,7 @@ export function cleanTitle(rawTitle?: string): string { let base = rawTitle.trim(); // 1. Remove authorship / responsibility statements after slash (e.g. "... / Clergyman's daughter") - const slashIdx = base.indexOf("/"); + const slashIdx = base.search(/\s+\/\s+/); if (slashIdx !== -1) { base = base.substring(0, slashIdx).trim(); } @@ -68,9 +67,8 @@ export function cleanTitle(rawTitle?: string): string { if (colonIdx !== -1) { const mainTitle = base.substring(0, colonIdx).trim(); const subTitle = base.substring(colonIdx + 1).trim(); - const genericGenrePattern = /^(powie[sś][cć]|opowiadani|esej|reporta[zż]|bajka|nowel|dramat|poemat|poezj|wiersz|wspomnien|autobiograf|biograf|felieton|utw[oó]r|antologi|wyb[oó]r|tom|cz[eę][sś][cć]|cz\.|wydani|przek[lł]ad|prze[lł]|proza)/i; - if (genericGenrePattern.test(subTitle) || (mainTitle.length >= 4 && subTitle.length <= 35)) { + if (/^(powie[sś][cć]|bajka polityczna|reporta[zż])\s*[.,;:]?$/i.test(subTitle)) { base = mainTitle; } } @@ -111,13 +109,13 @@ export async function fetchBnByIsbn(isbn: string): Promise { const yearNum = typeof raw.publicationYear === "number" ? raw.publicationYear - : parseInt(String(raw.publicationYear || "").replace(/\D/g, ""), 10) || new Date().getFullYear(); + : Number(String(raw.publicationYear || "").match(/\b[12]\d{3}\b/)?.[0]) || 0; const extractedIsbn = (raw.isbnIssn || "").split(/\s+/)[0] || ""; diff --git a/src/lib/api/bookProviders.ts b/src/lib/api/bookProviders.ts index 6bfb243..cafe493 100644 --- a/src/lib/api/bookProviders.ts +++ b/src/lib/api/bookProviders.ts @@ -11,7 +11,7 @@ export interface UnifiedBookMetadata { coverUrl?: string; description?: string; pageCount?: number; - source: "bn" | "openlibrary" | "googlebooks" | "composite"; + source: "bn" | "openlibrary" | "googlebooks" | "composite" | "curated"; } interface OpenLibraryBook { @@ -95,7 +95,7 @@ export function normalizeBindingFormat(rawFormat?: string): BindingFormat { */ export function extractYear(dateStr?: string | number): number | undefined { if (!dateStr) return undefined; - const match = String(dateStr).match(/\b(19\d{2}|20\d{2})\b/); + const match = String(dateStr).match(/\b([12]\d{3})\b/); return match ? parseInt(match[0], 10) : undefined; } @@ -125,6 +125,7 @@ export async function fetchOpenLibraryByIsbn(isbn: string): Promise { const cleanedTitle = cleanDisplayTitle(b.title); - const workKey = canonicalizeBookTitle(cleanedTitle); + const workKey = `${b.author.toLowerCase().trim()}::${canonicalizeBookTitle(cleanedTitle)}`; if (!workKey || workKey.length < 2) return; if (seenWorkKeys.has(workKey)) { - const existing = results.find((r) => canonicalizeBookTitle(r.title) === workKey); + const existing = results.find((r) => `${r.author.toLowerCase().trim()}::${canonicalizeBookTitle(r.title)}` === workKey); if (existing) { if (!existing.coverUrl && b.coverUrl) existing.coverUrl = b.coverUrl; if (!existing.isbn && b.isbn) existing.isbn = b.isbn; @@ -652,7 +653,7 @@ export function cleanDisplayTitle(rawTitle: string): string { let s = rawTitle.trim(); // Strip authorship statements after / - const slashIdx = s.indexOf("/"); + const slashIdx = s.search(/\s+\/\s+/); if (slashIdx !== -1) { s = s.substring(0, slashIdx).trim(); } @@ -669,9 +670,8 @@ export function cleanDisplayTitle(rawTitle: string): string { if (colonIdx !== -1) { const mainTitle = s.substring(0, colonIdx).trim(); const subTitle = s.substring(colonIdx + 1).trim(); - const genericGenrePattern = /^(powie[sś][cć]|opowiadani|esej|reporta[zż]|bajka|nowel|dramat|poemat|poezj|wiersz|wspomnien|autobiograf|biograf|felieton|utw[oó]r|antologi|wyb[oó]r|tom|cz[eę][sś][cć]|cz\.|wydani|przek[lł]ad|prze[lł]|proza)/i; - if (genericGenrePattern.test(subTitle) || (mainTitle.length >= 4 && subTitle.length <= 35)) { + if (/^(powie[sś][cć]|bajka polityczna|reporta[zż])\s*[.,;:]?$/i.test(subTitle)) { s = mainTitle; } } @@ -707,8 +707,7 @@ export function canonicalizeBookTitle(rawTitle: string): string { // Normalize "1984" vs "rok 1984" s = s.replace(/\brok\s+1984\b/g, "1984"); - // Remove parenthesized or bracketed qualifiers - s = s.replace(/\([^)]*\)/g, " ").replace(/\[[^\]]*\]/g, " "); + // Keep part/volume qualifiers: they may identify different works. // Polish diacritics folding s = s.replace(/[ąćęłńóśźż]/g, (c) => { @@ -743,8 +742,10 @@ export async function unifiedSearchByAuthor( if (!workKey || seenWorkKeys.has(workKey)) continue; seenWorkKeys.add(workKey); results.push({ - ...b, title: cleanDisplayTitle(b.title), + author: b.author, + formatType: "paperback", // Default selection, not verified edition metadata. + source: "curated", }); } break; @@ -764,15 +765,6 @@ export async function unifiedSearchByAuthor( const workKey = canonicalizeBookTitle(cleanedTitle); if (!workKey || workKey.length < 2) return; - // Filter out untranslated foreign titles if we already have curated or Polish entries - const isEnglishUntranslated = /^[a-z0-9\s,.'":;!?-]+$/i.test(b.title) && - !/[ąćęłńóśźż]/i.test(b.title) && - cleanedTitle === b.title && - results.length > 0; - if (isEnglishUntranslated) { - return; - } - // Filter out obvious metadata or biography artifacts about the author const authorSimplified = author.toLowerCase().replace(/[^a-z0-9]/g, ""); if (workKey === authorSimplified || (workKey.includes(authorSimplified) && !workKey.includes("1984"))) { @@ -783,6 +775,10 @@ export async function unifiedSearchByAuthor( // Enrich existing book if incoming has cover or missing metadata const existing = results.find((r) => canonicalizeBookTitle(r.title) === workKey); if (existing) { + if (existing.source === "curated") { + Object.assign(existing, { ...b, title: cleanedTitle }); + return; + } if (!existing.coverUrl && b.coverUrl) { existing.coverUrl = b.coverUrl; } diff --git a/src/lib/api/validation.ts b/src/lib/api/validation.ts new file mode 100644 index 0000000..19fd225 --- /dev/null +++ b/src/lib/api/validation.ts @@ -0,0 +1,14 @@ +export function normalizeIsbn(value: string): string { + return value.replace(/[\s-]/g, '').toUpperCase(); +} +export function isValidIsbn(value: string): boolean { + const isbn = normalizeIsbn(value); + if (/^\d{9}[\dX]$/.test(isbn)) { + return Array.from(isbn).reduce((sum, c, i) => sum + (c === 'X' ? 10 : Number(c)) * (10 - i), 0) % 11 === 0; + } + return /^(978|979)\d{10}$/.test(isbn) && Array.from(isbn).reduce((sum, c, i) => sum + Number(c) * (i % 2 ? 3 : 1), 0) % 10 === 0; +} +export function parseSearchLimit(value: string | null, fallback: number): number | null { + if (value === null) return fallback; + return /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 40 ? Number(value) : null; +} diff --git a/src/lib/library/catalog.ts b/src/lib/library/catalog.ts new file mode 100644 index 0000000..13cd40a --- /dev/null +++ b/src/lib/library/catalog.ts @@ -0,0 +1,53 @@ +import type { Book, BookEdition, Series, BindingFormat, ReadingStatus } from '@/types'; +import { canonicalizeBookTitle, cleanDisplayTitle } from '../api/bookProviders'; + +export interface AddBookData { + title: string; + author: string; + series: string; + formatType: BindingFormat; + isbn?: string; + cover?: string; + publisher?: string; + publicationYear?: number; + readingStatus?: ReadingStatus; +} + +// Resolve IDs before updating React state; bulk additions must never depend on +// side effects inside a state updater (which React may defer or run twice). +export function addBookToCatalog(list: Series[], data: AddBookData, id: () => string = () => crypto.randomUUID()) { + const author = data.author.trim(); + const seriesName = data.series.trim() || `Twórczość: ${author}`; + const title = cleanDisplayTitle(data.title); + const series = list.find(s => s.author.trim().toLowerCase() === author.toLowerCase() + && s.seriesName.trim().toLowerCase() === seriesName.toLowerCase()); + const book = series?.books.find(b => canonicalizeBookTitle(b.title) === canonicalizeBookTitle(title)); + const isbn = (data.isbn || '').replace(/[\s-]/g, '').toUpperCase(); + const existingEdition = book?.editions.find(e => isbn ? e.isbn.replace(/[\s-]/g, '').toUpperCase() === isbn + : !e.isbn && e.formatType === data.formatType && e.publisher === (data.publisher || '') + && e.year === (data.publicationYear || 0)); + const edition: BookEdition = existingEdition || { + id: `ed-${id()}`, formatType: data.formatType, publisher: data.publisher || '', + year: data.publicationYear || 0, format: data.formatType === 'hardcover' ? 'Oprawa twarda' : 'Oprawa miękka', isbn, + }; + const updated: Book = book ? { + ...book, cover: book.cover || data.cover, + editions: existingEdition ? book.editions : [...book.editions, edition], + } : { + id: `book-${id()}`, title, volume: Math.max(0, ...(series?.books.map(b => b.volume) || [])) + 1, + formatType: data.formatType, cover: data.cover, prices: [], editions: [edition], + }; + const books = series ? (book ? series.books.map(b => b.id === book.id ? updated : b) : [...series.books, updated]) : [updated]; + return { + seriesList: series ? list.map(s => s.seriesId === series.seriesId ? { ...s, books } : s) + : [...list, { seriesId: `series-${id()}`, seriesName, author, books }], + bookId: updated.id, editionId: edition.id, + }; +} + +export function matchesBookFormat(book: Book, format: 'all' | BindingFormat, ownedEditionId?: string) { + if (format === 'all') return true; + const ownedEdition = book.editions.find(e => e.id === ownedEditionId); + return ownedEdition ? ownedEdition.formatType === format + : book.editions.length ? book.editions.some(e => e.formatType === format) : book.formatType === format; +} diff --git a/src/lib/pricing/priceEngine.ts b/src/lib/pricing/priceEngine.ts index 919cbb4..01cc748 100644 --- a/src/lib/pricing/priceEngine.ts +++ b/src/lib/pricing/priceEngine.ts @@ -11,6 +11,7 @@ export interface LiveStoreOffer extends PriceOffer { export interface SeriesBasketOptimization { totalMissingBooks: number; + pricedBooksCount: number; cheapestCombinedPrice: number; // sum of best price per book bestSingleStore?: { storeName: string; @@ -34,11 +35,16 @@ export interface SeriesBasketOptimization { */ export function parsePriceNumber(priceStr: string): number { if (!priceStr) return 0; - const sanitized = priceStr - .replace(/[^\d.,]/g, "") - .replace(",", "."); - const val = parseFloat(sanitized); - return isNaN(val) ? 0 : val; + if (/-\s*\d/.test(priceStr)) return 0; + let sanitized = priceStr.replace(/[^\d.,]/g, ""); + const separator = Math.max(sanitized.lastIndexOf(','), sanitized.lastIndexOf('.')); + if (separator >= 0) { + const decimalDigits = sanitized.length - separator - 1; + sanitized = decimalDigits <= 2 ? sanitized.slice(0, separator).replace(/[.,]/g, '') + '.' + sanitized.slice(separator + 1) + : sanitized.replace(/[.,]/g, ''); + } + const val = Number(sanitized); + return Number.isFinite(val) && val >= 0 ? val : 0; } /** @@ -54,6 +60,8 @@ export function generateStorePurchaseUrl(store: string, title: string, isbn?: st case "empik": return `https://www.empik.com/szukaj/produkt?q=${encoded}&qtype=basicForm`; case "taniaksiazka": + case "taniaksiążka": + case "taniaksiążka.pl": return `https://www.taniaksiazka.pl/szukaj/q-${encoded}`; case "świat książki": case "swiat ksiazki": @@ -97,7 +105,7 @@ export function calculateSeriesBasket( return p.formatType === formatPreference; }); - const activeOffers = filteredOffers.length > 0 ? filteredOffers : b.prices; + const activeOffers = filteredOffers.filter(p => parsePriceNumber(p.price) > 0); const liveOffers: LiveStoreOffer[] = activeOffers.map((p) => { const num = parsePriceNumber(p.price); @@ -118,7 +126,7 @@ export function calculateSeriesBasket( storeName: "Księgarnie", formatType: b.formatType, format: b.formatType === "hardcover" ? "Twarda" : "Miękka", - price: "0 zł", + price: "Brak ceny", shipping: "Standard", url: generateStorePurchaseUrl("allegro", b.title), inStock: false, @@ -128,17 +136,25 @@ export function calculateSeriesBasket( cheapestCombinedPrice += best.normalizedPrice; // Aggregate single store options + // Count each title once per store, even if it has several editions/offers. + const seenStores = new Set(); liveOffers.forEach((offer) => { - if (!storeAggregates[offer.storeName]) { - storeAggregates[offer.storeName] = { + const storeKey = offer.storeName.trim().toLowerCase(); + if (seenStores.has(storeKey)) return; + seenStores.add(storeKey); + const shipping = /gratis|free/i.test(offer.shipping) ? 0 + : /\d/.test(offer.shipping) ? parsePriceNumber(offer.shipping) : 9.99; + if (!storeAggregates[storeKey]) { + storeAggregates[storeKey] = { storeName: offer.storeName, totalPrice: 0, availableBooksCount: 0, - shippingEstimate: offer.shipping.toLowerCase().includes("smart") || offer.shipping.toLowerCase().includes("gratis") ? 0 : 9.99, + shippingEstimate: shipping, }; } - storeAggregates[offer.storeName].totalPrice += offer.normalizedPrice; - storeAggregates[offer.storeName].availableBooksCount += 1; + storeAggregates[storeKey].shippingEstimate = Math.max(storeAggregates[storeKey].shippingEstimate, shipping); + storeAggregates[storeKey].totalPrice += offer.normalizedPrice; + storeAggregates[storeKey].availableBooksCount += 1; }); breakdown.push({ @@ -176,6 +192,7 @@ export function calculateSeriesBasket( return { totalMissingBooks: missingBooks.length, + pricedBooksCount: breakdown.filter(b => b.bestOffer.inStock).length, cheapestCombinedPrice: Math.round(cheapestCombinedPrice * 100) / 100, bestSingleStore, breakdown, diff --git a/src/lib/supabase/shelfSync.ts b/src/lib/supabase/shelfSync.ts index ad2fb48..936a438 100644 --- a/src/lib/supabase/shelfSync.ts +++ b/src/lib/supabase/shelfSync.ts @@ -11,7 +11,7 @@ export interface UserShelfRecord { * Loads user owned books from Supabase if configured, otherwise returns null for local mock fallback. */ export async function loadUserShelfFromCloud(userId: string): Promise | null> { - if (!isSupabaseConfigured || !supabase) { + if (!isSupabaseConfigured || !supabase || userId.startsWith("local-")) { return null; } @@ -48,7 +48,7 @@ export async function saveUserBookToCloud( bookId: string, editionId: string ): Promise { - if (!isSupabaseConfigured || !supabase) { + if (!isSupabaseConfigured || !supabase || userId.startsWith("local-")) { return false; } @@ -80,7 +80,7 @@ export async function removeUserBookFromCloud( userId: string, bookId: string ): Promise { - if (!isSupabaseConfigured || !supabase) { + if (!isSupabaseConfigured || !supabase || userId.startsWith("local-")) { return false; } diff --git a/supabase/schema.sql b/supabase/schema.sql index a64876a..16828ea 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -46,18 +46,24 @@ ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; ALTER TABLE public.user_books ENABLE ROW LEVEL SECURITY; ALTER TABLE public.custom_books ENABLE ROW LEVEL SECURITY; --- 5. Policies for user data isolation -CREATE POLICY "Public profiles are viewable by everyone" - ON public.profiles FOR SELECT USING (true); +-- 5. Policies: rerunnable for existing installations as well as fresh databases. +DROP POLICY IF EXISTS "Public profiles are viewable by everyone" ON public.profiles; +DROP POLICY IF EXISTS "Users can read their own profile" ON public.profiles; +DROP POLICY IF EXISTS "Users can insert their own profile" ON public.profiles; +DROP POLICY IF EXISTS "Users can update their own profile" ON public.profiles; +DROP POLICY IF EXISTS "Users can manage their own shelf books" ON public.user_books; +DROP POLICY IF EXISTS "Users can manage custom books" ON public.custom_books; -CREATE POLICY "Users can insert their own profile" - ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id); - -CREATE POLICY "Users can update their own profile" - ON public.profiles FOR UPDATE USING (auth.uid() = id); - -CREATE POLICY "Users can manage their own shelf books" - ON public.user_books FOR ALL USING (auth.uid()::text = user_id OR user_id LIKE 'user-%'); - -CREATE POLICY "Users can manage custom books" - ON public.custom_books FOR ALL USING (auth.uid()::text = user_id OR user_id LIKE 'user-%'); +CREATE POLICY "Users can read their own profile" + ON public.profiles FOR SELECT TO authenticated USING (auth.uid() = id); +CREATE POLICY "Users can insert their own profile" + ON public.profiles FOR INSERT TO authenticated WITH CHECK (auth.uid() = id); +CREATE POLICY "Users can update their own profile" + ON public.profiles FOR UPDATE TO authenticated + USING (auth.uid() = id) WITH CHECK (auth.uid() = id); +CREATE POLICY "Users can manage their own shelf books" + ON public.user_books FOR ALL TO authenticated + USING (auth.uid()::text = user_id) WITH CHECK (auth.uid()::text = user_id); +CREATE POLICY "Users can manage custom books" + ON public.custom_books FOR ALL TO authenticated + USING (auth.uid()::text = user_id) WITH CHECK (auth.uid()::text = user_id); diff --git a/tailwind.config.ts b/tailwind.config.ts index 4ab63ce..40dd740 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -10,8 +10,8 @@ const config: Config = { theme: { extend: { fontFamily: { - sans: ["var(--font-plus-jakarta-sans)", "sans-serif"], - serif: ["var(--font-lora)", "serif"], + sans: ["var(--font-geist-sans)", "sans-serif"], + serif: ["Georgia", "serif"], }, colors: { background: "var(--background)", @@ -19,11 +19,15 @@ const config: Config = { brand: { 50: "#fdf8f6", 100: "#f2e8e5", + 200: "#fed7aa", + 300: "#fdba74", + 400: "#fb923c", 500: "#e05a47", 600: "#c2410c", 700: "#9a3412", 800: "#7c2d12", 900: "#431407", + 950: "#210a03", }, }, }, diff --git a/tests/bookProviders.test.ts b/tests/bookProviders.test.ts index cc20fed..22653ce 100644 --- a/tests/bookProviders.test.ts +++ b/tests/bookProviders.test.ts @@ -52,7 +52,8 @@ test("extractYear extracts 4-digit years accurately", () => { assert.equal(extractYear(undefined), undefined); }); -test("unifiedSearchByAuthor returns unique books without duplicates", async () => { +test("unifiedSearchByAuthor returns unique books without duplicates", async (t) => { + t.mock.method(globalThis, "fetch", async () => new Response(JSON.stringify({}), { status: 200 })); const books = await unifiedSearchByAuthor("George Orwell", 20); assert.ok(books.length >= 4); diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts new file mode 100644 index 0000000..6e0b262 --- /dev/null +++ b/tests/catalog.test.ts @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { addBookToCatalog, matchesBookFormat } from '../src/lib/library/catalog'; +import type { Series } from '../src/types'; + +const entry = { title: 'Solaris', author: 'Stanisław Lem', series: 'Powieści', formatType: 'hardcover' as const, isbn: '9788308069875', publisher: 'Wydawnictwo Literackie', publicationYear: 2020 }; +test('repeat import selects the existing edition and does not manufacture prices', () => { + const first = addBookToCatalog([], entry); + const again = addBookToCatalog(first.seriesList, entry); + assert.equal(first.bookId, again.bookId); + assert.equal(first.editionId, again.editionId); + assert.equal(again.seriesList[0].books[0].editions.length, 1); + assert.deepEqual(again.seriesList[0].books[0].prices, []); + assert.equal(again.seriesList[0].books[0].editions[0].publisher, entry.publisher); +}); +test('same series label does not mix authors; same author does not merge unrelated series', () => { + let list: Series[] = []; + for (const data of [entry, { ...entry, author: 'Inny autor' }, { ...entry, series: 'Inny cykl' }]) list = addBookToCatalog(list, data).seriesList; + assert.equal(list.length, 3); +}); +test('adding an edition is immutable and preserves distinct ISBNs and volumes', () => { + const first = addBookToCatalog([], entry); + first.seriesList[0].books[0].volume = 7; + const snapshot = JSON.stringify(first.seriesList); + const next = addBookToCatalog(first.seriesList, { ...entry, isbn: '9788308070260', formatType: 'paperback' }); + assert.equal(JSON.stringify(first.seriesList), snapshot); + assert.equal(next.seriesList[0].books[0].editions.length, 2); + assert.equal(next.seriesList[0].books[0].volume, 7); + assert.equal(matchesBookFormat(next.seriesList[0].books[0], 'paperback'), true); + assert.equal(matchesBookFormat(next.seriesList[0].books[0], 'paperback', first.editionId), false); +}); +test('missing metadata remains unknown instead of a fabricated ISBN/year/publisher', () => { + const result = addBookToCatalog([], { ...entry, isbn: undefined, publisher: undefined, publicationYear: undefined }); + const edition = result.seriesList[0].books[0].editions[0]; + assert.equal(edition.isbn, ''); assert.equal(edition.year, 0); assert.equal(edition.publisher, ''); +}); diff --git a/tests/home.test.ts b/tests/home.test.ts new file mode 100644 index 0000000..119d809 --- /dev/null +++ b/tests/home.test.ts @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import Home from '../src/app/page'; +import { AddBookModal } from '../src/components/AddBookModal'; +import { Navbar } from '../src/components/Navbar'; +import { AuthModal } from '../src/components/AuthModal'; +import { StatsCards } from '../src/components/StatsCards'; +import { accountKey, shelfKey } from '../src/hooks/useAccount'; +import type { UserAccount } from '../src/types'; + +function setup() { + const values = new Map(); + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + } }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: { documentElement: { lang: 'pl' } } }); + const user: UserAccount = { id: 'local-test', name: 'Test', email: '', role: 'Profil lokalny', avatar: '', ownedBooks: {} }; + localStorage.setItem('tomestack_local_session', user.id); + localStorage.setItem(accountKey(user.id), JSON.stringify(user)); + return { values, user }; +} +test('bulk addition retains all ownership and reading state; repeated addition is idempotent', async () => { + const { user } = setup(); + let view!: ReactTestRenderer; + await act(async () => { view = create(React.createElement(Home)); }); + await act(async () => { view.root.findByType(Navbar).props.onOpenAddBook(); }); + const add = view.root.findByType(AddBookModal).props.onAddBook; + const data = { title: 'Książka A', author: 'Autor', series: 'Seria', formatType: 'hardcover', isbn: '9788328716162', readingStatus: 'read' }; + await act(async () => { add(data); add({ ...data, title: 'Książka B', isbn: '9788328716179' }); }); + const saved = JSON.parse(localStorage.getItem(accountKey(user.id))!); + assert.equal(Object.keys(saved.ownedBooks).length, 2); + assert.equal(Object.values(saved.readingStatus).filter(status => status === 'read').length, 2); + assert.equal(view.root.findByType(StatsCards).props.ownedBooks, 2); + await act(async () => { view.root.findByType(AddBookModal).props.onAddBook(data); }); + assert.equal(view.root.findByType(StatsCards).props.ownedBooks, 2); + await act(async () => { view.unmount(); }); + await act(async () => { view = create(React.createElement(Home)); }); + assert.equal(view.root.findByType(StatsCards).props.ownedBooks, 2); + await act(async () => { view.root.findByType(Navbar).props.onLogout(); }); + assert.equal(view.root.findByType(StatsCards).props.totalBooks, 0); + assert.equal(view.root.findByType(StatsCards).props.ownedBooks, 0); + await act(async () => { view.root.findByType(AuthModal).props.onLogin(saved); }); + assert.equal(view.root.findByType(StatsCards).props.ownedBooks, 2); + assert.equal(JSON.parse(localStorage.getItem(shelfKey(user.id))!).length, 1); + await act(async () => { view.unmount(); }); +}); +test('wishlisted books are tracked without claiming ownership', async () => { + setup(); let view!: ReactTestRenderer; + await act(async () => { view = create(React.createElement(Home)); }); + await act(async () => { view.root.findByType(Navbar).props.onOpenAddBook(); }); + await act(async () => { view.root.findByType(AddBookModal).props.onAddBook({ title: 'Plan', author: 'Autor', series: 'Seria', formatType: 'paperback', readingStatus: 'wishlist' }); }); + assert.equal(view.root.findByType(StatsCards).props.totalBooks, 1); + assert.equal(view.root.findByType(StatsCards).props.ownedBooks, 0); + await act(async () => { view.unmount(); }); +}); +test('malformed shelf is preserved rather than overwritten on startup', async () => { + const { user } = setup(); + localStorage.setItem(shelfKey(user.id), '{broken'); + let view!: ReactTestRenderer; + await act(async () => { view = create(React.createElement(Home)); }); + assert.equal(localStorage.getItem(shelfKey(user.id)), '{broken'); + assert.ok(view.root.findAllByProps({ role: 'alert' }).length); + await act(async () => { view.unmount(); }); +}); diff --git a/tests/priceEngine.test.ts b/tests/priceEngine.test.ts index 4708982..bb69a94 100644 --- a/tests/priceEngine.test.ts +++ b/tests/priceEngine.test.ts @@ -3,9 +3,9 @@ import assert from "node:assert/strict"; import { parsePriceNumber, generateStorePurchaseUrl, calculateSeriesBasket } from "../src/lib/pricing/priceEngine"; test("parsePriceNumber accurately parses various Polish currency formats", () => { - assert.equal(parsePriceNumber("44,99 z�"), 44.99); + assert.equal(parsePriceNumber("44,99 zł"), 44.99); assert.equal(parsePriceNumber("39.50 PLN"), 39.5); - assert.equal(parsePriceNumber("52 z�"), 52); + assert.equal(parsePriceNumber("52 zł"), 52); assert.equal(parsePriceNumber(""), 0); assert.equal(parsePriceNumber("brak"), 0); }); @@ -15,7 +15,7 @@ test("generateStorePurchaseUrl creates correct bookstore links", () => { assert.match(urlEmpik, /empik\.com/); assert.match(urlEmpik, /9788328716162/); - const urlTania = generateStorePurchaseUrl("taniaksiazka", "Folwark zwierz�cy"); + const urlTania = generateStorePurchaseUrl("taniaksiazka", "Folwark zwierzłcy"); assert.match(urlTania, /taniaksiazka\.pl/); }); @@ -27,8 +27,8 @@ test("calculateSeriesBasket computes single-store vs cheapest optimization", () volume: 1, formatType: "hardcover" as const, prices: [ - { store: "Empik", formatType: "hardcover" as const, format: "Twarda", price: "30,00 z�", shipping: "0 z�", isBest: true, url: "" }, - { store: "TaniaKsi��ka", formatType: "hardcover" as const, format: "Twarda", price: "35,00 z�", shipping: "9 z�", isBest: false, url: "" }, + { store: "Empik", formatType: "hardcover" as const, format: "Twarda", price: "30,00 zł", shipping: "0 zł", isBest: true, url: "" }, + { store: "TaniaKsiążka", formatType: "hardcover" as const, format: "Twarda", price: "35,00 zł", shipping: "9 zł", isBest: false, url: "" }, ], }, { @@ -37,8 +37,8 @@ test("calculateSeriesBasket computes single-store vs cheapest optimization", () volume: 2, formatType: "hardcover" as const, prices: [ - { store: "Empik", formatType: "hardcover" as const, format: "Twarda", price: "40,00 z�", shipping: "0 z�", isBest: false, url: "" }, - { store: "TaniaKsi��ka", formatType: "hardcover" as const, format: "Twarda", price: "25,00 z�", shipping: "9 z�", isBest: true, url: "" }, + { store: "Empik", formatType: "hardcover" as const, format: "Twarda", price: "40,00 zł", shipping: "0 zł", isBest: false, url: "" }, + { store: "TaniaKsiążka", formatType: "hardcover" as const, format: "Twarda", price: "25,00 zł", shipping: "9 zł", isBest: true, url: "" }, ], }, ]; diff --git a/tests/regressions.test.ts b/tests/regressions.test.ts new file mode 100644 index 0000000..9af6674 --- /dev/null +++ b/tests/regressions.test.ts @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cleanAuthor, cleanTitle, fetchBnByIsbn } from '../src/lib/api/bn'; +import { cleanDisplayTitle, unifiedSearchByAuthor, unifiedSearchByQuery } from '../src/lib/api/bookProviders'; +import { isValidIsbn, parseSearchLimit } from '../src/lib/api/validation'; +import { calculateSeriesBasket, parsePriceNumber } from '../src/lib/pricing/priceEngine'; + +test('title cleanup preserves meaningful subtitles and slash-containing titles', () => { + for (const clean of [cleanTitle, cleanDisplayTitle]) { + assert.equal(clean('Diuna: Ród Atrydów'), 'Diuna: Ród Atrydów'); + assert.equal(clean('11/22/63'), clean === cleanDisplayTitle ? "Dallas '63" : '11/22/63'); + assert.equal(clean('Saga: tom 2'), 'Saga: tom 2'); + } + assert.equal(cleanAuthor('Tolkien, John Ronald Reuel (1892-1973)'), 'John Ronald Reuel Tolkien'); +}); +test('BN lookup preserves publisher and extracts year from a range', async t => { + t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ bibs: [{ id: 1, title: 'Test', publisher: 'Świat Książki', publicationYear: '2020-2021' }] }))); + const book = await fetchBnByIsbn('9788328716162'); + assert.equal(book?.publisher, 'Świat Książki'); assert.equal(book?.publicationYear, 2020); +}); +test('author search keeps Polish titles without diacritics and English titles', async t => { + t.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => new Response(JSON.stringify(String(input).includes('data.bn') ? { bibs: [ + { id: 1, title: 'Solaris', author: 'Nieznany Autor' }, { id: 2, title: 'Eden', author: 'Nieznany Autor' }, { id: 3, title: 'A new story', author: 'Nieznany Autor' }, + ] } : {}))); + const books = await unifiedSearchByAuthor('Nieznany Autor', 20); + assert.deepEqual(books.map(b => b.title), ['Solaris', 'Eden', 'A new story']); +}); +test('generic search keeps identically titled works by different authors', async t => { + t.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => new Response(JSON.stringify(String(input).includes('data.bn') ? { bibs: [ + { id: 1, title: 'Dom', author: 'Autor Pierwszy' }, { id: 2, title: 'Dom', author: 'Autor Drugi' }, + ] } : {}))); + assert.equal((await unifiedSearchByQuery('Dom')).length, 2); +}); +test('ISBN validates checksum and book prefix; limit rejects malformed or unbounded requests', () => { + assert.ok(isValidIsbn('978-83-287-1616-2')); assert.ok(isValidIsbn('0-8044-2957-x')); + for (const value of ['123', '1234567890123', '9788328716163', 'foo9788328716162']) assert.equal(isValidIsbn(value), false); + for (const value of ['-1', '0', '41', 'NaN', '2foo', '1.5']) assert.equal(parseSearchLimit(value, 8), null); + assert.equal(parseSearchLimit(null, 8), 8); assert.equal(parseSearchLimit('40', 8), 40); +}); +const offer = (price: string, formatType: 'hardcover' | 'paperback' = 'hardcover') => ({ store: 'Empik', price, formatType, format: '', shipping: '0 zł', url: '' }); +const book = { id: 'b1', title: 'Test', volume: 1, formatType: 'hardcover' as const, prices: [offer('20 zł'), offer('30 zł', 'paperback')] }; +test('basket counts one book per store, parses shipping, and respects unavailable bindings', () => { + const basket = calculateSeriesBasket([book]); + assert.equal(basket.bestSingleStore?.availableBooksCount, 1); + assert.equal(basket.bestSingleStore?.grandTotal, 20); + const absent = calculateSeriesBasket([{ ...book, prices: [offer('30 zł', 'paperback')] }], 'hardcover'); + assert.equal(absent.breakdown[0].offers.length, 0); + assert.equal(absent.pricedBooksCount, 0); + assert.equal(absent.bestSingleStore, undefined); + const invalid = calculateSeriesBasket([{ ...book, prices: [offer('brak'), offer('30 zł')] }]); + assert.equal(invalid.cheapestCombinedPrice, 30); +}); +test('prices support thousands separators and reject negative amounts', () => { + assert.equal(parsePriceNumber('1.234,56 zł'), 1234.56); + assert.equal(parsePriceNumber('1,234.56 PLN'), 1234.56); + assert.equal(parsePriceNumber('-30 zł'), 0); +}); +test('curated fallback does not present hardcoded ISBNs or covers as verified editions', async t => { + t.mock.method(globalThis, 'fetch', async () => new Response('{}')); + const books = await unifiedSearchByAuthor('George Orwell'); + assert.ok(books.length > 0); + for (const book of books) { + assert.equal(book.source, 'curated'); + assert.equal(book.isbn, undefined); + assert.equal(book.coverUrl, undefined); + assert.equal(book.publisher, undefined); + } +}); diff --git a/tests/rls.test.ts b/tests/rls.test.ts new file mode 100644 index 0000000..552fe1b --- /dev/null +++ b/tests/rls.test.ts @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { PGlite } from '@electric-sql/pglite'; + +test('PostgreSQL RLS isolates two users, anonymous clients and legacy user-prefix rows', async () => { + const db = new PGlite(); + try { + await db.exec(`CREATE ROLE anon; CREATE ROLE authenticated; + CREATE SCHEMA auth; + CREATE TABLE auth.users (id UUID PRIMARY KEY); + CREATE FUNCTION auth.uid() RETURNS UUID LANGUAGE SQL STABLE AS $$ SELECT NULLIF(current_setting('request.jwt.claim.sub', true), '')::UUID $$; + GRANT USAGE ON SCHEMA auth TO anon, authenticated; + GRANT EXECUTE ON FUNCTION auth.uid() TO anon, authenticated;`); + const schema = await readFile(new URL('../supabase/schema.sql', import.meta.url), 'utf8'); + await db.exec(schema); await db.exec(schema); // Existing installations can rerun safely. + const a = '00000000-0000-0000-0000-000000000001'; + const b = '00000000-0000-0000-0000-000000000002'; + await db.exec(`INSERT INTO auth.users VALUES ('${a}'), ('${b}'); + INSERT INTO public.profiles (id,name,email) VALUES ('${a}','A','a@example.com'),('${b}','B','b@example.com'); + INSERT INTO public.user_books (user_id,book_id,edition_id) VALUES ('${a}','a','ed-a'),('${b}','b','ed-b'),('user-legacy','legacy','ed-old'); + INSERT INTO public.custom_books (id,user_id,title,author,format_type) VALUES ('a','${a}','A','Author','hardcover'),('b','${b}','B','Author','paperback'),('old','user-legacy','Old','Author','paperback'); + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO anon, authenticated; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated; + SET ROLE anon;`); + for (const table of ['profiles', 'user_books', 'custom_books']) assert.equal((await db.query(`SELECT * FROM public.${table}`)).rows.length, 0); + await assert.rejects(db.exec("INSERT INTO public.user_books (user_id,book_id,edition_id) VALUES ('user-attack','x','x')"), /row-level security/); + await db.exec(`RESET ROLE; SET ROLE authenticated; SELECT set_config('request.jwt.claim.sub','${a}',false);`); + for (const table of ['profiles', 'user_books', 'custom_books']) assert.equal((await db.query(`SELECT * FROM public.${table}`)).rows.length, 1); + await db.exec(`INSERT INTO public.user_books (user_id,book_id,edition_id) VALUES ('${a}','new','new');`); + await assert.rejects(db.exec(`INSERT INTO public.user_books (user_id,book_id,edition_id) VALUES ('${b}','attack','attack')`), /row-level security/); + await assert.rejects(db.exec(`UPDATE public.user_books SET user_id='${b}' WHERE book_id='a'`), /row-level security/); + assert.equal((await db.query(`DELETE FROM public.user_books WHERE user_id='${b}' RETURNING *`)).rows.length, 0); + assert.equal((await db.query(`UPDATE public.custom_books SET title='attack' WHERE user_id='${b}' RETURNING *`)).rows.length, 0); + await assert.rejects(db.exec(`INSERT INTO public.custom_books (id,user_id,title,author,format_type) VALUES ('attack','user-forged','x','x','hardcover')`), /row-level security/); + await db.exec(`SELECT set_config('request.jwt.claim.sub','${b}',false);`); + assert.deepEqual((await db.query('SELECT book_id FROM public.user_books')).rows, [{ book_id: 'b' }]); + assert.deepEqual((await db.query('SELECT email FROM public.profiles')).rows, [{ email: 'b@example.com' }]); + } finally { await db.close(); } +});