Przejście do treści
BajaBox
E-commerce

BajaBox

Butikowy sklep z ręcznie robionymi pudełkami i skarbonkami — własny backend, własny panel admina.

W produkcji (bajabox.pl), przed uruchomieniem sprzedaży ·

  • Next.js
  • React
  • TypeScript
  • .NET 10
  • PostgreSQL
  • Clean Architecture
Krótki opis

Sklep e-commerce na unikatowe, ręcznie robione produkty. Bez magazynu, bez sztuk.

Zrzuty ekranu

Pasek można przewijać w bok. Kliknięcie zrzutu powiększa go — dalej nawigacja strzałkami.

Pełny opis

BajaBox to produkcyjny sklep e-commerce dla marki oferującej ręcznie dekorowane pudełka, skarbonki i ramki — prezenty dla dzieci robione pojedynczo, nie masowo. Monorepo zawiera sklep dla klienta, panel administracyjny właściciela i API, które obsługuje oba.

RÓŻNICA OD TYPOWEGO SKLEPU

Każdy produkt jest unikatem. BajaBox nigdy nie zakłada istnienia dwóch identycznych egzemplarzy tego samego produktu, więc koszyk nie zna pojęcia ilości — produkt można dodać co najwyżej raz, a jego status (Dostępny / Zarezerwowany / Sprzedany) jest częścią modelu domeny, nie dodatkiem.

REZERWACJA ZAMIAST MAGAZYNU

Złożenie zamówienia rezerwuje produkt na czas realizacji płatności (domyślnie 15 minut) przez pojedynczy atomowy warunkowy UPDATE w Postgresie — bez osobnej tabeli rezerwacji, bez blokad na poziomie aplikacji. Wygasła rezerwacja jest rozpoznawana od razu przy odczycie, jeszcze zanim zajmie się nią zadanie porządkujące w tle.

PANEL ADMINISTRACYJNY

Jeden właściciel loguje się przez JWT bez odświeżania tokenu, zarządza katalogiem (dodawanie, edycja, zmiana statusu, podmiana zdjęcia, usuwanie) i zamówieniami (podgląd, oznaczenie jako opłacone, anulowanie) — każda z tych operacji jest idempotentna i bezpieczna względem automatycznego wygasania rezerwacji w tle.

DOSTAWA I PŁATNOŚĆ

Kurier albo paczkomat InPost, przelew bankowy z numerem zamówienia w tytule generowanym po stronie serwera. Cena dostawy, nazwa produktu i kwota do zapłaty nigdy nie pochodzą od klienta — serwer sam je oblicza z aktualnego stanu produktów i konfiguracji.

WŁASNA INFRASTRUKTURA

Backend .NET i frontend Next.js działają na jednym serwerze VPS jako usługi systemd, wdrażane skryptem, który buduje kod lokalnie i wysyła gotowe artefakty przez SSH — bez Dockera, bez zewnętrznego PaaS.


Technologie

Zakres prac: Projekt, architektura, implementacja frontendu i backendu, wdrożenie i utrzymanie

Aplikacja frontendowa
  • Next.js 16 (App Router)
  • React 19
  • TypeScript
  • Tailwind CSS v4
  • Redux Toolkit
  • Leaflet (parcel locker picker)
Backend
  • .NET 10
  • ASP.NET Core (minimal API)
  • EF Core 10
  • PostgreSQL
  • JWT (HMAC-SHA256)
  • MailKit (SMTP)
Testy
  • xUnit
  • Testcontainers
  • concurrency & migration tests
Wdrożenie
  • OVHCloud VPS
  • systemd
  • nginx (reverse proxy + TLS)
  • SSH + rsync deploy script

Realizacja — architektura i technologie

12 min

Realizacja w jednym akapicie

BajaBox to sklep e-commerce dla butikowej marki sprzedającej ręcznie dekorowane pudełka, skarbonki i ramki na zdjęcia — prezenty dla dzieci, z których każdy egzemplarz jest inny. Monorepo mieści trzy części zbudowane w Clean Architecture: publiczny sklep i panel administracyjny we wspólnej aplikacji Next.js 16, oraz ASP.NET Core API z PostgreSQL jako jedynym źródłem prawdy. To realizacja, w której najciekawsze decyzje wzięły się nie z technologii, tylko z natury samego produktu: skoro każdy przedmiot istnieje dokładnie w jednym egzemplarzu, cały model danych — koszyk, rezerwacja, zamówienie — musiał to założenie odzwierciedlać od pierwszej linijki kodu.

Jedna sztuka, żadnego magazynu

Typowy sklep internetowy ma stan magazynowy i ilość w koszyku. BajaBox świadomie tego nie ma. Produkt to fizyczny, ręcznie wykonany przedmiot z trzema możliwymi stanami — Available, Reserved, Sold — i koszyk nie zna pojęcia ilości: produkt można dodać najwyżej raz, a próba dodania czegoś, co jest już w koszyku, zmienia etykietę przycisku na "W koszyku" zamiast pokazywać błąd. Ta decyzja produktowa (opisana wprost w briefie: "BajaBox never assumes two identical units of the same product exist") determinuje kształt niemal każdej późniejszej warstwy — od frontendowego stanu Reduksa po to, jak backend rezerwuje produkty podczas checkoutu.

Architektura — te same granice po obu stronach stosu

Backend jako realne projekty .NET, zależności skierowane do wewnątrz:

BajaBox.Domain            encje i enumy (Product, Order, OrderItem, Payment...) — zero zależności
BajaBox.Application       use case'y, walidacja, porty (IProductRepository, IOrderRepository...)
BajaBox.Infrastructure    EF Core/Postgres, JWT, przechowywanie zdjęć, zadanie porządkujące rezerwacje
BajaBox.Api               minimal API, kontrakty request/response, mapowanie błędów na ProblemDetails

Frontend jest jedną aplikacją Next.js z App Routerem, w której sklep (/sklep, /produkt/[slug], /koszyk, /checkout) i panel administracyjny (/admin/*) współdzielą jeden serwer, ale zero stanu — cała komunikacja z API idzie przez server-side BAJABOX_API_BASE_URL, nigdy wystawiane wprost do przeglądarki, bo backend nie ma CORS-a.

Rezerwacja zamiast tabeli rezerwacji

Najciekawsza decyzja projektowa w całym backendzie: nie ma osobnej tabeli `ProductReservation`. Skoro produkt fizycznie istnieje w jednym egzemplarzu, to "co najwyżej jedna aktywna rezerwacja na produkt" jest już zagwarantowane przez pojedynczą nullowalną kolumnę ReservedByOrderId na wierszu Product — bez dodatkowego unique constraintu.

Gwarancja współbieżności to jeden atomowy, warunkowy UPDATE:

UPDATE products SET status = 'Reserved', reserved_by_order_id = @orderId,
       reservation_expires_at = @expiresAt, updated_at = @now
WHERE id = @productId
  AND (status = 'Available'
       OR (status = 'Reserved' AND reservation_expires_at <= @now))

Postgres blokuje pasujący wiersz na czas trwania zapytania — drugie, równoległe zamówienie na ten sam produkt czeka, aż pierwsze się zatwierdzi albo wycofa, po czym samo zapytanie ponownie ocenia ten sam WHERE względem tego, czym wiersz faktycznie się stał. Nie da się wygrać wyścigu, który ktoś inny już wygrał. Ten sam warunek (OR reservation_expires_at <= @now) sprawia, że wygasła rezerwacja jest rozpoznawana od razu przy odczycie, zanim jeszcze zajmie się nią jakikolwiek proces w tle — poprawność nigdy nie zależy od tego, czy sprzątanie zdążyło przebiec.

Dla zamówienia wieloproduktowego cały checkout to jedna transakcja: najpierw wstawiany jest Order z OrderItems, potem próba rezerwacji każdego produktu posortowanego po id — dzięki temu dwa zamówienia rywalizujące o nakładający się zestaw produktów zawsze próbują zablokować je w tej samej kolejności, co eliminuje deadlock z porządku blokowania. Jeśli którykolwiek produkt nie da się zarezerwować, cała transakcja — łącznie z dopiero co wstawionym zamówieniem — jest wycofywana i nic nie zostaje.

Sprzątanie, które nie musi się spieszyć

Rezerwacja wygasa po Reservation:DurationMinutes (domyślnie 15 minut). Zadanie w tle (ReservationCleanupService) okresowo zwalnia przeterminowane rezerwacje i przełącza zamówienie PendingPayment → Cancelled, ale to czysto porządkowa czynność — logika, która faktycznie decyduje, czy rezerwacja jest ważna, mieszka w tym samym warunku SQL, który obsługuje każdy odczyt. Zadanie w tle może się spóźnić o minutę i nic w systemie na tym nie ucierpi.

Oznaczenie jako opłacone bez ryzyka wyścigu z wygaśnięciem

Administrator może ręcznie oznaczyć zamówienie jako opłacone (POST /api/admin/orders/{id}/mark-as-paid) — realny scenariusz przy przelewach bankowych, gdzie potwierdzenie przychodzi asynchronicznie. Ta operacja używa dokładnie tego samego wzorca co automatyczne wygasanie: atomowy, warunkowy ExecuteUpdateAsync w jednej transakcji, który przestawia Payment na Succeeded, Order na Paid i każdy zarezerwowany produkt na Sprzedany. Dzięki temu ręczne zatwierdzenie płatności nigdy nie może się prześcignąć z automatycznym wygaśnięciem rezerwacji w stan mieszany — sytuację, w której płatność jest Succeeded, a zamówienie już Cancelled. Operacja jest też idempotentna: drugie wywołanie na już opłaconym zamówieniu to bezpieczny no-op, nie błąd i nie drugi Payment.

Ceny, które nigdy nie pochodzą od klienta

POST /api/orders przyjmuje tylko listę identyfikatorów produktów i dane dostawy — nigdy cenę, nazwę produktu, koszt wysyłki ani kwotę do zapłaty. Serwer sam odczytuje aktualne wiersze Product, kopiuje ich cenę i nazwę do OrderItem jako migawkę (ta sama zasada "snapshot, nie przelicz na nowo" dotyczy kosztu dostawy), i sam liczy TotalAmount. Późniejsza zmiana ceny dostawy w konfiguracji nigdy nie wpływa na już złożone zamówienie — nawet przy idempotentnym powtórzeniu requestu, które i tak zwraca oryginalne zamówienie zamiast liczyć cokolwiek od nowa.

Numer zamówienia (BB-2026-000123) pochodzi z sekwencji Postgresa przez nextval() — atomowej z definicji, bez blokad na poziomie aplikacji.

Idempotencja jako jedno pole, nie middleware

Opcjonalny nagłówek Idempotency-Key na POST /api/orders jest zapisywany na zamówieniu pod unikalnym indeksem częściowym (WHERE idempotency_key IS NOT NULL, więc zapytania bez klucza się nie ograniczają nawzajem). Powtórzony request z tym samym kluczem zwraca oryginalne zamówienie zamiast tworzyć duplikat — sprawdzane z góry, ale też jako fallback, gdyby prawdziwie równoległy powtórzony request przegrał własną próbę rezerwacji z konkurencyjnym requestem o tym samym kluczu, i wreszcie jako ostatnia linia obrony przez złapanie naruszenia unikalności w bazie. To rozwiązanie celowo ograniczone do jednego endpointu, a nie generyczny middleware.

Paczkomat bez oficjalnego API

BajaBox nie ma jeszcze konta biznesowego InPost (brak zarejestrowanej firmy/NIP-u), więc wybór paczkomatu w checkoucie to własny interfejs, a nie oficjalny widget InPost. Zasila go GET /api/delivery/inpost-points, który woła publiczne, nieautoryzowane API InPostu (api-shipx-pl.easypack24.net/v1/points) — bez tokenu, z rozstrzyganiem zapytania po nazwie punktu, potem współrzędnych, potem kodzie pocztowym, potem mieście. Pragmatyczne rozwiązanie na czas, zanim pojawi się prawdziwa integracja biznesowa.

Panel admina — jedno konto, żadnych ról

Uwierzytelnianie administratora to pojedyncze konto, JWT podpisany HMAC-SHA256, bez refresh tokenów — po wygaśnięciu tokenu administrator po prostu loguje się ponownie. Brak domyślnych danych logowania w kodzie: hasło trzeba wygenerować lokalnym narzędziem (BajaBox.PasswordHasher) i wpisać wyłącznie hash do konfiguracji. Produkcyjny host bez skonfigurowanego AdminAuth:JwtSecret odmawia startu — to fail-closed, nie fail-open.

Ten sam JWT chroni też edycję treści strony /o-mnie, która nie jest zaszyta w kodzie frontendu — żyje jako jeden wiersz AboutPage w Postgresie z uporządkowaną listą bloków (TextSection, Quote, BulletList), edytowalny w całości w jednym atomowym requeście: jeden niepoprawny blok odrzuca cały zapis, więc strona nigdy nie zostaje zapisana w połowie.

Wdrożenie bez Dockera i bez PaaS

Backend i frontend działają na jednym VPS-ie OVHCloud jako dwie usługi systemd. Skrypt wdrożeniowy buduje oba projekty lokalnie (dotnet publish, npm run build), pakuje gotowe artefakty i wysyła je po SSH jednym uwierzytelnionym połączeniem (SSH ControlMaster — jedno hasło na cały przebieg), po czym podmienia pliki na serwerze i restartuje usługę, kończąc health-checkiem. Migracje bazy danych są celowo wyłączone z tego skryptu i traktowane jako osobny, ręcznie przeglądany krok — kolejność jest zawsze taka sama: najpierw migracja bazy do przodu, potem podmiana kodu, nigdy odwrotnie, bo stary, wciąż działający backend nie dotyka kolumny, o której nie wie.

Co z tej realizacji wynika

Najmocniejsza lekcja z BajaBoksa: rezygnacja z ilości w koszyku i z osobnej tabeli rezerwacji nie jest uproszczeniem — to konsekwentne domodelowanie faktu, że produkt istnieje w jednym egzemplarzu, przeprowadzone od frontendowego przycisku "Dodaj do koszyka" aż po pojedynczy warunkowy UPDATE, który jest jedynym miejscem w systemie decydującym, czy rezerwacja się udała. Rzecz, którą bym powtórzył: postawienie logiki poprawności na warunku SQL sprawdzanym przy każdym odczycie, a nie na dyscyplinie zadania w tle, oznacza, że system zachowuje się poprawnie nawet wtedy, gdy coś w tle pójdzie wolniej niż powinno.


Porozmawiajmy o planowanym projekcie

Pierwsza konsultacja jest bezpłatna i niezobowiązująca. Wystarczy krótki opis procesu oraz oczekiwanego rezultatu.

Porozmawiajmy o projekcie