Ecommerce Template
Next.js·React 등으로 만든 최신 전자상거래 템플릿
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A small shop ledger for NetBazar, Hazi Rahim Super Market (2nd floor), Shafipur
Bazar: record customer sales and dealer purchases, track stock levels, and print or
save an A4 invoice for any sale.
Built to run on a single machine in the shop. Once installed it needs no internet —
every stylesheet, script and font is served from the app itself.README 전문 보기 ↗A small shop ledger for NetBazar, Hazi Rahim Super Market (2nd floor), Shafipur Bazar: record customer sales and dealer purchases, track stock levels, and print or save an A4 invoice for any sale.
Built to run on a single machine in the shop. Once installed it needs no internet — every stylesheet, script and font is served from the app itself.
Requires Node.js 18 or newer (developed on Node 22).
npm install # dependencies
npm run build # compile CSS + copy Alpine and fonts into public/
# First run only: creates the admin account.
[email protected] ADMIN_PASSWORD='choose-a-strong-one' npm start
npm start # afterwards — http://localhost:3000
There is no default password. The first admin is created from ADMIN_EMAIL and
ADMIN_PASSWORD on a database with no admin yet; after that, change it from the dashboard.
Set JWT_SECRET too, or logins will not survive a restart —
see .env.example and DEPLOY.md.
npm run build only needs an internet connection once, to install packages. After that
the app runs entirely offline.
npm run watch:css # rebuild CSS on save, in one terminal
npm start # server, in another
public/css/app.css is a generated file. Never edit it by hand — edit
src/input.css and rebuild, or your changes are lost on the next build.
| Layer | Choice | Notes |
|---|---|---|
| Server | Express 4 | server.js; also exported for serverless |
| Database | SQLite via @libsql/client | local shop.db, or hosted Turso |
| Frontend | One static HTML page | public/index.html |
| Interactivity | Alpine.js 3 | served from public/vendor/, not a CDN |
| Styling | Tailwind CSS 4 | compiled ahead of time to public/css/app.css |
| Fonts | Noto Sans Bengali | served from public/fonts/ |
There is no bundler and no framework. The page is plain HTML with Alpine directives;
public/js/app.js holds all the state and logic in a single
shopApp() component. Pages are switched by the URL hash (#/sales/new, #/serials…) —
see Screens and navigation.
Why assets are vendored rather than loaded from a CDN: a shop cannot have its billing
screen render as unstyled text because an internet link dropped. npm run vendor copies
Alpine and the font files out of node_modules into public/, and those copies are
committed to the repository, so a fresh checkout works without npm install.
server.js Express app, database setup, auth, all API routes
api/index.js re-exports the app as the Vercel serverless handler
vercel.json routes every request to that handler
.env.example the environment variables the app reads
shop.db the local database (gitignored — see Backups)
public/ everything served to the browser
index.html the entire UI: login, app shell, every page, dialogs, invoice
js/shop-config.js shop name, address, phones, email, invoice terms
js/app.js Alpine component — state, API calls, print logic
css/app.css GENERATED by `npm run build:css` — do not edit
vendor/alpine.min.js copied from node_modules by `npm run vendor`
fonts/*.woff2 copied from node_modules by `npm run vendor`
src/input.css CSS source: Tailwind import, @font-face, brand palette,
component classes (.nb-*), motion, invoice + print styles
scripts/vendor.js copies Alpine + fonts into public/
Everything lives in public/index.html. Until the server confirms a session cookie, the
login card is shown. After that, the app shell takes over: a sidebar on the left (a
drawer on phones and tablets, foldable to icons on a desktop), a topbar with search, and
one page at a time.
| Sidebar | Address | What is there |
|---|---|---|
| Dashboard | #/ | today's figures, 7-day sales, what needs attention, recent sales and purchases |
| Sales › New Sale | #/sales/new | the till: barcode + serial scanning, cart, customer, payment |
| Sales › Sales History | #/sales | every invoice; show, print, payment, return, warranty |
| Purchases › New Purchase | #/purchases/new | dealer batches, serial receiving |
| Purchases › Purchase History | #/purchases | every batch bought |
| Products & Stock › Products | #/products | stock, prices, margins; add / edit / delete |
| Products & Stock › Low Stock | #/products/low-stock | under 5 units, with Restock |
| Customers | #/customers | built from invoices, grouped by phone number |
| Suppliers | #/suppliers | built from dealer purchases |
| Expenses | #/expenses | expense list and the add form |
| Serial / IMEI | #/serials | every tracked unit, fetched on arrival |
| Warranty Claims | #/warranty | open and settled claims |
| Faulty & Supplier | #/faulty | faulty in the shop, at the supplier, move history |
| Reports | #/reports/… | sales, profit & loss, stock, expenses — printable |
Some things to know about how the pages behave:
x-show, never x-if. A half-built cart or dealer batch survives a trip to
another page, and the scanner's refs are always in the DOM.focusWhenShown()./api/data already returns, passed through the same invoice and range functions
the Sales page uses. So the report days always add up to the totals above them.<body>, on purpose
(why).| Script | What it does |
|---|---|
npm start | Runs the server. Uses the committed CSS — no build needed. |
npm run dev | Build, then start. Use after pulling changes. |
npm run build | vendor + build:css. Run after npm install. |
npm run build:css | Compiles src/input.css → public/css/app.css, minified. |
npm run watch:css | Same, rebuilding on every save. |
npm run vendor | Copies Alpine and the fonts from node_modules into public/. |
Whenever you add a Tailwind class to index.html or app.js, rerun build:css.
Tailwind only generates classes it can see in those files, so a new class without a
rebuild simply has no styles.
Click Show on any sale to preview the invoice, then Print / Save as PDF. In the browser's print dialog choose Save as PDF as the destination to keep a copy.
The printed sheet is deliberately identical to the on-screen preview — same borders, padding and type sizes. There is no separate print typography to drift out of sync.
Masthead (logo, shop name, address, mobiles, email) · Bill To with the customer's
name and contact number · Invoice Details with invoice number, date DD-MM-YYYY and
time hh:mm AM/PM · a numbered item table with Quantity, Price/ Unit and
Amount · the total spelled out in words · Paid and Due lines with a DUE
or PAID stamp in the corner, on receipts that track payment · terms and an authorised
signature line.
The stamp is outlined, not filled. Print dialogs drop background colours by default, so a filled badge would come out of the printer white-on-white and the shop would only find out from the customer's copy; borders and text colour print either way.
NetBazar-Invoice-<no>-<customer>-<date>.pdf. The
app swaps document.title before opening the dialog and restores it afterwards.This is the part most likely to break if the invoice markup is restructured, so it is
worth understanding. All of it is at the bottom of src/input.css.
The invoice modal is a direct child of <body>. That is not incidental — it is what
makes the whole thing work:
body.receipt-open > *:not(#receipt-modal) { display: none !important; }
Three rules that must not be undone:
display: none, never visibility: hidden. Hidden elements still occupy layout
space and produce blank pages.margin, padding, max-height and overflow are all reset.
A scrollable ancestor clips printed content in Chrome. And note the print viewport for
A4 is ~794px wide, which is above Tailwind's sm: breakpoint — so sm: classes
still apply while printing. An un-reset sm:my-4 was enough on its own to push the
invoice onto a second page.print-color-adjust: exact. Without it browsers drop background fills and the
white text on the green bands prints white-on-white — invisible.The body.receipt-open scope exists so that pressing Ctrl+P with no invoice open does not
emit an empty sheet.
A single-item invoice uses about half an A4 sheet. To halve paper use, change the @page
rule in src/input.css and rebuild:
@page { size: A5 portrait; margin: 8mm; }
The layout already fits; nothing else needs to change.
Every place the shop's identity appears — login card, navbar and the printed invoice —
reads from one file, public/js/shop-config.js:
| Field | Used for |
|---|---|
name | login card, navbar |
invoiceName | the invoice masthead and the signature line |
addressLine1, addressLine2 | the masthead address |
phones | array; joined with commas after Mob: |
email | the masthead email |
terms | array; the Terms & Conditions list, one line each |
Editing that file needs no rebuild — reload the page. The browser tab title in
public/index.html is the one place the name is still literal.
The invoice's own look — the green bands, column widths, borders — is the .inv-* block
in src/input.css; changing it does require npm run build:css. The
logo is an inline SVG in the .inv-logo cell of index.html; to use a real image file,
drop it in public/ and swap the <svg> for an <img>.
One green runs through the whole app. brand-500 is #8cc63f — the exact green on the
printed invoice band — and the scale is built out from it in the @theme block of
src/input.css, which is what makes bg-brand-700, text-brand-800
and friends available as ordinary Tailwind utilities.
brand-500 carries only 2.05:1 against white, so it is never used behind white text.
Interactive surfaces use the dark end of the scale instead:
| Token | Hex | Contrast on white | Used for |
|---|---|---|---|
brand-500 | #8cc63f | 2.05:1 | invoice bands, badges — never white text |
brand-700 | #4f7d1f | 4.9:1 | primary buttons (AA) |
brand-800 | #3d5f19 | 7.4:1 | headings, figures, active tabs (AAA) |
brand-950 | #1a2b0d | 15.9:1 | darkest brand shade |
sidebar | #13210b | — | the sidebar and login background, one step past brand-950 |
canvas | #f5f7f8 | — | the workspace behind the cards |
ink | #172033 | 16.3:1 | headings and figures |
Colour means something. Green is money in and "available", red is danger and money
owed, amber is waiting (low stock, open claims), blue is information, and violet is the
warranty/supplier workflow. Every status badge (.nb-badge) carries a word as well as a
colour. The dealer scan boxes stay amber, so the purchase till never looks like the sale
till.
The patterns every page repeats are written once, as .nb-* classes in the
@layer components block of src/input.css: cards, page headers, buttons
(nb-btn + -primary / -secondary / -ghost / -danger / -danger-soft), inputs and
labels, filter chips (lit by aria-pressed), tables (sticky header, .num right-aligned
tabular figures), badges, empty states, skeletons, dialogs, menus and the sidebar items.
Build a new screen from those rather than restating long utility strings. Icons are one
inline outline set in ICONS in app.js, rendered with x-html="icon('name')", so
nothing is loaded from the network.
The invoice's --inv-green is var(--color-brand-500), so the screen and the paper can
never drift apart.
All of it is the .nb-* block in src/input.css — entrance and
feedback only. Nothing loops except the low-stock ring, and nothing moves while a figure
is being read.
| Class | What it does |
|---|---|
nb-stagger | children rise in sequence — the summary cards, last one landing by ~0.25s |
nb-rise / nb-pop | panels and the invoice sheet settling in |
nb-lift | cards lift 2px under the cursor |
nb-press | buttons give way on press |
nb-row | table rows warm up on hover |
nb-toast | the toast slides up rather than only fading |
nb-loading | a sweep across "Loading…" instead of a blinking block |
nb-alert | a slow red ring, only while something is actually low on stock |
Two safeguards, both at the bottom of input.css and both worth keeping:
prefers-reduced-motion: reduce collapses every animation to its finished state.
It does not remove them — an entrance that got removed instead of completed would leave
content invisible.transform
and opacity. printReceipt() fires two animation frames after the modal opens, which
is sooner than the 0.28s entrance finishes; without the reset a sheet could be captured
half-faded or a few pixels off.SQLite, created automatically on first run at shop.db next to server.js.
inventory(id, item_name, quantity, cost_price, selling_price, barcode, warranty_months,
track_serial, pre_serial_quantity)
invoices(id, customer_name, customer_contact, date, sale_time, comment, paid_amount)
sales(id, invoice_id, line_no, customer_name, customer_contact, item_name, quantity,
total_price, date, sale_time, warranty_months, cost_price, list_price, comment,
serial_no, returned_date)
dealer_purchases(id, dealer_name, item_name, quantity, total_cost, date)
product_serials(id, product_id, serial_no, status, purchase_id, sale_id, invoice_id,
received_date, received_time) -- one row per physical unit
serial_events(id, serial_id, event, date, time, invoice_id, purchase_id, note)
expenses(id, category, amount, note, date, created_time)
admin(id, email, password)
app_meta(key, value) -- holds schema_version
CREATE UNIQUE INDEX idx_inventory_barcode ON inventory(barcode)
WHERE barcode IS NOT NULL AND barcode != '';
CREATE INDEX idx_sales_invoice ON sales(invoice_id);
CREATE INDEX idx_expenses_date ON expenses(date);
CREATE UNIQUE INDEX idx_serial_no ON product_serials(serial_no COLLATE NOCASE);
CREATE INDEX idx_serial_product ON product_serials(product_id, status);
CREATE INDEX idx_serial_events ON serial_events(serial_id);
Expenses are the shop's running costs — rent, electricity, salary, tea,
transport — and deliberately not stock buying. Goods bought for resale go through
dealer_purchases and are already inside each sale line's snapshotted cost_price,
so recording them as an expense too would subtract them from profit twice. Nothing in
the schema can enforce that; the form says it plainly and warns when a head looks like
stock buying. category is free text with no table behind it: the form offers a
datalist of heads already used. date is the day the money went out and is editable;
created_time records when the row was entered and never changes.
An invoice is one money receipt; its items are rows in sales, joined by
sales.invoice_id and numbered by line_no. The Invoice No. printed on a receipt is
invoices.id. Customer, date and time are repeated on each line so sales reads sensibly
on its own, but the invoice row is the source of truth, and comment lives on the invoice
only.
Due money. invoices.paid_amount is what the customer actually handed over; the rest
becomes the due by itself — the shopkeeper only ever types what was paid. The sale form's
Paid field is blank for an ordinary cash sale and the client sends the cart total, so
a full payment costs no typing. The receipt
is stamped DUE or PAID in the corner, and prints Paid and Due lines under the
total. When the customer settles up later, the Paid button on the invoice row raises
paid_amount to the total and the same receipt becomes a paid one — the Invoice No. the
customer holds keeps meaning what it meant. PUT /api/invoices/:id/payment is the only
write path to an invoice, and it touches that one column; it re-sums the lines itself and
rejects a payment above the total rather than clamping it.
paid_amount is nullable, and NULL means "issued before the shop tracked dues" — not
"nothing paid". Those invoices read as settled, are left out of the Due total, and print
no stamp: the app does not know, so it does not put a claim on paper it cannot stand
behind. NOT NULL DEFAULT 0 would have declared the whole existing ledger outstanding.
The Customer Sales History has All / Due / Paid chips next to the date presets — the two combine, so "everything still owed, all time" is one chip from each row — and a Due tile totalling what is out on credit over the range. Due is not subtracted from profit: the goods left the shop and the sale is real.
Sales recorded before invoices existed were each wrapped in an invoice of their own on the first start after upgrading, keeping their original number — so a receipt already handed to a customer still matches. New invoices continue numbering after the highest old one.
date columns hold YYYY-MM-DD text and sale_time holds HH:MM (24-hour); both are
local wall-clock values, never UTC. Money is REAL, and only the line total is stored:
Price/ Unit on the invoice is total_price / quantity.
sales.warranty_months, sales.cost_price and sales.list_price are copied from the
product at the moment of sale rather than joined at render time. Restocking a product
at a new price, or changing its warranty, must never rewrite an invoice already issued or
profit already earned — the customer's paper copy and a reprint have to agree.
All three are nullable, and the distinction matters: NULL means unknown (recorded
before this existed, or an item that was never in inventory), while 0 means known to be
zero. Profit calculations skip NULL rows rather than treating them as costing nothing,
and the dashboard says so instead of quietly understating the figure.
sales.comment is an optional private note. It shows in the sales history and is
deliberately never rendered on the invoice.
inventory.barcode is TEXT, not INTEGER — an integer column would eat the leading
zeros off an EAN-13, and Code 39 is alphanumeric. Uniqueness comes from a partial unique
index, because SQLite cannot ADD COLUMN ... UNIQUE. The WHERE clause is what lets
many barcode-less products coexist: NULLs are already distinct in a unique index, but a
second empty string would collide. Blank input is normalised to NULL server-side too.
A PRAGMA index_list(inventory) line is logged at boot. That is the only cheap proof the
index exists — without it, a failed creation would be invisible until two products ended
up sharing a code. Don't remove it.
A barcode says what a product is; a serial says which unit. Products with serials
(fans, routers, phones) are serial-tracked: every unit the shop holds is a row in
product_serials, and the product's stock is simply the number of those rows that are
available. Everything else — cables, bulbs, small goods — never gets a serial and works
exactly as before: typed quantity in, quantity - n out, no serial box ever asked for.
There is no setting to switch it on. A product becomes serial-tracked
(inventory.track_serial = 1) the moment its first serial is scanned in. From then on
syncSerialStock() in server.js recounts its quantity inside every
transaction that changes a serial, and a typed quantity is ignored. Undoing a product's
only serial puts it back to untracked with the count it had before
(pre_serial_quantity), so a cable given a serial by mistake is not stuck.
Receiving (New Purchase page): scan the product barcode, then scan each unit's
serial. Each scan is saved the moment it is scanned (POST /api/serials/receive) — there
is no batch to lose and no Finish to forget. The first scan of a batch creates its
dealer_purchases row; each later scan adds one unit and its cost to that row, so the
purchase history always equals what was scanned. Scanning another product's barcode into
the serial box switches product. ✕ on a scanned unit takes it back out.
Existing stock is converted from the product's Edit screen: scan every unit on the shelf into Serials in stock. No purchase is recorded for those — they were bought long ago. A new product can take its serials on the Add Product screen the same way; they are held in the form and saved with the product in one transaction.
Selling: scan the product barcode (focus jumps to the serial box), then its serial. The
till asks the server about each serial as it is scanned (GET /api/serials/lookup) and
refuses, with a beep and a specific message, a serial that:
A serial scanned with no product waiting adds its own product, so the barcode scan is
optional for serial goods. The server checks all of this again when the invoice is saved,
inside the same transaction that marks the unit sold, and the status = 'available' guard
on that update means two tills can never sell one unit.
One unit per line. A serial-tracked product never merges into an existing line; each unit is its own qty-1 line. A line reading "qty 3, serials SN-34 / SN-35 / SN-36" cannot say which piece cost what once a discount lands on it, and a warranty claim is about one piece.
Serial / IMEI page lists every unit — search by serial, product or customer (a scan works), filter by product and status. Each row shows where the unit came from, and for a sold one the invoice (click to open the receipt) and customer. History shows every event: received, sold, returned, sold again. Return puts a sold unit back in stock and marks its sale line returned (printed on the receipt as returned). A return does not refund money or change the invoice total — see Known limitations. Remove takes out an available unit that was mis-scanned.
The serial number is unique across the whole shop (case-insensitive), which is what
guarantees a serial is registered once and belongs to one product. sales.serial_no stays
on the line as the printed snapshot, like cost and warranty.
The character set is wider than a barcode's — [0-9A-Za-z/._-]{1,64} — because serials
printed on a carton carry dots, slashes and underscores often enough that rejecting them
would mean retyping what was just scanned. Spaces stay out: a scanner emits none, so a
space means two codes ran together. A product barcode is never accepted as a serial.
Scanner comfort: a high beep for a good scan and a low double beep for a refusal, so the cashier need not look up; the same code twice in one box within half a second is ignored as a double-fire.
Existing databases are migrated in place on startup by migrate() in
server.js — no manual step, no data loss. This includes the hosted Turso
database: deploying new code is all it takes.
A database already at the current schema_version (in app_meta) skips the migration
with a single read, so a normal cold start does not run twenty statements against the
hosted database. Bump SCHEMA_VERSION in server.js whenever you add a migration
step, or databases already stamped with the old version will never run it.
On serverless, several cold instances may migrate at once. Every step is idempotent, a
locked database is retried with jittered backoff rather than failing, and a setup error is
retried on the next request instead of crashing the process. It is awaited, not
callback-driven, and that is load-bearing: node-sqlite3 defaults to parallelize mode, so
the barcode index would otherwise be issued before the column it references existed. On
first run, if admin is empty, an account is created from ADMIN_EMAIL and
ADMIN_PASSWORD with the password bcrypt-hashed. If those are unset, no account is
created and the app says so at boot rather than seeding a guessable default.
Items are linked by their item_name string. There are no foreign keys, so renaming a
product cascades the new name through sales and dealer_purchases in a transaction —
see PUT /api/inventory/:id. Name uniqueness is enforced in application
code (case-insensitively) rather than by an index, because a UNIQUE ... COLLATE NOCASE
index could silently fail to create on a database that already held a case-variant pair.
Recording a dealer purchase sets the product's buying and selling price from the form, so
restocking updates prices rather than freezing them at whatever the first purchase
implied. Earlier sales keep their own snapshotted cost_price, so correcting a price never
changes profit already booked.
All endpoints are JSON. None of them require authentication — see Going live.
| Method | Path | Body | Returns |
|---|---|---|---|
POST | /api/login | {email, password} | {success, message}; 401 if wrong |
POST | /api/reset-password | {email, new_password} | {success, message} |
GET | /api/data | — | {inventory, invoices, sales, purchases, expenses} — sales are invoice lines |
POST | /api/sales | {customer_name, customer_contact?, date, comment?, paid_amount?, items: [{item_name, quantity, total_price, serial_no?, serial_override?}]} | {id} (invoice); 400 if paid_amount exceeds the total, or a serial repeats on the invoice; 409 if a serial-tracked line's serial is missing, sold, another product's, or unregistered without serial_override |
PUT | /api/invoices/:id/payment | {paid_amount} — null/'' clears due tracking | {id, paid_amount}; 400 invalid, 404 if gone |
POST | /api/dealer | {dealer_name, item_name, quantity, cost_price, selling_price, date, barcode?, warranty_months?} | {id}; 409 for a serial-tracked product |
POST | /api/serials/receive | {serial_no, purchase_id?, dealer_name, item_name, cost_price, selling_price?, barcode?, warranty_months?, date?} — or {serial_no, product_id} from the product screen | {purchase_id, serial, product}; 409 if already registered |
DELETE | /api/serials/:id | — | {success, product}; 409 if sold |
GET | /api/serials/lookup?code= |
POST /api/sales records one invoice. The body carries the customer, date and
optional comment, plus items: [{item_name, quantity, total_price, serial_no?}, …] — up
to 100 lines, where total_price is the line amount and serial_no is the piece's own
serial where it has one. Everything is written in one transaction:
the invoice, every line, and every stock decrement succeed together or not at all, so a
failure on one line cannot leave the others sold with no receipt. Lines are validated
before anything is written, and errors name the line. A body with a single top-level
item_name (a page cached from before invoices) is accepted as a one-line invoice.
It stamps sale_time itself from the server clock — a client-sent value is ignored
outright, since the browser's copy is stale the moment the form sits open. Each line
snapshots its product's cost, list price and warranty. A backdated date still gets
today's clock time. Returns the invoice id.
POST /api/dealer adds stock and writes the prices onto the product, creating it if it
does not exist. total_cost on the purchase row is derived as quantity × cost_price. A
page cached from before this change still posts total_cost, and the unit cost is derived
from it rather than rejected.
PUT /api/inventory/:id returns renamed: the number of sales and dealer_purchases
rows whose item_name was updated to follow the rename. The rename runs in a
BEGIN IMMEDIATE transaction and rolls back as a unit. This is the one place where an
already-issued invoice's text does change — deliberately, because a rename is a
correction (the typo was on the customer's copy too), unlike a price or warranty change,
which is a new decision that must not rewrite a promise already made.
DELETE /api/inventory/:id removes only the stock record. Sales and purchases keep
their own item_name and reprint unchanged. It is allowed even when history references the
product, because the commonest reason to delete is a typo'd item auto-created by a dealer
purchase — which always has history attached.
Authentication is now in place — see DEPLOY.md for putting this online
with Turso and Vercel. Every /api route requires a signed session cookie, passwords are
bcrypt-hashed, and the first admin comes from environment variables rather than a literal
in the source.
The items below that remain unticked are still worth doing before a public deployment.
None of these are theoretical. Each was confirmed against the running app.
The API requires authentication. isLoggedIn is a JavaScript variable in
the browser; the server issues no session and checks nothing. Anyone who can reach
the server can read every sale and customer name with a single
curl http://host:3000/api/data. Fix: issue an httpOnly, secure,
sameSite: 'lax' session cookie on login (express-session), and put auth
middleware in front of /api/data, /api/sales and /api/dealer.
The public password-reset endpoint is gone.
POST /api/reset-password (server.js:213) changes the password
with no verification of any kind — no old password, no email confirmation, no token.
Fix: delete the public reset endpoint and replace it with a change-password form
behind the login that requires the current password. If self-service reset is
genuinely needed, it must go through an emailed single-use token.
Passwords are bcrypt-hashed
(server.js:190, server.js:203). Anyone who gets
the shop.db file gets the password. Fix: hash with bcrypt
(cost 12). Migrate the existing password on the owner's next successful login.
The default credentials are no longer printed on the login page. The hint
The hint that used to read Default: [email protected] / admin123 has been removed
from the login card.
The default admin is no longer seeded from a literal — set ADMIN_EMAIL and ADMIN_PASSWORD, then change the password from the dashboard after first login.
Put nginx or Caddy in front as a reverse proxy with a Let's Encrypt certificate. Session cookies marked require it. Then in .
PORT in the environment; it defaults to 3000.app.listen(PORT) listens on every interface. Behind a reverse
proxy, use app.listen(PORT, '127.0.0.1') so only the proxy can reach Node directly.NODE_ENV=production.systemd
unit, or pm2 start server.js --name netbazar.npm ci && npm run build. If you deploy only production
dependencies (npm ci --omit=dev), the Tailwind CLI will not be present — so either
build on a machine that has the dev dependencies, or rely on the committed
public/css/app.css, public/vendor/ and public/fonts/, which are enough for
npm start on their own.shop.db — it must be writable by the service user
and readable by nobody else (chmod 600)./healthz endpoint for uptime monitoring.public/ — the CSS, fonts and Alpine bundle never change
between deploys and can be cached for a long time.morgan) to a file the process manager rotates.quantity and prices are trusted as sent, so a
negative or non-numeric quantity is accepted today.shop.db is your entire business record and is gitignored, so it is never in a commit.
Nothing else in this repository is irreplaceable — this file is.
Back it up with SQLite's own copy command, which is safe to run while the server is live
(copying the file with cp while a write is in progress can produce a corrupt copy):
sqlite3 shop.db ".backup '/path/to/backups/shop-$(date +%F).db'"
If the sqlite3 command is not installed — it usually isn't on a plain shop PC — use
the driver already bundled with the app. VACUUM INTO is the same safe online copy:
node -e "new (require('sqlite3').Database)('shop.db').run(\"VACUUM INTO '/path/to/backups/shop-$(date +%F).db'\")"
Either way, verify the copy rather than assuming it worked:
node -e "new (require('sqlite3').Database)('BACKUP.db').get('PRAGMA integrity_check',(e,r)=>console.log(r))"
Run it from cron daily and keep the copies somewhere other than the shop's computer.
Test a restore at least once — an untested backup is not a backup.
Deliberate scope choices, not defects. Worth knowing before you build on this.
paid_amount is one running figure that is edited
in place, so the shop sees what is still owed but not when each instalment came in, and
raising it leaves no record of the previous amount.PUT /api/invoices/:id/payment. Products can be edited, but a mistyped invoice still
has to be corrected directly in the database — there is no PUT/DELETE for /api/sales.cost_price, so they are skipped rather than counted as pure profit. The
dashboard says when that is happening.server.js:300); the quantity just goes below zero.The page loads but looks completely unstyled
public/css/app.css is missing or empty. Run npm run build.
A style I just added does nothing
Tailwind only generates classes it finds in index.html and js/app.js. Run
npm run build:css, and check you did not edit public/css/app.css directly — it is
overwritten on every build.
The invoice prints on two pages
Check the print rules in src/input.css are intact, especially the margin: 0 !important
on .modal-card. Remember sm: classes still apply while printing. Also confirm the
invoice modal is still a direct child of <body> in index.html — if it gets moved back
inside the dashboard, body.receipt-open > *:not(#receipt-modal) stops matching and the
whole dashboard prints too.
The invoice prints with white boxes where the green bands should be
print-color-adjust: exact was lost, or "Background graphics" is unticked in the browser's
print dialog.
Bengali text prints as boxes
The font files are missing from public/fonts/. Run npm run vendor.
I forgot the admin password
The password is stored as a bcrypt hash, so it cannot be recovered, only replaced. Run
this from the project folder on the shop PC, with your new password in place of
NewPassword123 (8 characters at least):
node --input-type=module -e "
import { createClient } from '@libsql/client';
import bcrypt from 'bcryptjs';
const db = createClient({ url: 'file:shop.db' });
const r = await db.execute({ sql: 'UPDATE admin SET password = ? WHERE email = ?',
args: [bcrypt.hashSync('NewPassword123', 12), '[email protected]'] });
console.log('rows updated:', r.rowsAffected);"
rows updated: 1 means it worked. Copy shop.db somewhere safe first. ADMIN_PASSWORD in
the environment will not help here: it is only read when no admin exists yet.
I get logged out unexpectedly
The login state lives in localStorage under shop-session. It survives refresh, new tabs
and browser restarts, but is cleared by Logout, by clearing site data, and in private
windows. This is a UI convenience only — it is not a real session, and it will be replaced
by a session cookie when authentication is added.
The database looks empty after moving the app
shop.db is resolved relative to server.js, so it travels with the folder. If you moved
only server.js, the database was left behind.
| — |
the serial with product and invoice; 404 if unregistered |
GET | /api/serials?q=&product_id=&status=&limit=&offset= | — | {rows, total} |
GET | /api/serials/:id/history | — | {serial, events} |
POST | /api/serials/:id/return | {note?} | {success, product}; 409 if not sold |
POST | /api/expenses | {category, amount, note?, date?} | {id}; 400 invalid |
PUT | /api/expenses/:id | same as POST | {success}; 404 if gone |
DELETE | /api/expenses/:id | — | {success}; 404 if gone |
POST | /api/inventory | {item_name, quantity, cost_price, selling_price, barcode?, warranty_months?, serials?: [..]} — with serials, stock is their count | {id}; 400 invalid, 409 duplicate barcode, name or serial |
PUT | /api/inventory/:id | same as POST | {success, renamed}; 404/400/409 |
DELETE | /api/inventory/:id | — | {success}; 404 if gone. Its serials go too |
secureapp.set('trust proxy', 1)server.js Rate-limit the login endpoint (express-rate-limit) so the password cannot be
brute-forced.
Add helmet for baseline security headers.
/api/data returns every row every time. Fine for
thousands of sales; revisit at tens of thousands. The UI already pages at 25 rows.