Gisheri
Gisheri sells handmade stone bracelets and pendants, and the whole product is bilingual down to the content model: every product, collection and zodiac sign stores its Georgian text beside its English, and Georgian is the default. We built it end to end — a Django 5 + Ninja API with JWT auth, an audit trail and a role-gated admin surface, and a React 19 storefront and console on top of it.
Year: 2026 · Category: e-commerce
Our role
Full-stack. We designed the data model, the discount and order pipeline and the two-layer catalogue i18n, and built the whole front end.
The problem
A jewellery workshop making stone bracelets by hand had a catalogue that lived in a spreadsheet and an order book that lived in a phone. New designs were announced on Instagram, prices were typed back at customers from memory, and how many of a piece were left was whatever the person answering the phone remembered. Nothing recorded what a returning customer had bought before, so the shop met every regular as a stranger.
The shop's customers do not shop by product name either. They arrive asking what suits a Scorpio, or for something to calm a friend who is anxious, or for a gift that carries a meaning — questions a list of names and prices cannot answer. They also arrive reading Georgian, and every piece therefore has to be described twice, in both languages, by the person who made it, before it can be sold to either kind of visitor.
The approach
The catalogue is the product, so the catalogue is what the admin owns outright. A piece carries its stones, its purposes, its zodiac signs and its price, and every screen that sells it — the shop's facets, the collection pages, the twelve zodiac pages, the quiz — is a query over those fields rather than a list somebody curated by hand. Adding a stone to a sign, or a bracelet to a collection, is a form in the console, and for collections, zodiac pages, the quiz and site settings the storefront's cache is invalidated in the same session, so the change is visible without a deploy.
Every translated field is a column pair, not a translation file. A product stores its Georgian name beside its English one, because a translation the shop has to write is data and belongs where the data is; only the fixed UI chrome stays in i18next. The resolver is one documented order — the Georgian column, then the i18n key for seeded content, then canonical English as the floor — so a piece nobody has finished translating degrades into something readable rather than a blank.
The order pipeline is deliberately small: no shipping calculator, no tax engine, one discount code. What it does do carefully is the part that costs money when it is wrong. The code is re-validated at order creation under a row lock instead of being trusted from the cart, so a code that was live a second ago can still be refused and the usage cap cannot be raced; every line snapshots the name, image and price it sold at; and every admin hand that touches an order or a customer leaves an audit row behind it.
Highlights
- Georgian is the default language, and the catalogue resolves it in two layers: a database column per translated field for anything an admin writes, and an i18n key for the content that shipped with the app, with canonical English as the floor.
- The admin console is a full second surface inside the same SPA — eighteen pages, role-gated at the route and again at the API, sharing one sortable table, one date-range hook and one activity feed.
- Discounts are re-validated atomically at order creation, not just at the cart: a code that passed the check can still be refused a second later, and the order create path is the only thing that moves the usage counter.
- Order lines are snapshots — name, image and unit price are frozen at the moment of sale, so a later price edit never rewrites history, and the admin item editor recomputes the totals without re-evaluating the discount.
- Every admin change to an order — and every role or activation change to a customer — writes an audit row, and both of those pages render their own feed from it: who changed what, when, and from what to what.
- The quiz scores the live catalogue against a zodiac sign, a mood and an intention rather than a curated answer key, and all four question banks are admin-editable.
- SEO is data: a settings singleton supplies the defaults and a per-path override table beats them, both edited from the console rather than deployed.
Architecture
- Storefront: React 19 with TanStack Query over the four catalogue reads and plain component state everywhere the state is genuinely local. The shop's filters — purpose, sign, gender, price, sort, page — live in the URL, so a filtered view is a link, and every facet count is computed against the live set so a dead filter is visibly dead.
- Admin console: Eighteen lazy-loaded pages behind one role gate, sharing a single sortable, paginated table, one date-range hook and one activity feed. It is a second surface inside the same bundle as the shop, so an order placed at checkout is in the order book on the next read.
- Catalogue i18n: Two layers and a documented resolution order: the translated database column, the i18n key, then canonical English. The active language is read off the i18next instance rather than threaded through props, so a component rendering a product name needs to know nothing about any of it.
- Orders and discounts: One create path serves customer checkout and staff phone orders. It re-validates the code under `SELECT FOR UPDATE` and enforces the usage cap there rather than at the cart, snapshots every line at the price it sold for, and lets the admin item editor recompute the totals without re-evaluating the discount — so an amended order keeps the deal it was given.
- Content and SEO: A settings singleton holds the hero copy, the banner, the featured collections and the SEO defaults, and a per-path override table beats it. Both are edited from the console, so the front page and the meta tags are data rather than a deploy.
- Backend: Seven Django apps and 12 models on Postgres, with django-ninja routers mounted twice — a public router and an admin router per app — JWT from django-ninja-jwt, camelCase on the wire, and money serialised as decimal strings, so no price passes through a float anywhere on the server or the wire.
- Deploy: Docker Compose: Postgres, Django, Vite, with the migrations committed. One command from a clone to a seeded shop you can sign in to.
In numbers
API endpoints: 68 · Django models: 12 · Front-end lines: 18k · Languages: 2
Results
- The catalogue moved out of a spreadsheet: adding a product, a collection or a zodiac description became an admin action rather than a deploy.
- Georgian stopped being an afterthought — the shop reads natively in its own market and falls back to English only where nobody has written the Georgian yet.
- Phone orders and web orders became the same object, with the same discount rules, the same snapshots and the same audit trail.
- A discount code cannot be over-redeemed by two people checking out at once, because the cap is enforced where the order is written rather than where the code is offered.
- Staff can see, for any customer, what they have spent and when they last ordered, without leaving the order in front of them.
What is used
- Backend: Python 3.12, Django 5.2, Django Ninja 1.3, django-ninja-jwt 5.3, Pydantic (camelCase wire schemas), django-cors-headers, Pillow
- Data: PostgreSQL 17, psycopg 3 (binary), committed migrations, JSONField for tag arrays and quiz banks, DecimalField for all money
- Front end: React 19, TypeScript 5.8, Vite 5.4, Tailwind CSS 3.4, shadcn/ui on Radix, TanStack Query 5, React Router 6.30, React Hook Form 7 + Zod 3, i18next 25 (KA/EN), framer-motion 12, fuse.js 7, sonner
- Tooling and infrastructure: Docker Compose, Bun 1, pytest + pytest-django, Vitest 3 + Testing Library, ruff, mypy strict + django-stubs, ESLint 9, OpenAPI at /api/docs
The demo
The real front end with its Django backend replaced by an in-browser mock: seed data compiled into the bundle, served out of memory behind the same latency and the same error bodies the API would have returned. Every write works — place an order as a customer and it is waiting in the console, audit trail and all — and nothing is persisted, so a reload restores the pristine shop. The banner in the corner signs you in as a customer or as an administrator, through the real login form rather than around it. Unlike the shop it is ported from, the demo opens in English; the switch in the header puts the whole thing, console included, back into Georgian. There is no network request of any kind — no API, no fonts, no analytics — so it runs offline and in an iframe.