A full-stack e-commerce marketplace with a product catalog, cart, favorites, and dual buyer/seller roles. The backend is a Node.js/Express REST API on PostgreSQL; the frontend is a React (Vite) single-page application.
- Catalog of 54 seeded products across 6 categories (Electronics, Clothing, Home & Living, Sports, Books, Beauty), each with price, discount, stock, rating, and review count
- Category filtering, full-text search (PostgreSQL GIN index, with an ILIKE fallback for partial matches), and 6 sort modes (featured, price low-to-high, price high-to-low, rating, review count, newest)
- A dedicated product detail page — larger view, quantity picker, stock status, seller attribution, and related products from the same category
- Cart and favorites, scoped to the signed-in user via their JWT — not a URL parameter
- Row-level stock locking on checkout-adjacent operations so concurrent requests can't oversell stock
- JWT authentication with a
customer/sellerrole chosen at registration, enforced by arequireRolemiddleware - Sellers get their own product CRUD (create/update/soft-delete), scoped to products they own
- Checkout turns a cart into an order inside a single transaction — stock is locked and decremented, a pluggable payment step runs, and the cart is cleared only once payment succeeds
- Order history for buyers; a per-item order queue with status updates (pending → processing → shipped → delivered) for sellers
- Centralized request validation (
express-validator) and error handling, Helmet + CORS allow-list, and a/healthendpoint that checks the database connection
Backend: Node.js, Express, PostgreSQL (pg), dotenv, JWT
(jsonwebtoken), bcryptjs, express-validator, Helmet, CORS, Morgan,
Nodemon (dev)
Frontend: React, Vite, lucide-react
- Node.js ≥ 18
- PostgreSQL ≥ 14
backend/
├── migrations/
│ ├── 001_initial.sql
│ ├── 002_add_orders.sql
│ └── migrate.js
├── seeds/
│ └── seed.js
├── src/
│ ├── app.js
│ ├── config/
│ │ └── db.js
│ ├── controllers/
│ │ ├── authController.js
│ │ ├── cartController.js
│ │ ├── categoryController.js
│ │ ├── favoriteController.js
│ │ ├── orderController.js
│ │ └── productController.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── errorHandler.js
│ │ └── validate.js
│ ├── routes/
│ │ ├── auth.js
│ │ ├── cart.js
│ │ ├── categories.js
│ │ ├── favorites.js
│ │ ├── orders.js
│ │ └── products.js
│ └── services/
│ └── paymentService.js
├── .env.example
└── package.json
frontend/
├── index.html
├── vite.config.js
├── src/
│ ├── api.js
│ ├── App.jsx
│ └── main.jsx
└── package.json
(node_modules/ is omitted — it's regenerated by npm install. The
frontend stays single-file by design — checkout and order history are
components inside App.jsx, and api.js gained an orders module —
so no new frontend files were added.)
psql -U postgres
CREATE DATABASE vendoo;
\qcd backend
npm install
cp .env.example .env # fill in DB_HOST, DB_USER, DB_PASSWORD, JWT_SECRET, etc.
npm run migrate # create tables
npm run seed # load 54 demo products, categories, and demo users
npm run dev # http://localhost:3000cd frontend
npm install
npm run dev # http://localhost:5173Make sure the backend is reachable at the address configured as BASE_URL
in src/api.js (http://localhost:3000/api by default).
Orders are paid through a mock provider by default (PAYMENT_MOCK_MODE=true
in .env), so checkout works end-to-end without a real payment account —
see the 0.1.1 entry below for how to wire up a live gateway later.
| Role | Password | |
|---|---|---|
| Buyer | alici@demo.com | Demo1234 |
| Seller | satici@demo.com | Demo1234 |
categories products users
────────── ──────── ─────
id (PK, serial) id (PK, serial) id (PK, uuid)
name category_id (FK) email
slug seller_id (FK → users) name
emoji name password_hash
sort_order description is_verified
created_at price role ('customer'|'seller')
discount created_at
stock
emoji cart_items
tag ──────────
rating id (PK, serial)
review_count user_id (FK)
is_active product_id (FK)
created_at / updated_at quantity
created_at / updated_at
favorites
─────────
user_id (FK) ┐ PK
product_id(FK) ┘
created_at
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/products |
Public | List products — query params below |
| GET | /api/products/category/:slug |
Public | Products in one category |
| GET | /api/products/:id |
Public | Single product |
| POST | /api/products |
Seller | Create a product |
| PUT | /api/products/:id |
Seller | Update a product you own |
| DELETE | /api/products/:id |
Seller | Soft-delete a product you own |
GET /api/products query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
category |
string | — | Category slug (electronics, clothing, …) |
search |
string | — | Matches name via full-text search + ILIKE |
sort |
string | featured |
featured, price_asc, price_desc, rating, reviews, newest |
page |
number | 1 |
Page number |
limit |
number | 20 |
Items per page (max 100) |
sellerId |
uuid | — | Restrict to one seller's products |
Example response:
{
"data": [
{
"id": 5,
"name": "Mechanical Keyboard RGB",
"price": "1599.00",
"discount": 0,
"final_price": "1599",
"stock": 60,
"category_name": "Electronics",
"category_slug": "electronics"
}
],
"meta": { "total": 10, "page": 1, "limit": 12, "totalPages": 1, "hasNext": false, "hasPrev": false }
}| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/categories |
Public | All categories with product counts |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/cart |
Get the current user's cart |
| POST | /api/cart |
Add an item — body: productId, quantity |
| PUT | /api/cart/item/:itemId |
Update an item's quantity |
| DELETE | /api/cart/item/:itemId |
Remove one item |
| DELETE | /api/cart |
Clear the cart |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/favorites |
List favorites |
| GET | /api/favorites/check/:productId |
Check favorite status |
| POST | /api/favorites |
Add a favorite — body: productId |
| DELETE | /api/favorites/:productId |
Remove a favorite |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/register |
body: email, name, password, role? (customer|seller) |
| POST | /api/auth/login |
body: email, password |
| GET | /api/auth/me |
Auth required |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/orders |
Checkout — body: shippingAddress. Converts the caller's cart into an order |
| GET | /api/orders |
The caller's own order history |
| GET | /api/orders/:id |
A single order the caller owns, with its items |
| GET | /api/orders/seller/items |
Seller-only — order items across all orders that belong to the caller |
| PUT | /api/orders/items/:itemId/status |
Seller-only, ownership-checked — body: status (pending|processing|shipped|delivered|cancelled) |
import { products, cart, auth, orders } from './api';
// Products
const { data, meta } = await products.list({ category: 'electronics', sort: 'price_asc' });
// Cart (token comes from the signed-in session)
await cart.add(productId, 1, token);
// Auth
const { token, user } = await auth.login(email, password);
// Orders
const order = await orders.create(shippingAddress, token);
const myOrders = await orders.list(token);- Frontend-only release — the existing
GET /api/products/:idendpoint already returned everything a detail view needed (description, seller name via a join, rating, stock), so no backend or schema changes were required - New
ProductDetailView, added inside the existingApp.jsxalongside the other components: large product view, quantity stepper, live stock status, "Sold by" attribution for seller-listed products, and a "You might also like" row (same-category products, computed client-side from the already-loaded catalog — no extra request) ProductCardis now clickable (title/image area) to open the detail view, with keyboard support (Enter/Space) androle="button"; the favorite and "Add to Cart" buttons stop event propagation so quick actions from the grid still work without navigating awayaddToCartnow accepts an optional quantity (defaults to1), so the detail page's quantity stepper can add more than one at a time while the card's quick-add button keeps its original one-tap behavior- Category bar hides while viewing a product; the search box and the Vendoo wordmark both return to the catalog automatically
- Database: new
ordersandorder_itemstables (002_add_orders.sql).order_items.product_id/order_idareINT, matching the existingSERIALprimary keys onproducts/orders;seller_idisUUID, matchingusers(id)— denormalized from the product at purchase time so a seller's order queue stays correct even if the product changes later - Checkout:
POST /api/ordersreads the caller's cart, row-locks each product (FOR UPDATE, the same pattern used to fix theaddToCartrace condition in 0.0.2), checks stock, decrements it, snapshots the price paid, runs the order throughpaymentService, and clears the cart — all inside one transaction, so a failed payment rolls back the stock deduction too - Payments: new
services/paymentService.js, a small provider-agnostic interface. It ships in mock mode (PAYMENT_MOCK_MODE=true) so checkout works end-to-end with no real payment account; going live means implementing the samecharge()function against a real provider's SDK (Stripe, iyzico, ...) - Order management: buyers get
GET /api/orders(history) andGET /api/orders/:id(detail); sellers getGET /api/orders/seller/itemsandPUT /api/orders/items/:itemId/statusto move their own items through pending → processing → shipped → delivered - Frontend: checkout modal, an order-history drawer for buyers, and an
"Orders" tab with a live status dropdown for sellers — all added as new
components inside the existing
App.jsx, plus anordersmodule inapi.js. Seller dashboard gained a fourth "Pending Orders" stat card
- Database:
role(customer/seller) added tousers,seller_idadded toproducts— both folded into the initial migration - Auth:
register,login, andmecarryroleend-to-end, including inside the JWT payload;registervalidatesroleis one ofcustomer/seller - Authorization:
requireRole(...roles)middleware; seller-only routes protected with[authenticate, requireRole("seller")] - Seller product management:
POST /api/products,PUT /api/products/:id,DELETE /api/products/:id, each checking the caller owns the product; deletes are soft (is_active = false) - Frontend: a dedicated
AuthScreen(role selection + login/register) and separateCustomerApp/SellerAppviews chosen byuser.role; the session persists insessionStorage - Localization: all source comments, API error/log messages, category and
seed-product data, and UI copy translated to English (category slugs
changed accordingly, e.g.
elektronik→electronics); full-text search configuration switched from'turkish'to'english'to match; bothpackage.jsonversionfields now read0.1.0 - Rebranded from "Pazaryeri" to Vendoo across the codebase (package
names, database name default, page title, and the in-app wordmark);
version stays at
0.1.0
- Role-selection screen (Buyer / Seller) with animated cards, a demo-account autofill button, and a combined login/register form
- Buyer experience: header shows name and avatar; cart and favorites persist across logout/login; seller-added products appear in the catalog tagged "Seller Product"
- Seller dashboard: sidebar with Panel (stat cards + recent products), My Products (table with edit/delete and a delete-confirmation modal), and New Product (form with an emoji picker and a live discount preview)
- Note: at this stage the app was a self-contained frontend demo — auth,
cart, favorites, and seller products all ran on
window.storage, with no backend calls yet
- Fixed a race condition in
addToCartby wrapping it in a transaction withSELECT ... FOR UPDATErow locking, so concurrent requests can no longer oversell stock; the stock check also accounts for the quantity already in the cart userIdis no longer read from the URL; a JWT-basedauthenticatemiddleware was added and applied to every cart and favorites route- Wired up
express-validatoracross all routes, with centralized422error responses via a sharedvalidatemiddleware - Product search now matches via a GIN full-text index
(
to_tsvector/plainto_tsquery), with an ILIKE fallback for partial matches - Added a 10 kb JSON body size limit;
/healthnow runsSELECT 1to verify the database connection instead of only confirming the process is alive - Split the single combined routes file into
auth.js,cart.js,favorites.js, andcategories.js - Added a
_migrationstracking table so re-running migrations skips what was already applied; addedpassword_hashandis_verifiedcolumns tousers - New:
authController.jswith bcrypt password hashing, JWT issuing, andregister/login/getMeendpoints, with password-policy validation (minimum 8 characters, an uppercase letter, and a digit) - Frontend: memoized components and derived values, cleaned up a
setTimeoutmemory-leak risk, added a search debounce, made product cards stock-aware, and added accessibility attributes
- Frontend: catalog of 54 products across 6 categories with discounts, tags, and ratings; real-time search; category filter bar; sort options (price, rating, review count); add to cart directly from the product card; toggle favorites; cart and favorites drawers; discount badges
- Backend: Node.js + Express REST API on PostgreSQL; schema for
categories,products,users,cart_items, andfavoriteswith foreign keys, indexes, andupdated_attriggers; endpoints for products (filter/search/sort/pagination), categories, cart, and favorites; centralized error handling, CORS + Helmet, and connection pooling
- Real payment gateway integration (Stripe or iyzico) in place of the mock provider, plus webhook handling for asynchronous payment confirmation
- Order status transition rules (e.g. prevent skipping from
pendingstraight todelivered) - Admin dashboard for product and stock management
- Redis caching for frequent queries
- Product image uploads (AWS S3 or Cloudinary)