Zinfurn — Furniture E-commerce Marketplace
Live: zinfurn.uz · API: GraphQL @ api.zinfurn.uz · Backend repo: Zinfurn · Mobile app: zinfurn-app
A production-deployed furniture marketplace: catalog browsing with rich filters, ordering with coupons, a repair-service vertical, agent profiles, community articles, an AI shopping assistant, an AI room designer, native mobile AR placement, and a full admin panel — in 5 languages, on a hand-built dark/light theme system.
Try it
| Demo login | DEMO_NICK / DEMO_PASSWORD — regular customer account (orders auto-advance to delivered in ~1 min) |
| Payments | Toss sandbox — any test card, no real charge |
| Android | APK on zinfurn.uz/about#app |
Features
Storefront
- Product catalog with faceted filters (category, type, material, color, price, condition), typo-tolerant live search, list/grid view modes
- Flash Sale section driven by a server-side "active sale" filter with live countdowns
- Cart → 3-step checkout with coupon codes (percent/fixed, usage limits, expiry — the discount is computed server-side, never trusted from the client)
- Order lifecycle: pending → processing → shipped → delivered → confirmed, plus return requests
- Telegram order notifications — customers with a linked Telegram account get a bot message on every status change; admins get a new-order alert
- Purchase-gated reviews and ratings, favorites, recently viewed, agent following, real-time chat (WebSocket) and notifications
- Agents manage their own listings, including an in-stock / sold-out toggle that reflects instantly on every card
AI
- Live community chat personas — AI members (marked with an "AI" badge) keep the WebSocket chat active with short, casual threads that reply to each other, and answer real users; rate-limited to 15 messages/hour and only active while someone is online.
- Shopping assistant (
pages/api/ai-chat.ts— Groq ·openai/gpt-oss-120b, Geminigemini-3.6-flashfallback) grounded in the live catalog: it recommends real products as clickable cards and navigates the site through a whitelisted action set, so the model can never invent a route or a product it hasn't been given. - AI Room Designer (
analyzeRoom/generateRoomImageGraphQL mutations, Gemini vision): the customer uploads a photo of their room and optionally describes what they want. The model returns a structured reading of the space — room type, dominant colors, suggested material, requested furniture type, a search keyword — every field validated against the project's own enums before it touches the database. Those constraints then drive a normal catalog query, so the recommendations are always real, in-stock products, never hallucinated ones. A chosen product can be composited back into the customer's own room photo through the image model. - Automatic content translation (
translation.service.ts— Groqopenai/gpt-oss-120b, Gemini fallback): product, article and notice content is machine-translated into all five locales on create/update, in one schema-constrained JSON call, with a per-locale retry path if the batch call fails.
AR / 3D
- Native AR placement —
ArLaunchButtonon the product page callsmodel-viewer'sactivateAR()directly inside the user gesture, so Android opens Scene Viewer and iOS opens Quick Look with the real system camera pipeline./ar-viewremains as the interactive 3D preview for desktop and for phones without AR support. - Real-world scale, not magic numbers. Every GLB is normalized through
@gltf-transform/core(libs/utils/normalizeGlb.ts): the mesh is scaled from the product's declared real width in centimetres, rested on the floor plane, and flat "studio floor" meshes emitted by generators are detected by bounding-box flatness and stripped. Because the metres are baked into the asset, AR opens at the correct physical size on the first try. - Image → 3D generation (
pages/api/ar-generate.ts, Meshy image-to-3D): an admin turns an existing product photo into a textured PBR model without leaving the panel. The API key never leaves the server — the browser only ever holds a task id and polls for progress. - Hardened asset pipeline.
pages/api/ar-import.tsfetches the generated GLB server-side (the generator CDN sends no CORS headers) behind an explicit host allowlist, an HTTPS-only check and a 25 MB cap, so the route cannot be turned into an open proxy. Hand-uploaded GLBs take the same path throughar-normalize.ts. Both return raw bytes that the admin panel uploads through the regular validatedmodelUploadermutation — one entry point intouploads/, one place to audit. - Products without their own GLB fall back to six bundled reference models, matched by title keyword and category.
Platform
- i18n:
uz / en / ru / kr / arvia next-i18next; prices follow the active locale (USD / KRW / UZS / RUB / AED) with live exchange rates and a 1-hour cache - Dark/light theming: a single CSS custom-property token system (~40 semantic tokens replacing 270+ scattered hardcoded colors), a no-flash boot script,
prefers-color-schemeas default, and MUI palettes derived from the same tokens - SEO: SSR product list and detail pages (crawlers get real product HTML), sitemap, hreflang, JSON-LD
- Responsive: a dedicated mobile layout ≤768px; 769–1400px windows scale-to-fit so the fixed-width desktop design never clips
- Admin panel: users, properties, community, CS content, coupons, order management, 3D model generation
Tech Stack
| Layer | Tech |
|---|---|
| Frontend | Next.js 14 (Pages Router) · TypeScript · MUI 5 · SCSS (token-based theming) |
| Data | Apollo Client (GraphQL) + WebSocket subscriptions |
| Backend | NestJS 10 monorepo · Apollo Server (code-first) · MongoDB/Mongoose |
| Auth | JWT (1h access + refresh rotation) · Google OAuth · Telegram OAuth |
| AI | Groq (Llama 3.3 70B · gpt-oss-120b) · Google Gemini (text, vision, image) |
| 3D / AR | <model-viewer> (Scene Viewer + Quick Look) · @gltf-transform/core · Meshy image-to-3D |
| Infra | Docker on VPS · Nginx + SSL · cron-based auto-deploy on push to main |
Security Highlights
- Brute-force protection: global + per-endpoint rate limiting (login/signup 5/min, GraphQL-aware throttler guard behind
trust proxy) - Token pair: short-lived access token with refresh rotation; refresh tokens carry a
tokenTypeclaim that is rejected as an access credential; blocked members cannot refresh - Server-authoritative money math: coupon validation and redemption are atomic (race-safe usage limits) and discounts are recomputed server-side
- Uploads: target-directory whitelist (path-traversal safe), extension pinning, image transcoding through
sharp(a non-image cannot survive re-encoding); model uploads are size-capped and normalized before storage - Outbound fetches are allowlisted: the AR import route accepts only HTTPS URLs on known generator hosts, so no user-supplied URL can make the server fetch arbitrary hosts (SSRF)
- All AI keys are server-side only — Groq, Gemini and Meshy credentials live in
.envand are never exposed to the browser - GraphQL: depth limit, introspection and playground disabled in production
- Nginx layer: HSTS, X-Frame-Options, nosniff, Referrer-Policy and Permissions-Policy on both site and API; helmet on the API
Architecture
flowchart LR
subgraph Clients
W[Browser<br/>Next.js SSR/ISR]
M[Android / iOS<br/>Capacitor shell]
end
subgraph VPS["VPS (Docker, nginx + TLS)"]
N[nginx<br/>TLS · security headers · cache]
F[zinfurn-next<br/>Next.js 14 :3006]
A[zinfurn-api<br/>NestJS GraphQL + WS :3007]
B[zinfurn-batch<br/>cron: ranking, flash sale]
end
DB[(MongoDB)]
EXT[Toss · Google/Apple/Telegram OAuth<br/>Groq/Gemini · Meshy · Resend · FCM/APNs]
W & M --> N
N --> F & A
F -- GraphQL --> A
A & B --> DB
A --> EXT
CI/CD: GitHub Actions runs lint + tests + production build on every push/PR; a pre-push hook repeats the build locally, and the server cron pulls main and redeploys the container.
zinfurn/
├── zinfurn/ # NestJS monorepo
│ ├── apps/zinfurn-api/ # GraphQL, WebSocket, OAuth, AI room, translation (:3007)
│ └── apps/zinfurn-batch/ # Cron jobs (monthly ranking)
└── zinfurn-next/ # Next.js (:3006)
├── pages/ # Routes (+ /_admin panel, /api AI & AR routes)
├── libs/components/ # Feature components (ar/, aiRoomDesigner/, property/, …)
├── libs/utils/normalizeGlb.ts# Real-world GLB normalisation
├── apollo/ # Client, queries, reactive stores
└── scss/ # pc/ + mobile/ splits, theme tokens
Deploys are boring on purpose: push to main → the VPS cron detects the new commit within a minute → git reset --hard + container recreate → runtime build. A committed pre-push hook builds the project locally in an isolated dist dir, so a broken build can never reach main.
Performance
Measured on the production homepage with Lighthouse 12 (median of 3 runs, default throttling, 2026-10-29).
| Change | Metric | Before | After |
|---|---|---|---|
8b78cd5 lighter bundle (lazy sweetalert2/overlays, no animate.css/axios in _app, StarRating instead of MUI Rating) |
JS shared by all pages | 365 kB | 303 kB (−17%) |
| Homepage first-load JS | 400 kB | 367 kB | |
743cc73 hero slide class rendered on the server + <link rel=preload> for the LCP image (it was a CSS background only discovered after hydration) |
Mobile LCP | 14.6 s | 4.6 s |
| Mobile performance score | 53 | 66 | |
| Desktop LCP / score | 5.1 s / 58 | 3.1 s / 65 |
Accessibility 86–88 · Best practices 93 · SEO 100 · CLS ≈ 0. Server render time is ~5 ms (cached ISR); the remaining ~1 s TTFB is network latency to the Tokyo VPS, so a CDN is the next lever.
Known Limitations
Being upfront — these are conscious trade-offs, not blind spots:
- Payments run in test mode only. Toss Payments (KRW) is wired end-to-end — server-side confirm plus a cron that expires or reconciles unpaid orders — but uses sandbox keys; local PSPs (Payme/Click) are not integrated yet.
- Demo order progression. Order statuses auto-advance (pending→delivered in ~1 min) so reviewers can see the full lifecycle without a warehouse. The next step time is stored on the order and a 10-second cron advances it, so progression survives restarts; in a real deployment this would be replaced by admin/ops updates.
- JWTs live in
localStorage. No Content-Security-Policy yet (third-party scripts — Toss, analytics, Telegram widget, model-viewer — need an allow-list first), so XSS hygiene relies on React escaping and sanitised inputs. An httpOnly-cookie migration is planned but touches WebSocket auth and both OAuth flows, so it is deliberately a separate change. - Refresh tokens are single-use and server-tracked. Each session's current
jtilives in the database: logout, password change or blocking a member revokes it immediately, and reuse of an old token closes the session. Tokens still live inlocalStorage(see 3). - Test coverage is focused, not exhaustive. Unit tests cover the money and trust paths — auth/session tokens, order pricing, Toss reconciliation, demo progression, coupons and purchase-gated reviews (81 tests, run in CI); other modules rely on typed contracts and manual E2E passes.
- ~60s deploy window. Runtime-build deploys briefly 502; an earlier zero-downtime attempt was reverted for env-injection reasons and revisiting it needs build-arg plumbing.
- Single-instance assumptions. Rate limiting and AI-chat state are in-memory, so horizontal scaling would need Redis first.
- 3D coverage is partial. Most of the catalog is photos only; products without their own GLB fall back to the bundled reference models. Filling the catalog is a content problem, and the generation pipeline exists precisely to grind it down.
- iOS AR needs USDZ for the best result. Quick Look accepts the GLB path, but a per-product
ios-srcUSDZ would render more faithfully; conversion is not yet part of the pipeline. - Generated models are review-gated. Image-to-3D output quality varies with the source photo, so every generated GLB is checked by an admin before it is attached to a product — the pipeline assists, it does not publish on its own.
Roadmap
A pragmatic, value-ordered plan — what turns this from a polished showcase into a revenue-generating marketplace. Grouped by impact, not by effort.
🔴 Revenue & trust — ship first
These directly unblock money and buyer confidence.
- Live local payments (Payme · Click · Uzum). Payme/Click are wired as a demo checkout today; promoting them to production (server-side confirm + webhook reconciliation) is the single highest-value step for the UZ market, replacing the Toss sandbox.
- Delivery & logistics. Region-based shipping cost, estimated delivery date at checkout, and courier tracking numbers surfaced on the order timeline.
- Seller dashboard. Per-agent analytics — views → order conversion, revenue, best-selling products, low-stock warnings — so agents have a reason to list and restock.
- Abandoned-cart recovery. Telegram/email nudge when a cart is left unpaid; typically recovers a meaningful slice of lost orders with infrastructure that already exists (notifications are built).
🟡 Conversion & retention — grow the numbers
- Recommendations & upsell. "Related products", "frequently bought together", and recently-viewed-driven suggestions to lift average order value.
- Loyalty points & tiers.
memberPointsalready lives on the member schema — spend/earn rules + checkout redemption turn it into repeat-purchase fuel. - Back-in-stock & price-drop alerts. Favorites already capture intent; notify the user when a saved item restocks or drops in price.
- Promoted / featured listings. Let agents pay to boost placement — a clean marketplace monetization channel on top of order flow.
- Full returns & refund workflow. Return requests exist; close the loop with approve → refund → restock states.
🟢 Professional polish — the ideal build
- PWA + web push. Installable app shell and push notifications (order updates, alerts) without a separate native build.
- Per-product iOS USDZ for higher-fidelity Quick Look AR (today iOS reads the GLB path).
- Performance budget. Lighthouse 95+ target, image CDN, route-level code splitting, and LCP/CLS tracking on the catalog and detail pages.
- Accessibility (WCAG AA). Keyboard navigation, focus states, and screen-reader labels across catalog, cart, and checkout.
- Product analytics. Funnel instrumentation (catalog → cart → pay) so roadmap priorities are driven by real drop-off data.
Credits & Third-Party Work
- three.js (MIT) — via
<model-viewer>, which is vendored as a standalone bundle because its ESM build pins a different three.js version than this app. <model-viewer>(Apache-2.0) — Scene Viewer / Quick Look entry point and 3D preview.- @gltf-transform/core (MIT) — server-side GLB normalisation.
- Meshy — image-to-3D generation API.
- The six bundled
.glbstand-in models inpublic/models/come from cynthiachiu/3D-WebXR-Furniture (MIT). An earlier version of this project also started from that repo's WebXR hit-test approach; that implementation has since been replaced end-to-end by the Scene Viewer / Quick Look pipeline described above, because WebXR renders the ARCore feed at a resolution no client-side setting can fix, and it does not exist on iOS at all.
Running Locally
# backend (needs MongoDB + .env, see .env.example)
cd zinfurn && npm i && npm run start:dev # :3007
# frontend
cd zinfurn-next && yarn && yarn dev # :3006
Key env vars: REACT_APP_API_URL, REACT_APP_API_GRAPHQL_URL, REACT_APP_API_WS, GROQ_API_KEY, GEMINI_API_KEY, MESHY_API_KEY (image→3D), and on the backend SECRET_TOKEN, MONGO_DEV, plus the OAuth credentials.
Author: Asadbek Khusanov · Telegram
