parity(docs): Phase 0 inventory, mapping and draft PROTECTED list

Read-only inventory of all 7 projects (3 reference + 4 target) produced by
parallel subagents, every claim cited path:line. No source code changed.

- 00-INVENTORY.md  consolidated mapping: stack, backend, admin, user apps
- PROTECTED.md     draft protected list (awaiting user confirmation)
- ISSUES.md        26 logged issues (security, money/stock, validation, dead code)
- CHANGELOG.md     change log scaffold
- PROGRESS.md      resumable state + Q1-Q5 blocking Phase 1
- _inv-*.md        7 raw per-project inventories (8111 lines)

Key finding: the reference is a gold-jewellery B2B/B2C e-commerce system, not a
gold bank. Buy/sell, deposit/withdraw, transfer, KYC levels, payment callbacks,
cron and websockets do not exist in it, so they cannot be parity gaps.

[ref: henza_server, henza_admin, henza_pwa]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-21 18:09:46 +03:30
co-authored by Claude Opus 5
parent a743f6f93c
commit 71192ba2ba
12 changed files with 8592 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
# 00 — INVENTORY & MAPPING (Phase 0)
Read-only inventory of all four reference/target pairs, plus the mapping tables
that drive Phase 1 and Phase 2.
**Method:** seven parallel read-only subagents, one per project, each citing
`path:line`. Their raw output lives in the `_inv-*.md` files next to this one;
this document is the consolidated map. Claims marked ✔ below were additionally
re-verified by the main session by reading the cited lines directly.
| Detail inventory | Project | Lines |
|---|---|---|
| `_inv-ref-server.md` | `henza_server` (reference backend) | 1313 |
| `_inv-tgt-api.md` | `apps/api` (target backend) | 1179 |
| `_inv-ref-admin.md` | `henza_admin` (reference admin) | 1705 |
| `_inv-tgt-admin.md` | `apps/admin-panel` (target admin) | 1261 |
| `_inv-ref-pwa.md` | `henza_pwa` (reference user app) | 1178 |
| `_inv-tgt-userapp.md` | `apps/user-app` (target v1) | 612 |
| `_inv-tgt-userappv2.md` | `apps/user-app-v2` (target v2) | 700 |
---
## 0. The single most important finding — the domain is NOT gold banking
The kickoff brief asks to audit "gold price, gram/weight, fees, commissions,
spread, wallet/balance, buy/sell orders, deposit/withdraw, transfers, KYC levels,
daily limits, payment callbacks, live price sockets". **Most of that does not
exist in the reference**, so it cannot be a parity gap.
`henza_server` + `henza_pwa` are a **gold-jewellery B2B wholesale + B2C
e-commerce** system (Henza Gold): a catalogue of physical SKUs priced from a gold
rate, a cart, orders, and a gram-denominated customer ledger.
Verified absences in the reference user app (`_inv-ref-pwa.md` §6.2):
| Brief's flow | Reality in `henza_pwa` |
|---|---|
| buy gold | ABSENT — nearest is add-to-cart for a physical jewellery SKU |
| sell gold | ABSENT |
| wallet | ABSENT — a read-only balance strip on the dashboard |
| deposit | ABSENT — nearest is submitting a *findoc* of type چک/حواله/آب‌شده |
| withdraw | ABSENT |
| transfer | ABSENT — findoc type 2 (`حواله`) only records a transfer note |
| KYC levels | PARTIAL — two flat flags, no levels |
| addresses | ABSENT — one free-text field on the user |
| tickets / support | ABSENT — a static WhatsApp FAB (`index.html:59-61`) |
| daily/min/max limits | only one exists: `gram_level` (`ApiMobileController.php:528-758`) |
| live price socket | ABSENT — `BROADCAST_DRIVER=null`, zero `ShouldBroadcast` events ✔ |
| payment callback | route `site/transaction/verify` is **not registered** anywhere |
| cron | `Console/Kernel::schedule()` is **empty** |
**Consequence for the plan:** the parity matrices will cover what the reference
actually has — catalogue, pricing formula, cart/orders, ledger ("اسناد"), customer
management, ACL — and will not manufacture rows for gold-banking features. Where
the *target* has something bank-like, it is an intentional addition, not a gap.
---
## 1. Stack mapping
| Layer | Reference | Target | Note |
|---|---|---|---|
| Backend | Laravel/PHP, 46 controllers, 57 models | NestJS 10 + Prisma 5, 25 modules, 29 models | total rewrite; port behaviour only |
| Admin | Vue 2 + Vuesax/Vuexy template, Vuex | Vue 3.5 + ant-design-vue 4.2.6, Pinia | total rewrite |
| User app | Vue 3 + Vite + vant + ant-design-vue 3 | Vue 3 + Vite, two design versions | closest pair |
| Money types | PHP floats throughout | `Prisma.Decimal`, **zero `Float` in schema** ✔ | target is *stricter* — keep it |
---
## 2. Backend mapping — `henza_server` ⇄ `apps/api`
### 2.1 Scale
| Metric | `henza_server` | `apps/api` |
|---|---|---|
| Routes / endpoints | **369** (admin 215, api 70, customer 44, site 30, web 10) | **179** |
| Controllers | 46 | 25 |
| Models | 57 files (34 migrated tables + ~28 legacy Django-era tables) | 29 Prisma models |
| Enums | PHP static arrays | 14 Prisma enums |
| DTO / validation | **zero FormRequests**, all inline, several endpoints unvalidated | 8 DTO classes; most admin bodies untyped `Record<string, unknown>` |
| Seeders | 1, **empty** | `prisma/seed.ts` — user types, 144 rids, admins |
The 369 → 179 drop is expected, not automatically a gap: the reference counts
`Route::resource` expansions, ~28 dead legacy tables, a `site.php` storefront and
a `web.php` that the monorepo replaces with its own apps. **Which of the 369 are
genuinely unmapped is Phase 1 / Phase 2 matrix work**, per route group.
### 2.2 Core business logic — where each rule lives
| Rule | Reference | Target |
|---|---|---|
| **Gold price formula** ✔ | `app/Models/ProductVariant.php:183-190` | `apps/api/src/modules/...` (Phase 1 to map) |
| Price source | `app/Models/WebServiceGold.php` — `getPriceGold18Ayar()` divides by 10 (rial→toman) | `pricing` module |
| Order pipeline | `app/Http/Controllers/Controller.php` — `createSingleCustomerOrders`, `afterCreateOrder`, cashback at sts 6 | `orders.service.ts`, `admin-orders.service.ts` |
| Wholesale order + the only limit | `ApiMobileController.php:528-758` (`gram_level`) | `orders.service.ts` |
| Retail checkout | `Customer/OrderController.php:68-168` | `orders.service.ts` |
| Ledger / wallet | `packages/rooyesh/wallet/` — hash chain, pepper `"yhoenszeaf"` | `accounting/ledger.service.ts` (dual-currency, `Prisma.Decimal`) |
| Cashback | `Adm/AdminController.php:985-1013` (`chargeWalletAfterOrder`, in grams) | `admin-customers.service.ts` |
| Order statuses | **two divergent enums**: `Order.php:66` (0–6) and `SingleCustomerOrders.php:67` (1–7) | `OrderStatus` + `PreOrderStatus` |
| SMS / OTP | `app/Models/Sms.php` | **absent** — DB persistence only |
#### The gold price formula, verified ✔
`henza_server/app/Models/ProductVariant.php:183-190`:
```php
$price = goldPrice + (goldPrice * ((percentage_pay + product_price_extra_percent) / 100));
$price += $price * 0.07; // 7% margin
$temp = $price - goldPrice; // non-metal portion only
$temp += $temp * 0.09; // 9% VAT on that portion only
$price = goldPrice + $temp;
$price *= grams;
return floor($price / 1000) * 1000; // round DOWN to the nearest 1000
```
Three superseded variants sit commented out above it — they apply VAT to the
whole price, which is **wrong** and must not be ported. `getPriceWithDiscountAttribute`
(`:255-280`) is the same formula with `0.07 - discount_percent/100` and a trailing
`price -= price * (discount_price/100)`.
This formula, its ordering, and the `floor(/1000)*1000` are **row #1 of the Phase 1
matrix** — it is the money rule the whole catalogue depends on.
### 2.3 Auth & permissions
| Aspect | Reference | Target |
|---|---|---|
| Admin route protection | **NONE** ✔ — `auth:api` commented at `routes/admin.php:12` and `:272`, so all ~215 admin routes are public | global `JwtAuthGuard` + `PermissionsGuard`; 15 routes opt out with `@Public()` ✔ |
| Permission model | `Permission(rid, ut)` rows returned at login (`AdminController:74`), enforced **client-side only** — no Gates/Policies/Spatie | `acl.constants.ts` — 144 rids + `expandWithParents()`, enforced **server-side** |
| Permission catalogue source | not derivable from the server; lives in the admin at `views/user/permissionConst.js` (**172 declared keys**) | `acl.constants.ts` (144 rids) + `permissionConst.ts` (**179 keys, 174 unique**) |
| Parent expansion | `store/users/index.js:3-21` `checkParent()` — `order.list.sts.remove` implies `order.list.sts`, `order.list`, `order` | `expandWithParents()` — same semantics |
| Token refresh | machinery exists but is **unreachable** (`main.js:22-23` commented) | none |
**The target is deliberately stricter than the reference on auth.** That is a
prior, documented decision (`docs/CHANGELOG-PORT-HISTORY.md`), not a parity gap —
and reverting it would reopen a critical hole. Logged as `SEC-01`/`SEC-02`.
**This needs your confirmation (see Q1).**
---
## 3. Admin mapping — `henza_admin` ⇄ `apps/admin-panel`
| Metric | `henza_admin` | `apps/admin-panel` |
|---|---|---|
| `.vue` under `views/` | **541** — but only ~69 reachable from the live router; the rest are Vuexy template demos | **91** `.vue` + 19 `.ts` table models = 110 files |
| Routes | admin build + branch build (two route trees) | 80 admin-mode + 15 branch-mode records |
| Permission keys | 172 in `permissionConst.js` | 179 (174 unique) in `permissionConst.ts` |
| Rids enforced by `meta.role` | ~58 | ~58 |
| Button-level `$hasPermission` | catalogued in `_inv-ref-admin.md` §5.3 | 73 distinct rids via `hasPermission()` |
| HTTP | `src/axios.js`, **no response interceptor**, no working refresh | `src/api/client.ts` + `endpoints.ts` legacy→REST map (~100 exact + 34 regex), 401 → clear token + redirect |
| Table system | `src/components/x-table/` (4 forked copies) | one `XTable`, POST `{search, filters, page, limit, order, select, titles, report_title}` → `{rows, query}` |
| Digit/price utils | **none exist** (`_inv-ref-admin.md` Appendix B #17) | none, except inside `DateInput.vue` |
**Route-tree note:** `src/router/{apps,category,customer,dashboard,full,manage,order,product,temp}.js`
exist in the reference but are **not imported** — only `admin.js` and `branch.js`
are (`src/router.js:6,8`). Nine dead route files. They must not be mistaken for
missing pages in Phase 1.
### 3.1 Mapping status
A route-by-route and action-by-action mapping is **Phase 1's deliverable**
(`01-ADMIN-MATRIX.md`). What Phase 0 establishes is the denominator: ~69 live
reference views against 91 target views, with the reference's 172-key permission
tree against the target's 174-unique-key tree.
---
## 4. User-app mapping — `henza_pwa` ⇄ `user-app` + `user-app-v2`
### 4.1 Routes
| Reference route | name | `user-app` / `user-app-v2` | Status |
|---|---|---|---|
| `/home` | home | `/home` → `HomeView.vue` | ✅ |
| `/collection/:id?` | collection | same | ✅ |
| `/search/:id?` | search | `/search` — **no `:id?` param** | ⚠️ different |
| `/product/:id` | singleProduct | `components/product/SingleProduct.vue` | ✅ |
| `/newest` | newestProduct | same | ✅ |
| `/user` | userPage::hasSubRoute | `DashboardView.vue` | ✅ |
| `/orders` | ordersList | same | ✅ |
| `/transactions` | transactionsList | same | ✅ |
| `/profile` | profileData | same | ✅ |
| `/favorites` | favoritesList | same | ✅ |
| `/cart` | cart | same | ✅ |
| `/login` | login | `LoginRegisterView.vue` | ✅ |
| **`/report`** | report | — | ❌ missing |
| **`/report/detail`** | reportDetail | — | ❌ missing |
| **`/ChildOrder`** | ChildOrder (sales-rep child orders) | — | ❌ missing |
| **`/customerChoice`** | customerChoice (impersonation) | — | ❌ missing |
| **`/complete_profile`** | completeProfile (document upload / KYC) | — | ❌ missing |
| — | — | **`/notifications`** | ➕ extra in target |
Verified by reading `henza_pwa/src/router/index.ts:98-215` against
`apps/user-app/src/router/index.ts:18-45` ✔. The two target routers are
**byte-identical** (`diff` returns nothing) ✔ — good for the v1/v2 parity rule.
### 4.2 Guard differences ✔
| Behaviour | `henza_pwa/src/router/index.ts` | target |
|---|---|---|
| unauth hitting a gated route | `showNotify('ابتدا وارد شوید')` then redirect to **`home`** (`:250-254`) | redirect to **`/login`** with `?redirect=`, no toast |
| gated route list | ordersList, transactionsList, profileData, favoritesList, **completeProfile** | `meta.requiresAuth` on 4 (no completeProfile — the route doesn't exist) |
| logged-in hitting `/login` | → home (`:256`) | → home ✅ |
| `!isLoaded && to.name !== 'home'` | → home (`:258`) | **absent** |
| `sts_verify_step_two ∈ {1,2}` blocks completeProfile | `:260` | **absent** |
| route analytics | `useCustomAnalytics()` → `POST /log/create/{key}`, `GET /log/update/{id}` (`customAnalytics.ts:25,32`) | **absent entirely** |
### 4.3 The v1 ⇄ v2 rule
`CLAUDE.md` §2: the only allowed difference is visual design. Five **logic**
differences found, all with v2 behind v1 — these are parity gaps *inside* the
target, independent of the reference:
| # | Gap in `user-app-v2` | Evidence |
|---|---|---|
| 1 | **No cart stock ceiling** — no `capQty`/`maxQty`; a user can order more units than exist | v1 `stores/cartStore.ts:62,65`, `components/product/CartItem.vue:82-83`; zero hits in v2 ✔ |
| 2 | No `getHomeSlider()` / `GET /home/slider` admin banner | `diff` of the two `api/client.ts` ✔ |
| 3 | No per-variant images — v1 has `ProductImageSummary{id,url,sortOrder,role}` + `imageId`/`image`; v2's `images` is `Array<{url}>` | same diff ✔ |
| 4 | No orderable-first variant defaulting | v1 `composables/useProduct.ts:26` |
| 5 | No variant-chip preview cap / per-chip stock badges | v1 `components/product/SingleProduct.vue:388-392` |
Logged as `MON-01` and `V2-01..05` in `ISSUES.md`; they become Phase 2 matrix rows.
### 4.4 Live price — reference vs target ✔
Neither side has a socket. But the reference has a **two-path bug** worth knowing
before porting: `baseStore.getPriceList()` (`store.ts:107-156`) and
`Dashboard.getListPrice()` (`Dashboard.vue:632-660`) both do a one-shot
`GET /price`; the visible grid comes from the second, while the manual refresh
button re-runs the first — **so the refresh button does not update the list the
user is looking at**. Also `prices.date`/`prices.time` assignments are commented
out (`store.ts:145-147`), so the dashboard's date/time header is permanently blank.
Target: one-shot `GET /pricing/latest?karatPerMille=750` on mount + pull-to-refresh,
in `HomeView` and `DashboardView` only.
---
## 5. What Phase 0 did NOT determine (goes to Phase 1 / Phase 2)
- Route-by-route mapping of the reference's 369 backend routes onto the target's 179.
- Field-by-field comparison of request/response shapes per endpoint.
- Per-view form/validation/table comparison for the ~69 live admin views.
- Whether the target's gold-price implementation matches `ProductVariant.php:183-190`
exactly, including the `floor(/1000)*1000`. **This is Phase 1 row #1.**
- Whether the 174 target permission keys are a superset/subset of the reference's 172.
---
## 6. Open questions — need your answer before Phase 1
See the gate message; recorded here so they survive a context reset.
- **Q1 — Auth on admin routes.** The reference leaves all ~215 admin routes
unauthenticated (`routes/admin.php:12` commented). The target enforces JWT +
per-rid permissions. Strict parity would mean *removing* auth. Confirm the
target's stricter behaviour stands as an accepted deviation.
- **Q2 — Scope of the "gold banking" checklist.** Confirm we audit the domain the
reference actually implements (§0) rather than manufacturing rows for
buy/sell/deposit/withdraw/KYC-levels/sockets that exist nowhere.
- **Q3 — The 5 missing user-app routes** (`/report`, `/report/detail`,
`/ChildOrder`, `/customerChoice`, `/complete_profile`). These are real reference
features absent from both target apps. Building them means ~5 screens × 2 designs
plus backend. In scope for Phase 2, or deferred?
- **Q4 — Reference bugs.** 20 defects found in `henza_pwa` and 18 in `henza_admin`
(listed in the `_inv-*` appendices). `CLAUDE.md` §3 says replicate + log. Confirm
we do **not** replicate the data-destroying / clearly-broken ones (e.g. the
refresh button that updates an invisible list), and log them instead.
- **Q5 — Stray folder** `gold-banking-monorepo/user-app-v2/` (repo root, outside
`apps/`): only `package.json` + an outdated `vite.config.ts`, ignored by
pnpm-workspace. Delete, or leave?
+29
View File
@@ -0,0 +1,29 @@
# CHANGELOG — parity changes
Every change made to `gold-banking-monorepo` during the parity project, with the
reason and the reference path that justifies it.
Format:
```
## <date> — <phase> — <group title>
- **What:** <the change>
- **Why:** <the parity gap it closes>
- **Reference:** <henza_* path:line>
- **Target:** <gold-banking-monorepo path:line>
- **Matrix row:** <id in 01-ADMIN-MATRIX.md / 02-APPS-MATRIX.md>
- **Commit:** <sha>
```
---
## 2026-09-21 — Phase 0 — Setup
- **What:** Created branch `parity/phase-0` and `docs/parity/` scaffolding
(`PROGRESS.md`, `ISSUES.md`, `CHANGELOG.md`).
- **Why:** Persistent state required by `CLAUDE.md` §6.
- **Reference:** n/a
- **Target:** `docs/parity/`
- **Matrix row:** n/a
_No source code changed in Phase 0 (read-only phase)._
+76
View File
@@ -0,0 +1,76 @@
# ISSUES — logged, NOT fixed during parity
Anything found during the parity audit that is a bug, risk, smell or improvement
opportunity goes here. **Do not fix these while parity work is in progress** (see
`CLAUDE.md` §3). They are worked through in Phase 3, only when the user asks.
Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` · `Low`
> Paths are relative to the workspace root (`gold bank/`).
> "REF" = reference project (read-only). "TGT" = `gold-banking-monorepo`.
> Items marked **[replicated]** exist in the reference and were faithfully ported —
> fixing them is a product decision, not a parity fix.
## Security & auth
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| SEC-01 | Critical | REF `henza_server/routes/admin.php:12,272` | The whole admin route file is **unauthenticated** — the `Route::middleware('auth:api')->group(...)` wrapper is commented out at both ends, leaving all ~215 admin routes public (incl. `wallet/charge`, `users`, `roles`). | Verified by reading both lines. | n/a in reference (read-only). Drives TGT decision below. |
| SEC-02 | Critical | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7`, `orders/orders.controller.ts:7`, `customers/customers.controller.ts:5`, `catalog/catalog.controller.ts:5`, `pricing/pricing.controller.ts:5` | 15 routes carry `@Public()`, including **ledger writes** (`POST accounting/repayments/*`, `entries/:id/confirm`) and `POST /api/orders` + `PATCH /api/orders/:id/status`. The public status DTO accepts a client-supplied `actorAdminUserId`, so a caller can attribute an action to any admin. | `grep -rn "@Public()"`; source comments say "pre-existing unauthenticated endpoint". | Introduce customer-session auth for the app-facing routes; move ledger/status writes behind the admin guard. **[replicated]** from SEC-01. |
| SEC-03 | Critical | TGT `apps/user-app/src/views/user/LoginView.vue`, `apps/user-app-v2/` same | OTP is **not verified** — any 4-digit code logs the user in. Login also downloads the **entire customer list** (`GET /api/customers`) and matches the phone number client-side, exposing every customer's data to any visitor. | Both inventory agents verified independently; `customers.controller.ts:5` is `@Public()`. | Implement a real server-side OTP issue/verify pair; never ship the customer list to the client. |
| SEC-04 | High | TGT `apps/api` (two files) | `JWT_SECRET` falls back to `'dev-only-insecure-secret'` and is absent from `.env.example`. `enableCors()` is wide open. | Reported by api inventory; see `_inv-tgt-api.md` §11. | Require the env var at boot; restrict CORS origins. |
| SEC-05 | High | TGT `apps/api/prisma/seed.ts` | Seed creates a SUPER_ADMIN (`09128081392`) whose **password equals the phone number**, holding all 144 rids. Marked DEV-ONLY in comments but lives in the normal seed. | `docs/CHANGELOG-PORT-HISTORY.md` §4 documents this as intentional for dev. | Gate behind an explicit env flag so it can never run outside dev. |
| SEC-06 | High | REF `henza_server` | Hardcoded secrets committed to the repo: SMS panel password, PayPing bearer token, Tahesab bearer + IP allowlist, wallet hash pepper (`"yhoenszeaf"`). | See `_inv-ref-server.md` §11.4. | Rotate all of them before any deployment that reuses these credentials. |
| SEC-07 | Medium | TGT `apps/api/src/modules/admin-customers/admin-customers.service.ts` | `setRoles` stores arbitrary strings as rids with no validation against `ALL_RIDS`. | api inventory §9. | Validate against the rid catalogue. |
## Money, stock & data integrity
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| MON-01 | Critical | TGT `apps/user-app-v2/src/stores/cartStore.ts` | v2 has **no `capQty` / `maxQty` stock ceiling**, which `user-app` does have (`apps/user-app/src/stores/cartStore.ts:62,65`; `components/product/CartItem.vue:82-83`). A v2 user can order more units than exist. | Verified by grep across both apps — zero hits in v2. | Port the ceiling to v2 (this is also a **Phase 2 parity gap**, row to be added to `02-APPS-MATRIX.md`). |
| MON-02 | High | REF `henza_server` wallet package | The wallet `balance` column **mixes grams and toman in one field**, and money operations are not wrapped in DB transactions. | `_inv-ref-server.md` §7. | Separate the units; wrap mutations in transactions. |
| MON-03 | High | TGT `apps/api/src/modules/accounting/ledger.service.ts:189` | `GRAMS_PER_MESGHAL = 4.331802` is a JS **float** used inside an otherwise-`Prisma.Decimal` CASH repayment formula, so the divisor is pre-rounded before the Decimal division. | Read at `ledger.service.ts:182-190`; the source comment already flags the formula for stakeholder review. | Make the constant a `Decimal` (or a string literal passed to `Decimal`). |
| MON-04 | High | TGT `apps/api/src/modules/orders/orders.service.ts` | Debt is recognised at `DELIVERED_TO_AGENT`, but the idempotency guard checks `deliveredAt`, which is only set by `DELIVERED_TO_CUSTOMER` — so the debt trigger is not reliably idempotent. There is also **no status-transition validation** anywhere. | api inventory §8. | Guard on the same field the trigger fires on; add an explicit transition table. |
| MON-05 | Medium | TGT `apps/api` | `canPlaceOrders` and `verificationStep` are stored and toggled by the admin panel but **never enforced** on any order path. | api inventory §8. | Enforce at order creation, or remove the toggles. |
| MON-06 | Medium | TGT `apps/api` | `Setting` rows are never seeded — `wallet.config`, `wallet.birthday`, `promotion.auto`, `mobile_main_slider` only materialise on first write, so a fresh install has undefined behaviour until an admin saves each one. | api inventory §7. | Seed the defaults. |
| MON-07 | Medium | REF `henza_server/app/Models/ProductVariant.php:183-190` vs `SingleCustomerOrders.php:67`, `Order.php:66` | Two **divergent order-status enums** coexist (0–6 and 1–7) for what is conceptually one flow. | Read both models. | Unify, or document the mapping explicitly. |
## Validation & contracts
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| VAL-01 | High | REF `henza_server` | **Zero FormRequests**; all validation is inline, and `setOrder`, wallet `charge`, `setConst` and file upload have **no validation at all**. The wallet package's own validator is dead code (it calls `failed()` instead of `fails()`). | `_inv-ref-server.md` §12. | n/a in reference; informs how much validation the target must add. |
| VAL-02 | High | TGT `apps/api` | Only **8 DTO classes** exist; most admin request bodies are untyped `Record<string, unknown>`, so class-validator never runs on them. | api inventory §5. | Add DTOs for the admin write paths. |
| VAL-03 | Medium | TGT both user apps | HTTP client is a bare `fetch` wrapper: **no interceptors, no Authorization header, no refresh, no 401 handling, no error toasts**. Non-2xx throws a generic `Error`; call sites mostly `console.log(e)`. Order-submit failure is **silent to the user**. | `apps/user-app/src/api/client.ts`, `apps/user-app-v2/src/api/client.ts:3`. | Add a shared client with error surfacing; compare against `henza_pwa/src/service/*` in Phase 2. |
## Parity-relevant divergence between user-app and user-app-v2
> `CLAUDE.md` §2: the only allowed difference is visual design. These are logic
> differences, so they are **Phase 2 parity gaps** as well as issues.
| ID | Severity | Where | Description | Evidence |
|---|---|---|---|---|
| V2-01 | Critical | v2 missing stock ceiling | see MON-01 | grep |
| V2-02 | Medium | v2 missing `getHomeSlider()` / `GET /home/slider` admin banner | v1 `api/client.ts:152-170` defines `AdminSlide` + `getHomeSlider`; v2 has neither | `diff apps/user-app/src/api/client.ts apps/user-app-v2/src/api/client.ts` |
| V2-03 | Medium | v2 missing per-variant images | v1 defines `ProductImageSummary` (`id/url/sortOrder/role`) and `imageId`/`image` on the variant; v2's `images` is just `Array<{url:string}>` | same diff |
| V2-04 | Medium | v2 missing orderable-first variant defaulting | v1 `composables/useProduct.ts:26` picks the first orderable variant; v2 has no equivalent | grep |
| V2-05 | Low | v2 missing variant-chip preview cap + per-chip stock badges | v1 `components/product/SingleProduct.vue:388-392` | grep |
## Dead code & maintenance
| ID | Severity | Where | Description |
|---|---|---|---|
| DEAD-01 | Low | TGT `apps/user-app` | 8 components with no importers, `loadingStore`, `api.getBalance` (zero call sites), unused `gsap` dependency. |
| DEAD-02 | Low | TGT `apps/user-app-v2` | 11 dead components, `loadingStore`, `getBalance`, `priceUnit`/`priceUnitRialLess`, 12 of 15 validators, unused `gsap` + `@ant-design/icons-vue` deps. |
| DEAD-03 | Low | TGT `gold-banking-monorepo/user-app-v2/` (repo root, outside `apps/`) | Stray leftover folder containing only `package.json` and an **older** `vite.config.ts` (hardcoded port 5179, hardcoded proxy, imports a `themeConfig` that doesn't exist there). Not matched by `pnpm-workspace.yaml`, so pnpm ignores it. Left in place — deleting it needs user approval. |
| DEAD-04 | Low | REF `henza_server/app/Console/Kernel.php` | `schedule()` is **empty** — no cron. Price refresh / expiry / birthday logic must be driven by OS cron or the `GET /` web route. |
| DEAD-05 | Low | REF `henza_server` | PayPing `returnUrl` points at `site/transaction/verify`, which is **not registered in any route file** — the payment callback is unreachable. |
## Known stubs in the target (not bugs, but not parity either)
| ID | Where | Description |
|---|---|---|
| STUB-01 | TGT both user apps | Registration persists nothing; favourites are in-memory only (lost on reload); notifications is a static empty state; home slides are 3 hardcoded placeholders in v2. |
| STUB-02 | TGT `apps/api` | No OTP, no SMS provider, no payment gateway/callbacks, no cron, no websockets. SMS and notifications are DB-persistence only. |
| STUB-03 | TGT both user apps | All list filtering and "pagination" is **client-side** over one flat `GET /catalog/products`; no server `page`/`limit` params. "Pagination" is a 20-item IntersectionObserver window. |
| STUB-04 | TGT both user apps | Live price is **pull-on-mount only** (`GET /pricing/latest?karatPerMille=750`) — no socket, no SSE, no polling interval. `quotedAt` renders as a Jalali **date only**, so a stale rate looks current. |
+75
View File
@@ -0,0 +1,75 @@
# PROGRESS — Reference Parity Project
> Read this file FIRST at the start of every session and after every `/compact`.
> Rules live in `CLAUDE.md` (workspace root). Method lives in the `reference-parity-audit` skill.
## Current state
| Field | Value |
|---|---|
| **Phase** | Phase 0 — Inventory (read-only) |
| **Branch** | `parity/phase-0` |
| **Status** | ✅ COMPLETE — **at the gate, waiting for user confirmation** |
| **Next step** | User answers Q1–Q5 below → finalise `PROTECTED.md` → branch `parity/phase-1-admin` → build `01-ADMIN-MATRIX.md` |
| **Blocked on** | user answers to Q1–Q5 |
| **Last updated** | 2026-09-21 |
## Phase checklist
- [x] P0.1 — All 3 reference folders readable (`henza_server`, `henza_admin`, `henza_pwa`)
- [x] P0.2 — Branch `parity/phase-0` + `docs/parity/` created
- [x] P0.3 — Inventory all 7 projects → `_inv-*.md` (8111 lines total) → consolidated in `00-INVENTORY.md`
- [x] P0.4 — Mapping tables, missing list, extra list (`00-INVENTORY.md` §2–4)
- [x] P0.5 — Draft `PROTECTED.md`
- [ ] **P0.6 — GATE: user confirms PROTECTED list + answers Q1–Q5** ← HERE
- [ ] Phase 1 — Admin parity (branch `parity/phase-1-admin`, `01-ADMIN-MATRIX.md`)
- [ ] **GATE:** "Admin parity is complete and tested. Shall we move on to the apps (user-app & user-app-v2)?"
- [ ] Phase 2 — Apps parity (`02-APPS-MATRIX.md`, both user apps)
- [ ] Phase 3 — Work through `ISSUES.md` (only when user asks)
## Open questions blocking Phase 1
| # | Question | Proposed answer |
|---|---|---|
| Q1 | Reference leaves all ~215 admin routes unauthenticated (`henza_server/routes/admin.php:12,272`). Target enforces JWT + 144 rids. Strict parity = remove auth. | Keep target's stricter auth as an accepted deviation (`PROTECTED.md` §D) |
| Q2 | The brief's gold-banking checklist (buy/sell, deposit/withdraw, transfer, KYC levels, sockets, payment callbacks) does not exist in the reference — it's a jewellery e-commerce system. | Audit the domain the reference actually has (`00-INVENTORY.md` §0) |
| Q3 | 5 reference user-app routes missing from BOTH target apps: `/report`, `/report/detail`, `/ChildOrder`, `/customerChoice`, `/complete_profile`. ~5 screens × 2 designs + backend. | In scope for Phase 2, or deferred? |
| Q4 | 20 defects in `henza_pwa` + 18 in `henza_admin`. Replicate or log? | Log, don't replicate the broken/destructive ones (`PROTECTED.md` §F) |
| Q5 | Stray `gold-banking-monorepo/user-app-v2/` at repo root (only `package.json` + outdated `vite.config.ts`, ignored by pnpm-workspace). | Delete, or leave? |
## Key context (survives context loss)
- **Domain correction:** the reference is a **gold-jewellery B2B/B2C e-commerce**
system, not a gold bank. No buy/sell order book, no spread, no deposit/withdraw,
no P2P transfer, no KYC levels, no daily limits (only `gram_level`), no payment
callback route, no cron, no websockets. See `00-INVENTORY.md` §0.
- **The gold price formula** (Phase 1 row #1) —
`henza_server/app/Models/ProductVariant.php:183-190`: `+extra%` → `+7%` margin →
`+9%` VAT **on the non-metal portion only** → `×grams` → `floor(/1000)*1000`.
Three superseded commented-out variants above it apply VAT to the whole price —
do **not** port those.
- **A large prior port already happened** — see `docs/ADMIN-PORT-PLAN.md` and
`docs/CHANGELOG-PORT-HISTORY.md` (80 views, 25 Nest modules, 29 Prisma models,
154 admin routes, 144/144 rids). Prior port notes **no view was ever visually
verified in a browser**.
- **Stack changed across the port:** Laravel→NestJS/Prisma, Vue2/Vuesax→Vue3/antd.
Port behaviour, not code style.
- **Reference scale is inflated:** 541 admin `.vue` files but only ~69 reachable
(rest are Vuexy template demos); 9 reference router files are dead (not imported);
~28 legacy Django-era DB tables have no migrations.
- **v1 ⇄ v2 logic gaps** (5, all v2 behind v1) are parity work, not design:
`ISSUES.md` MON-01, V2-01..05.
## Inventory files (Phase 0 working files)
| File | Scope | Lines |
|---|---|---|
| `_inv-ref-server.md` | henza_server | 1313 |
| `_inv-tgt-api.md` | apps/api | 1179 |
| `_inv-ref-admin.md` | henza_admin | 1705 |
| `_inv-tgt-admin.md` | apps/admin-panel | 1261 |
| `_inv-ref-pwa.md` | henza_pwa | 1178 |
| `_inv-tgt-userapp.md` | apps/user-app | 612 |
| `_inv-tgt-userappv2.md` | apps/user-app-v2 | 700 |
Consolidated into `00-INVENTORY.md`. Issues found: `ISSUES.md` (26 entries).
+193
View File
@@ -0,0 +1,193 @@
# PROTECTED — intentional monorepo additions (additive-only)
**DRAFT — awaiting user confirmation (Phase 0 gate).**
Items here are intentional changes in `gold-banking-monorepo`. Per `CLAUDE.md` §4
they must **not** be removed, restyled, restructured, or have their behaviour
changed. You may only **ADD** what the reference has and they lack, in a way that
doesn't break them. Any fix that touches a protected item needs explicit approval
with a diff plan first.
Legend: **[brief]** = named as protected in the kickoff brief · **[found]** =
discovered in Phase 0, proposed for protection · **[?]** = needs your ruling.
---
## A. Admin panel — Dashboard · **[brief]**
**Location:** `apps/admin-panel/src/views/Dashboard.vue` (327 lines) — one file
serving both `/dashboard/ecommerce` and `/dashboard/analytics`.
**Gate:** rid `dashboard.view`; lacking it redirects to `my-data` (not a 403 page).
**Data:** a single `GET admin/dashboard/overview` on mount, then client-side sorts
(`reagent` desc by `grams_sell`; `lastOrder` desc by `w`).
Contents, in render order:
1. 4 KPI tiles — مشتریان جدید (نفر) · طلای فروش‌رفته (گرم) · تعداد سفارشات · نرخ هر گرم طلای ۱۸ عیار
2. 2 KPI tiles — سفارشات تحویل‌شده · مجموع بدهی طلای مشتریان (گرم)
3. Area chart «روند سفارش‌ها» — 2 series (تک / عمده); x-labels rotated **-45°** with
`rotateAlways` + `hideOverlappingLabels` (a deliberate overlap fix — do not "tidy" it)
4. 2 donuts — وضعیت سفارشات (8-entry colour array) · تفکیک عمده/تک
5. Horizontal bar + an always-rendered agent table (کارشناس فروش / میزان فروش گرم / تعداد سفارش)
6. Vertical bar «محصولات پرفروش» with an `a-empty` fallback
7. `a-descriptions` «میزان فروش» — 3 columns incl. a computed average
8. Table «سفارشات هفته جاری» — `pageSize` 10, `sts_str` as a coloured `a-tag`, `caj` via `jdate`
Fixed palette `#1c2d56 / #28c76f / #ff9f43 / #ea5455`. **No filters, no date range,
no export, no refresh** — that is the intended design, not a gap.
> Why this matters for parity: the reference's two dashboards
> (`DashboardECommerce.vue`, `DashboardAnalytics.vue`) were found in the earlier
> port to be **mostly commented-out Vuexy demo markup**, with ApexCharts imported
> but never used in live markup (`docs/CHANGELOG-PORT-HISTORY.md`). So there is
> essentially nothing to port *into* this dashboard. Phase 1 will confirm and
> record that, and will not touch this file.
---
## B. Admin panel — the extra option in product Variants · **[brief]**
**Location:** `apps/admin-panel/src/views/apps/product/AddProduct.vue:151-270,
315-361, 519-568` (Create/Edit Product).
**Gate:** the card is gated `product.list.variants`; add/edit/remove on
`.add` / `.edit` / `.remove`.
Variant table columns: تصویر | عنوان | وزن | اجرت درصدی | تعداد | فعال (tag) |
« پیش سفارش» (tag) | عملیات.
**The protected extra field is `تصویر تنوع` (variant image).** It is a **picker,
not an uploader**: a 64×64 flex grid over the product's already-saved gallery
(`data.gallery`, each `{id, url, role, sortOrder}`), whose first tile is a
`بدون تصویر` option and whose selected tile gets a blue `.is-on` border.
`pickVariantImage()` sets `image_id` (posted) plus `image_url` (thumbnail only,
stripped before POST).
It is populated **only in edit mode** — on create the gallery is empty and an
`a-alert` shows a context-dependent message (`ابتدا محصول را ذخیره کنید…` vs
`برای این محصول تصویری ثبت نشده است.`).
Variants are edited **purely in memory** (row key `__key = v-<ts>-<rand>`) and
saved with the product via `PUT product/update/{id}` / `POST product/create` under
a `variants[]` array — there is **no per-variant endpoint**. Only validation is
`عنوان تنوع الزامی است`; delete has **no confirm dialog**.
> Related, and NOT protected by itself: the same `imageId`/`image` contract on the
> **user-app** side. `user-app` consumes it; `user-app-v2` does **not** (gap V2-03).
> Closing that gap in v2 is additive and does not touch this admin code.
---
## C. User apps — new designs and new features · **[brief]**
Both `apps/user-app` and `apps/user-app-v2` keep their own visual design. The
following were identified in Phase 0 as design/feature additions with no
`henza_pwa` equivalent.
### C.1 Shared by both apps
| Item | Location |
|---|---|
| `/notifications` route + screen (self-documented "no legacy equivalent") | `views/user/NotificationsView.vue` |
| `ui/PageBar.vue` replacing the legacy `AppHeader` | `components/ui/` |
| Redesigned CSS bottom nav (5 tabs; legacy was a 4-tab GSAP notch) | `layout/` |
| `GallerySlider.vue` rewrite + teleported full-screen image preview | `components/product/` |
| Variant chips with per-variant images, stock badges, «نمایش همه» | `components/product/SingleProduct.vue` |
| The whole stock / pre-order rule (`qty`, `isPreOrder`, `capQty`, `maxQty`) | `stores/cartStore.ts`, `composables/useProduct.ts` — **v1 only; porting to v2 is gap MON-01** |
| vant pull-to-refresh on 5 screens | various views |
| IntersectionObserver incremental rendering (20-item window) | list views |
| Ledger-based «اسناد» screen | `views/user/TransactionsView.vue` |
| Order-detail drawer + 4-step tracker + `useOrderItemProduct` join | `views/user/OrdersView.vue` |
| 9-chip `OrderStatus` enum | `types/` |
| Dotlottie update dialog | app shell |
| New API contracts: `GET /home/slider`, `GoldPriceQuote`, `productCode`, `collections[]`, `gold-bank:*` session keys | `api/client.ts` |
### C.2 `user-app` (design v1) only
Home family: `components/home/RateStrip.vue`, `ShowcaseSlider.vue`,
`ShowcaseCard.vue`, `CategoryRail.vue`, `SectionHead.vue`, `AccountStrip.vue`;
admin banner via `GET /home/slider` + `AdminSlide`; the «برگزیده منتزر» and
«تازه‌ها» home sections; trust-badge rows.
### C.3 `user-app-v2` (design v2) only
`components/home/HeroCarousel.vue`, `ProductCarousel.vue`, `CategoryGrid.vue`,
`ProductPanel.vue`; the IRANSans FaNum font family (12 files,
`src/assets/styles/fonts/iransans/`); redesigned HomeView structure; redesigned
DashboardView (hero card + 4 settings rows, replacing card + tile grid); the
staggered first-paint reveal system (module-scope `hasRevealed`) in
HomeView/CartView/DashboardView; the `compact` RateStrip pill variant;
SingleProduct's scroll-driven solid header + category deep-link pill; the
pill-shaped SearchInput.
> **Important boundary.** C.2 and C.3 are *design* differences and stay separate.
> The five items in `ISSUES.md` §V2-01..05 are *logic* differences and are **not**
> protected — `CLAUDE.md` §2 requires logic to be identical across both apps.
---
## D. Backend — stricter auth than the reference · **[?] needs your ruling**
**Not yet protected — Q1 in the gate.**
The reference leaves every admin route unauthenticated (`henza_server/routes/admin.php:12`
and `:272` — the `auth:api` group wrapper is commented out at both ends, verified).
`apps/api` instead applies a global `JwtAuthGuard` + `PermissionsGuard` with
144 server-enforced rids, and only 15 routes opt out via `@Public()`.
Strict parity would mean **removing** authentication from ~215 admin endpoints.
`CLAUDE.md` §3 already carves out exactly this case ("if matching the reference
would cause … a clear security hole in money flows → STOP and ask").
**Proposed:** protect the target's stricter auth as an accepted deviation; log the
reference behaviour as `SEC-01` and the residual `@Public()` routes as `SEC-02`.
---
## E. Backend — Decimal money discipline · **[found] proposed**
The reference uses PHP floats throughout. `apps/api` has **zero `Float` columns**
in `prisma/schema.prisma`; ledger and order math run on `Prisma.Decimal`.
Reverting to floats for "parity" would introduce rounding error into money and
gram values. **Proposed:** protect the Decimal discipline. The one leak
(`GRAMS_PER_MESGHAL` as a JS float, `ledger.service.ts:189`) is logged as `MON-03`,
to be fixed in Phase 3 — not now.
Note this does **not** exempt the target from matching the reference's *arithmetic*,
including the `floor(price/1000)*1000` rounding in `ProductVariant.php:183-190`.
Same result, better representation.
---
## F. Prior deliberate non-ports · **[?] needs your ruling (Q4)**
Recorded in `docs/CHANGELOG-PORT-HISTORY.md` as intentionally **not** ported:
1. Unauthenticated file upload (`file/create` had no middleware in the reference)
2. SMS sent inside `toggleActive`
3. Six destructive data behaviours — deleting a confirmed document, editing a
confirmed amount, deleting a category that has dependents, creating a category
cycle, orphaning the audit trail — each replaced with a Persian 400 message
4. The reference's avatar-load crash (`ReagentCustomer.vue:280` dereferenced
`data.image.id` unguarded)
5. The branch-mode horizontal layout (`HorizontalNavMenu`, 4 files never ported;
`mainLayoutType` narrowed to `'vertical'`)
Items 1–4 are security/data-loss carve-outs consistent with `CLAUDE.md` §3 and are
**proposed for protection**. **Item 5 is different** — it is a missing feature, not
a safety carve-out, and Phase 1 should treat it as a real gap unless you say
otherwise.
---
## Summary — what needs your decision
| Section | Item | Proposed |
|---|---|---|
| A | Admin Dashboard | protect (per brief) |
| B | Product Variants image picker | protect (per brief) |
| C | User-app designs + new features | protect (per brief), **excluding** the 5 v1↔v2 logic gaps |
| D | Stricter admin auth in `apps/api` | protect — **confirm (Q1)** |
| E | Decimal money types | protect — **confirm** |
| F.1–4 | Prior security / data-loss non-ports | protect — **confirm (Q4)** |
| F.5 | Branch horizontal layout | **treat as a real gap** — confirm |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+612
View File
@@ -0,0 +1,612 @@
# Inventory — apps/user-app (target user app v1)
All `path:line` references are relative to `apps/user-app/`. Scope: `src/**`, root config. `node_modules`/`dist` excluded.
---
## 1. Stack & versions
Source: `package.json:1-38`
| Field | Value | Line |
|---|---|---|
| name / version / private | `user-app` / `0.0.1` / true | `package.json:2-4` |
| scripts | `dev: vite`, `build: vue-tsc -b && vite build`, `lint: eslint "src/**/*.{ts,vue}"` | `package.json:6-8` |
### Dependencies (`package.json:10-24`)
| Package | Version | Line |
|---|---|---|
| @ant-design/icons-vue | ^6.1.0 | `package.json:11` |
| @gold-bank/shared-types | workspace:* | `package.json:12` |
| @vueuse/core | ^9.13.0 | `package.json:13` |
| ant-design-vue | ^3.2.20 | `package.json:14` |
| gsap | ^3.9.1 | `package.json:15` |
| moment-jalaali | ^0.10.0 | `package.json:16` |
| pinia | ^2.2.0 | `package.json:17` |
| swiper | ^7.4.1 | `package.json:18` |
| vant | ^4.0.3 | `package.json:19` |
| vue | ^3.5.0 | `package.json:20` |
| vue-router | ^4.4.0 | `package.json:21` |
| vue3-lazyload | ^0.2.5-beta | `package.json:22` |
| @lottiefiles/dotlottie-vue | ^0.4.15 | `package.json:23` |
### devDependencies (`package.json:25-37`)
| Package | Version | Line |
|---|---|---|
| @vitejs/plugin-vue | ^5.1.0 | `package.json:26` |
| autoprefixer | ^10.4.2 | `package.json:27` |
| less | ^4.1.2 | `package.json:28` |
| postcss | ^8.4.5 | `package.json:29` |
| tailwindcss | 3.0.18 (pinned) | `package.json:30` |
| typescript | ^5.5.0 | `package.json:31` |
| vite | ^5.4.0 | `package.json:32` |
| vue-tsc | ^2.1.0 | `package.json:33` |
| vite-plugin-pwa | ^0.20.5 | `package.json:34` |
| workbox-precaching / workbox-window | ^7.1.0 | `package.json:35-36` |
Notes: `gsap` is declared but **unused** in `src/**` (the GSAP notch nav was replaced — `src/components/ui/Navigation.vue:1-10`). `swiper` is used only by `src/components/slider/HomeSlider.vue:37` and `src/components/slider/Slider.vue:42`, both of which have **no importers** (dead components).
---
## 2. HTTP layer
Single file: `src/api/client.ts`. **No axios, no interceptors, no token attach, no refresh, no 401 handling, no error toasts.**
| Aspect | Value | Line |
|---|---|---|
| Client | bare `fetch` wrapper `request<T>()` | `src/api/client.ts:5-15` |
| Base URL | const `BASE = '/api'` (relative; dev-proxied) | `src/api/client.ts:3` |
| Default headers | `{'Content-Type':'application/json'}` only | `src/api/client.ts:7` |
| Auth header | **none** — no Authorization/Bearer anywhere in the app | `src/api/client.ts:5-15` |
| Refresh token | **none** | — |
| 401 handling | **none** — any non-2xx throws `Error("API <path> failed (<status>): <body>")` | `src/api/client.ts:10-13` |
| Error toast | **none** at the client layer; each caller does `console.log(e)` or a silent `catch` | e.g. `src/views/HomeView.vue:248`, `src/stores/cartStore.ts:52` |
| Credentials / cookies | not set | — |
| Retry / timeout / abort | none | — |
### Every API function
| Function | Method | URL | Params / payload | Returns | Line |
|---|---|---|---|---|---|
| `api.listProducts()` | GET | `/api/catalog/products` | none | `ProductSummary[]` | `src/api/client.ts:160` |
| `api.listCustomers()` | GET | `/api/customers` | none | `CustomerSummary[]` | `src/api/client.ts:161` |
| `api.getCustomer(id)` | GET | `/api/customers/{id}` | path `id` | `CustomerDetail` | `src/api/client.ts:162` |
| `api.getBalance(customerId)` | GET | `/api/accounting/customers/{customerId}/balance` | path `customerId` | `CustomerBalanceDto` | `src/api/client.ts:163` |
| `api.getLedgerEntries(customerId)` | GET | `/api/customers/{customerId}/ledger-entries` | path `customerId` | `LedgerEntrySummary[]` | `src/api/client.ts:164` |
| `api.createOrder(dto)` | POST | `/api/orders` | body `CreateOrderDto` = `{customerId, items:[{productVariantId, quantity, wagePercent}]}` | `OrderSummary` | `src/api/client.ts:165`; payload built at `src/views/CartView.vue:198-208` |
| `api.getCustomerOrders(customerId)` | GET | `/api/customers/{customerId}/orders` | path `customerId` | `OrderDetail[]` | `src/api/client.ts:166` |
| `api.getLatestGoldPrice(karatPerMille=750)` | GET | `/api/pricing/latest?karatPerMille={n}` | query `karatPerMille`, default **750** | `GoldPriceQuote` | `src/api/client.ts:167` |
| `api.getHomeSlider()` | GET | `/api/home/slider` | none (public) | `AdminSlide[]` | `src/api/client.ts:170` |
**Total: 9 API functions.** `api.getBalance` is declared but has **zero call sites** in `src/**`.
### DTO / response interfaces (`src/api/client.ts`)
| Interface | Key fields | Line |
|---|---|---|
| `ProductImageSummary` | `id, url, sortOrder, role: 'FEATURED'\|'FEATURED_MOBILE'\|'GALLERY'\|'GALLERY_MOBILE'` | `17-22` |
| `ProductVariantSummary` | `id, title, rashWeightGrams, defaultWagePercent, qty?, isPreOrder?, imageId?, image?` | `24-41` |
| `CategorySummary` | `id, title, parentId` | `43-47` |
| `BrandSummary` | `id, title` | `49-52` |
| `CollectionSummary` | `id, title, description, isActive` | `54-59` |
| `ProductSummary` | `id, title, description, productCode?, variants[], images[], category, brand, collections[]` | `61-75` |
| `LedgerEntrySummary` | `id, currency, direction, sourceType, amount, confirmed, createdAt, note` | `77-86` |
| `CustomerSummary` | `id, fullName, mobile, goldDebtGrams, fiatDebtRial, goldCreditLimitGrams, isActive, isArchived` | `88-97` |
| `OrderSummary` | `id, orderNumber, status, totalGoldWeightGrams, isSuspendedOverCreditLimit` | `99-105` |
| `OrderItemDetail` | `id, productVariantId, quantity, rashWeightGramsSnapshot, wagePercentSnapshot, discountPercentSnapshot, computedWeightGrams, flatRialCharge` | `107-116` |
| `OrderDetail` | `id, orderNumber, status, totalGoldWeightGrams, wageWeightGrams, flatRialCharge, isSuspendedOverCreditLimit, deliveredAt, createdAt, items[]` | `118-129` |
| `CustomerDetail` | `id, fullName, mobile, nationalCode, cityId, goldCreditLimitGrams, goldDebtGrams, fiatDebtRial, verificationStep, createdAt` | `131-142` |
| `GoldPriceQuote` | `id, karatPerMille, pricePerGramRial, source, quotedAt` | `144-150` |
| `AdminSlide` | `image, link` | `152-157` |
---
## 3. Router — every route
Source: `src/router/index.ts:16-45`. History: `createWebHistory()` (`:9`). `scrollBehavior`: restore `savedPosition` on back/forward, else `{top:0}` (`:12-15`).
| path | name | component file | meta |
|---|---|---|---|
| `/home` | `home` | `src/views/HomeView.vue` | — (Master layout, no guard) — `:18` |
| `/product/:id` | `singleProduct` | `src/components/product/SingleProduct.vue` (`props:true`) | `layout: 'FullPage'` — `:19-25` |
| `/newest` | `newestProduct` | `src/views/NewestView.vue` | `layout: 'FullPage'` — `:26` |
| `/cart` | `cart` | `src/views/CartView.vue` | — (**no auth guard**; login enforced in-view) — `:27` |
| `/login` | `login` | `src/views/user/LoginRegisterView.vue` | `layout: 'FullPage'` — `:28` |
| `/user` | `userPage::hasSubRoute` | `src/views/user/DashboardView.vue` | — (**no guard**; view renders a signed-out invite) — `:29` |
| `/orders` | `ordersList` | `src/views/user/OrdersView.vue` | `requiresAuth: true` — `:30` |
| `/transactions` | `transactionsList` | `src/views/user/TransactionsView.vue` | `requiresAuth: true` — `:31-36` |
| `/profile` | `profileData` | `src/views/user/ProfileView.vue` | `requiresAuth: true` — `:37` |
| `/favorites` | `favoritesList` | `src/views/user/FavoritesView.vue` | `requiresAuth: true` — `:38` |
| `/notifications` | `notifications` | `src/views/user/NotificationsView.vue` | — (no guard) — `:39` |
| `/search` | `search` | `src/views/search/SearchView.vue` | — `:40` |
| `/collection/:id?` | `collection` | `src/views/search/CollectionView.vue` | — (optional param; title via `?page=`) — `:41` |
| `/` | — | redirect → `{name:'home'}` | `:43` |
| `/:pathMatch(.*)*` | — | redirect → `{name:'home'}` | `:44` |
**13 named routes + 2 redirects.** No `meta.title` and **no `meta.level` / level requirement anywhere** — there is no KYC/level gating in the router.
---
## 4. Navigation
Bottom tab bar: `src/components/ui/Navigation.vue`; item list lives in `src/stores/navStore.ts:25-31`.
| index | label | name | path | icon |
|---|---|---|---|---|
| 1 | خانه | `home` | `/home` | `vuesax-linear:home-2` — `navStore.ts:26` |
| 2 | جستجو | `search` | `/search` | `vuesax-linear:search-normal-1` — `navStore.ts:27` |
| 3 | سبد خرید | `cart` | `/cart` | `vuesax-linear:bag-happy` — `navStore.ts:28` |
| 4 | سفارشات | `ordersList` | `/orders` | `vuesax-linear:receipt-text` — `navStore.ts:29` |
| 5 | پروفایل | `userPage::hasSubRoute` | `/user` | `vuesax-linear:user` — `navStore.ts:30` |
- Gating: **none per tab** — every tab is a plain `router-link` (`Navigation.vue:19-28`). `/orders` is gated by the router's `requiresAuth` and bounces to `/login`.
- Visibility: bar is `v-show="nav.showNav"` (`Navigation.vue:12`). `router.afterEach` sets `showNav=true` + `setActiveLink(index)` only for the five tab routes, else `false` (`src/router/index.ts:95-101`).
- Active indicator: CSS transform `translateX(-(activeLink-1)*100%)`, negative because the app is RTL (`Navigation.vue:40-44`).
- SearchView hides the bar while its filter sheet is open (`src/views/search/SearchView.vue:228`) and restores it in `onBeforeRouteLeave` (`:262-264`).
- Other nav surfaces: home bar cart/search links `src/views/HomeView.vue:25-34`; dashboard 4-tile grid `src/views/user/DashboardView.vue:184-200`; `AccountStrip` 3 action buttons (`src/components/home/AccountStrip.vue:32-43`) — **AccountStrip has no importer** (dead).
---
## 5. Guards
| Guard | Rule | Line |
|---|---|---|
| `router.beforeEach` — already-logged-in | `to.name === 'login' && session.customerId` → redirect `{name:'home'}` | `src/router/index.ts:56-58` |
| `router.beforeEach` — auth | `to.meta.requiresAuth && !session.customerId` → `{name:'login', query:{redirect: to.fullPath}}` | `src/router/index.ts:59-61` |
| `router.beforeEach` — transition | `to.name==='singleProduct'` → `nav.setPgaTransition('slideDown')`, else `undefined` | `src/router/index.ts:64-65` |
| `router.afterEach` | resets transition flags (100 ms cycle, inert — no consumer), sets active tab + nav visibility | `src/router/index.ts:80-102` |
| `onBeforeRouteLeave` (SearchView) | restores nav visibility if the filter sheet is open | `src/views/search/SearchView.vue:262-264` |
| In-view login gate (Cart) | `submitOrder()` → if `!session.customerId` open `loginNotify` dialog instead of posting | `src/views/CartView.vue:191-195` |
| In-view login gate (Dashboard) | `goToDetails()` → if `!session.customerId` open `data.loginNotify` dialog | `src/views/user/DashboardView.vue:191-195` |
| In-view login gate (product) | add-to-cart: `isUserLoggedIn() ? addToCart() : (loginNotify = true)` | `src/components/product/SingleProduct.vue:227`; helper `src/utils/checkAccess.ts:4-6` (`localStorage.getItem('appToken')`) |
| Post-login redirect | `route.query.redirect` honoured, else `{name:'home'}` | `src/views/user/LoginView.vue:161-162` |
**KYC / verification-level guard: does not exist.** `verificationStep` is read-only display text in `src/views/user/ProfileView.vue:32,54` with labels at `:75-80`. No route, button or flow is gated on it. Credit-limit (`goldCreditLimitGrams`, `isSuspendedOverCreditLimit`) is likewise never enforced client-side.
---
## 6. Views — all 14 files under `src/views` (recursive)
| File | Purpose |
|---|---|
| `src/views/HomeView.vue` | Redesigned shop-first home: brand bar, live rate strip, hero slider, category rail, featured grid, newest reel, trust badges. |
| `src/views/NewestView.vue` | "جدیدترین محصولات" grid with incremental reveal. |
| `src/views/CartView.vue` | Basket → per-item note → confirm → POST order → success step. |
| `src/views/search/SearchView.vue` | Search + category chips + filter sheet + collection tiles + result grid. |
| `src/views/search/CollectionView.vue` | Products of one collection (client-side filter by route param). |
| `src/views/user/LoginRegisterView.vue` | Toggle shell between LoginView and RegisterView. |
| `src/views/user/LoginView.vue` | Mobile → OTP login (OTP verification stubbed). |
| `src/views/user/RegisterView.vue` | 7-field registration form (submit stubbed, no endpoint). |
| `src/views/user/DashboardView.vue` | "میز کار": balance card, 4-tile access grid, rate strip, logout dialog. |
| `src/views/user/OrdersView.vue` | Order list, 9 status chips, number search, detail drawer with 4-step tracker. |
| `src/views/user/TransactionsView.vue` | "اسناد" ledger list + detail drawer. |
| `src/views/user/ProfileView.vue` | Read-only identity card (4 fields + verification seal). |
| `src/views/user/FavoritesView.vue` | Local favourites filtered against the catalogue. |
| `src/views/user/NotificationsView.vue` | Static empty state only — no data source. |
### 6.1 `HomeView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.listProducts()` GET `/api/catalog/products` + `memory.init()` (same endpoint) in `Promise.all` | `243-249` |
| API | `api.getLatestGoldPrice()` GET `/api/pricing/latest?karatPerMille=750`; on throw → `goldPrice=null` | `251-255` |
| API | `api.getHomeSlider()` GET `/api/home/slider`; on throw → `adminSlides=null` | `257-263` |
| Derived data | `featured = products[0]`; `gridProducts = slice(1,5)`; `newestProducts = slice(5,13)` | `160-162` |
| Hero source | admin slides when non-empty (filtered by `s.image`), else first 3 categories with rotating copy `SLIDE_COPY` (`مجموعه منتزر`/`دیدن مجموعه`, `انتخاب سردبیر`/`مشاهده`, `پیشنهاد این هفته`/`بررسی کنید`) | `170-202` |
| Slide click | absolute `http(s)` link → `window.open(link,'_blank','noopener')`; other link → `router.push(link)`; else set `filters.query.cats=[categoryId]` → `/search` | `219-235` |
| Category click | `filters.query.cats=[cat.id]` → `router.push({name:'search'})` | `214-217` |
| Buttons | cart icon (badge = `cart.items.length`), search icon, `همه محصولات`→`/search`, `مشاهده همه`→`/newest`, ShowcaseCard→drawer | `25-34, 61, 75, 63-66` |
| Pull-to-refresh | vant `PullRefresh` → `loadData()` | `38-42, 266-269` |
| Empty state | `v-if="!loading && !products.length"` → "هنوز محصولی در مجموعه ثبت نشده است." | `88-91` |
| Conditional render | hero only if `slides.length`; rail only if `memory.cats.length`; featured section if `featured`; newest section if `newestProducts.length` | `46, 48, 56, 70` |
| Detail | ant `Drawer` bottom, `height=100vh`, hosts `SingleProduct` | `110-119` |
| Not present | no price on any card, no form, no validation, no pagination params | file header `13-19` |
### 6.2 `CartView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `cart.init()` on mount + on pull-refresh → `api.listProducts()` (re-sync/prune) | `224-226, 219-222`; store `src/stores/cartStore.ts:39-55` |
| API | `api.createOrder({customerId: session.customerId, items:[{productVariantId: item.vid, quantity: item.qty, wagePercent: parseFloat(variant.defaultWagePercent) \|\| 0}]})` POST `/api/orders` | `198-208` |
| Steps | `step===1` basket, `step===2` success | `35, 66` |
| Calculation shown | total weight only: `cart.total()` = `Σ parseFloat(rashWeightGrams)*qty`, `.toFixed(2)`, unit گرم. **No rial total.** | `86-89`; `src/stores/cartStore.ts:124-131` |
| Form | note textarea, `rows=4`, placeholder "مثلاً سایز یا جزئیات سفارش"; **no validation rules** | `107-112` |
| Note submit | `cart.updateFeild(note, tempRowIndex, tempRow, 'note')`, closes drawer, clears model | `176-181` |
| Confirm dialog | `DialogVant` "آیا مطمئن به ثبت سفارش هستید ؟" — "بله"→`submitOrder`, "بی خیال"→close; `timeOut:-1`, `closeOnClickOverlay` | `116-125` |
| Login dialog | "برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید." — "ورود/ثبت نام"→`/login`, "بیخیال"→close | `127-138` |
| Loading | `Spinner :spinning="cartData.loadingPage"` tip "درحال دریافت اطلاعات"; `loadingPage` true during POST | `32, 213, 216` |
| Submit bar | rendered only when `cart.items.length > 0 && step === 1`; button is **not** disabled while posting (only the Spinner overlays) | `82, 91` |
| Success | shows `cartData.orderID` (= `order.orderNumber`), `cart.clear()`, CTA "فهرست سفارش‌ها" → `/orders` then `step=1` | `209-211, 185-188` |
| Error | `catch { console.log(e) }` — **no user-visible error message on order failure** | `212-214` |
| Empty | "سبد خرید شما خالی است." + CTA "دیدن محصولات" → `/home` | `56-62` |
### 6.3 `search/SearchView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.listProducts()` on mount and on pull-refresh | `206-213, 215-218, 266` |
| Filters (all client-side) | `text` → `p.title.includes(text)`; `cats` → `cats.includes(p.category.id)`; `brands` → `brands.includes(p.brand.id)`; `limit_gram` (len 2) → any variant weight within `[min,max]` | `153-167` |
| Pagination | **client-side window only**, `PAGE_SIZE = 20`, grown by `IntersectionObserver` with `rootMargin:'400px'`; reset to 20 on any filter change. No server page/limit params. | `174-199` |
| Chips | "فیلتر" (shows `${filterKeys.length} فیلتر` when active), "همه" (clears cats), one chip per available category | `23-50` |
| Collections | tiles → `router.push({path:'/collection/'+id, query:{page:title}})` | `71-82` |
| Header | `PageBar` title flips to "فیلترها" while the sheet is open; back button closes the sheet instead of navigating | `17, 241-248` |
| Nav side-effect | opening the sheet hides the tab bar; closing / leaving restores it | `226-237, 262-264` |
| Empty | "محصولی با این مشخصات پیدا نشد." when `!hasMore && !loadingPage` | `103-106` |
| Result count | SectionHead subtitle `${filteredProducts.length} محصول` | `84-88` |
### 6.4 `search/CollectionView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.listProducts()` on mount | `58-65` |
| Filter | `p.collections.some(c => c.collectionId === route.params.id)` — client-side | `54-56` |
| Title | `route.query.page` else fallback `'مجموعه'` | `52` |
| Empty | "در این مجموعه محصولی ثبت نشده است." when `!loadingPage` | `26-29` |
| Pagination | none | — |
### 6.5 `NewestView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `useProducts().getProducts(loadingPage, false)` → `api.listProducts()` | `59-61`; `src/composables/useProducts.ts:24-35` |
| Pagination | `PAGE_SIZE = 20` window; `pull()` grows the window first, only then calls `getProducts(loadingPull, true)` — which is a **no-op** (`isPull` returns early) | `55-71`; `useProducts.ts:25` |
| Empty | `ScrollFetchSearch` renders `EmptyState` when `productsData.flag` (= list length < 1) | `src/components/ui/ScrollFetchSearch.vue:19`; `useProducts.ts:29` |
### 6.6 `user/LoginView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | Step 1: `api.listCustomers()` GET `/api/customers`; match `c.mobile === formModel.username && c.isActive && !c.isArchived` | `131-133` |
| API | Step 2: **no request** — OTP not verified; `session.login(matchedCustomer.id, matchedCustomer.mobile)` | `153-160` |
| Field | `username` / "تلفن همراه", `<Input type="number">` | `62-63` |
| Validation | `required('تلفن همراه')` → " لطفا تلفن همراه را وارد کنید "; `mobile('تلفن همراه')` pattern `/^(\+98\|0098\|98\|0)?9\d{9}$/g` → " مقدار تلفن همراه نا معتبر است " | `61`; `src/utils/validator.ts:5-8,23,3` |
| Error message | not-found → NotifyVant red (`#DC2626`) "اطلاعات کاربری در سیستم وجود ندارد." (2000 ms) | `22, 103, 135-137` |
| OTP | `OtpInput` `:num-inputs="4"`, `inputType="tel"`, auto-focus, `@on-complete="sendToken"` | `40-48` |
| Timer | `timer = 120` s on step 2, 1 s countdown; label "امکان دریافت مجدد رمز در {counterZeroShow(timer)} ثانیه دیگر" (zero-padded < 10), else "برای دریافت رمز عبور مجدد تلاش کنید" | `112-122, 50-53, 170` |
| Button | label `body.step===1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'`; `:disabled="body.loading \|\| (timer > 0 && body.step === 2)"` | `66-73, 172` |
| Success | push `route.query.redirect` or `/home`, then notify "با موفقیت وارد شدید" | `161-164` |
| Switch | "ثبت نام کنید" → `emit('register', true)` | `77-80` |
| Digit conversion | **none** — no Persian→English digit normalisation on the mobile input | — |
### 6.7 `user/RegisterView.vue`
| Field | Label | Control | Validation | Line |
|---|---|---|---|---|
| `fname` | نام | Input | `required('نام')` | `37-39` |
| `lname` | نام خانوادگی | Input | `required('نام خانوادگی')` | `40-42` |
| `mobile` | شماره موبایل | Input `type=number` | `mobile()` + `required()` | `43-49` |
| `store_name` | نام فروشگاه | Input | `required('نام فروشگاه')` | `50-52` |
| `phone` | تلفن فروشگاه | Input `type=number` | `required('تلفن فروشگاه')` only (no `phone()` pattern) | `53-55` |
| `address` | آدرس پستی | Textarea rows=3 | `required('آدرس')` | `56-58` |
| `city` | شهر | Input (plain text, no picker) | `required('شهر')` | `59-61` |
| Aspect | Detail | Line |
|---|---|---|
| API | **none — stubbed.** `register()` only sets `registeredInfo = true` | `117-125` |
| Submit | button "ثبت اطلاعات", `type=submit`, no disabled state | `63` |
| Success dialog | "ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید." + "متوجه شدم" → `/home`; `closeOnClickOverlay=false` | `74-86` |
| Loading | `Spinner :spinning="profileData.loading"` tip "در حال ثبت اطلاعات" | `25` |
### 6.8 `user/DashboardView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.getCustomer(session.customerId)` GET `/api/customers/{id}` (only when signed in) | `208-211` |
| API | `api.getLatestGoldPrice()`; on throw → null | `212-216` |
| Calculations | `isDebtor = parseFloat(goldDebtGrams) > 0`; `grams = Math.abs(parseFloat(goldDebtGrams))`; label `بدهکار گرمی`/`بستانکار گرمی`; seal `بدهکار`/`بستانکار`; rial row `price(customer.fiatDebtRial)` + "ریال" | `176-180, 58-71` |
| Access tiles | id 0 سفارشات→`ordersList`, 1 اسناد→`transactionsList`, 2 پروفایل→`profileData`, 3 علاقمندی→`favoritesList`; all `disabled:false` | `184-200` |
| Gate | any tile while signed out → `data.loginNotify` dialog (not navigation) | `191-195` |
| Logout dialog | "آیا می خواهید از برنامه خارج شوید؟" → "بله" = `session.logout()` + push `/home`; "بی خیال" | `123-132, 202-206` |
| Login dialog | long invite copy → "ورود/ثبت نام" / "بیخیال" | `134-148` |
| Signed-out block | invite card with CTA "ورود / ثبت‌نام" | `82-93` |
| Pull-to-refresh | `PullRefresh` → `loadDashboard()` | `51, 219-222` |
| Error | `api.getCustomer` is **not** wrapped in try/catch — a failure rejects `loadDashboard()` | `209-211` |
### 6.9 `user/OrdersView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.getCustomerOrders(session.customerId)` GET `/api/customers/{id}/orders` | `196-204` |
| Filter chips (9) | `ALL` همه, `SUSPENDED` معلق, `PLACED` ثبت سفارش, `ADMIN_APPROVED` تایید مدیر سیستم, `SOURCING` درحال تامین, `PREPARING` درحال آماده سازی, `DELIVERED_TO_AGENT` تحویل مسئول فروش, `DELIVERED_TO_CUSTOMER` تحویل مشتری, `CANCELLED` لغو شده | `167-177` |
| Search | `SearchInput` placeholder "جستجوی شماره سفارش" → `o.orderNumber.includes(term)` | `32, 188` |
| Filtering | client-side (status + number). **No server-side query params, no pagination.** | `185-190` |
| Detail drawer | 4-step tracker `ORDER_STEPS`, headline (orderNumber + coloured status), specs: items count "آیتم", `parseFloat(totalGoldWeightGrams).toFixed(2)` گرم, `jdate(createdAt)`; items via `OrderItemCard` | `86-138` |
| Status colours | from `ORDER_STATUS_COLORS` (inline `background`/`color` styles) | `108-113, 210` |
| Empty | "سفارشی با این مشخصات پیدا نشد." when `!loading` | `59-62` |
| Pull-to-refresh | yes → `loadOrders()` | `51, 218-221` |
### 6.10 `user/TransactionsView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.getLedgerEntries(session.customerId)` GET `/api/customers/{id}/ledger-entries` | `142-150` |
| Source labels | `ORDER_STOCK` سفارش, `ORDER_PRE_ORDER` سفارش ساخت, `REPAYMENT_MELTED_GOLD` طلای آب‌شده, `REPAYMENT_TRANSFER` حواله, `REPAYMENT_CHEQUE` چک, `REPAYMENT_CASH` پولی, `WALLET_BONUS` پاداش, `ADJUSTMENT` اصلاحیه | `112-121` |
| Direction | `isCredit = direction === 'CREDIT'` → green/in arrow-down; else red/out arrow-up | `132, 31-33` |
| Amount format | `currency === 'GOLD_GRAM' ? "{amount} گرم" : "{price(amount)} ریال"` | `134-135` |
| Confirm state | `confirmed ? 'تایید شده' : 'در انتظار تایید'` | `47-49` |
| Detail drawer | height 420px, 5 rows: نوع سند / مبلغ / وضعیت / تاریخ / توضیحات (`note \|\| '—'`) | `63-97` |
| Empty | "سندی برای حساب شما ثبت نشده است." | `56-59` |
| Filters / pagination | none | — |
### 6.11 `user/ProfileView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.getCustomer(session.customerId)` (skipped entirely when signed out) | `88-96` |
| Fields (read-only) | نام و نام خانوادگی, تلفن همراه, کد ملی, وضعیت احراز هویت | `40-56` |
| Verification labels | `UNVERIFIED` احراز نشده, `STEP_ONE_SUBMITTED` مرحله اول ثبت شده, `STEP_TWO_SUBMITTED` مرحله دوم ثبت شده, `VERIFIED` تایید شده | `75-80` |
| Seal class | `is-ok` when `verificationStep === 'VERIFIED'`, else `is-pending` | `31-33, 86` |
| Empty | "اطلاعات حساب در دسترس نیست." when `!loading` | `59-62` |
| Editing / uploads | **none** — no edit form, no document upload, no bank fields | header `6-17` |
### 6.12 `user/FavoritesView.vue`
| Aspect | Detail | Line |
|---|---|---|
| API | `api.listProducts()` on mount | `55-62` |
| Filter | `allProducts.filter(p => favorites.isFavorite(p.id))` — local store, **not persisted** | `53`; `src/stores/favoritesStore.ts:9-21` |
| Count | SectionHead subtitle `${favoriteProducts.length} محصول` | `19` |
| Empty | "هنوز محصولی را به علاقه‌مندی‌ها اضافه نکرده‌اید." + CTA "دیدن محصولات" → `/home` | `25-31` |
### 6.13 `user/NotificationsView.vue`
Static: `PageBar "اعلان‌ها" back-to="home"` + empty state "اعلانی برای نمایش وجود ندارد." — no API, no store, no props (`:9-17`).
### 6.14 `user/LoginRegisterView.vue`
Shell: `showRegister` ref toggled by the `register` event from either child (`:7-13`).
---
## 7. Pinia stores
| Store | id | State | Actions | Persistence |
|---|---|---|---|---|
| `src/stores/cartStore.ts` | `cart` | `items: CartItem[] {pid, vid, product, qty, note?}` (`:8-14,32-34`) | `init()` (GET `/catalog/products`, prune dead lines `:39-55`), `addToCart` (refuses non-orderable `:56-72`), `capQty` (`:79-84`), `maxQty` (`:87-93`), `remove`, `updateFeild` (clamps `qty` `:101-114`), `find_index`, `isInCart`, `variant`, `total()` (`:124-131`), `clear()` | **localStorage key `appCart`**, JSON, written by `persist()` on every mutation (`:16-29`) |
| `src/stores/session.ts` | `session` | `customerId`, `mobile` (hydrated from localStorage `:11-12`) | `setCustomerId`, `login(customerId, mobile)`, `logout()` | localStorage keys **`gold-bank:customerId`**, **`gold-bank:mobile`**, **`appToken`** (= customerId, legacy compat) (`:20-32`) |
| `src/stores/configStore.ts` | `config` | `config.main_slider: Slide[]` (3 hardcoded placeholder slides `:39-58`), `cats: HomeCategory[]`, `loaded` | `setCats`, `init()` — derives distinct categories from `api.listProducts()` (`:73-91`) | none |
| `src/stores/favoritesStore.ts` | `favorites` | `ids: string[]` | `toggle(id)`, `isFavorite(id)` | **none — in-memory only**, lost on reload (`:1-21`) |
| `src/stores/filtersStore.ts` | `filters` | `query: {colors[], brands[], ciny_id[], limit_percent[], limit_gram[], has_discount, text?, cats?, page:0}` (`:9-31`) | `reset()` | none |
| `src/stores/navStore.ts` | `nav` | `navBarRoutes[5]`, `activeLink:1`, `showNav:true`, `pageTransition:'right'` | `setNavVisibility`, `setActiveLink`, `setPgaTransition` | none |
| `src/stores/loadingStore.ts` | `loading` | `loading:false` | `setLoading` | none — **no importers (dead)** |
| `src/stores/transitionStore.ts` | `transition` | `transitionFlag`, `transitionEnded`, `headerHeight` | `setTransitionFlag`, `setTransitionEnded`, `setHeaderHeight` | none; `transitionFlag` is inert (`src/router/index.ts:72-79`) |
| `src/stores/windowDataStore.ts` | `windowData` | `width:0`, `pullingDown:false` | `setWidth`, `setPullingDown` | none |
Unused filter fields (declared, never applied): `colors`, `limit_percent`, `ciny_id`, `has_discount`, `page` (`src/stores/filtersStore.ts:1-6`).
---
## 8. Shared utils
### `src/utils/filters.ts` — formatters
| Function | Behaviour | Line |
|---|---|---|
| `price(val)` | thousands separator via `replace(/\B(?=(\d{3})+(?!\d))/g, ',')`; returns `val` unchanged when falsy (so `0` / `''` pass through) | `7-12` |
| `priceUnit(val)` | same separator + `" تومان "` suffix | `14-19` |
| `priceUnitRialLess(val)` | same separator + trailing space | `21-26` |
| `jdate(val)` | `moment(val).format('jYYYY/jMM/jDD')` — parses ISO 8601 | `28-36` |
**Digits: Western only.** File header explicitly forbids swapping to `fa-IR` (`:1-4`). There is **no Persian↔English digit converter anywhere in `src/**`**, and **no gram/weight formatter** — weights are printed raw or via ad-hoc `parseFloat(x).toFixed(2)` (`src/views/user/OrdersView.vue:123`, `src/components/product/OrderCard.vue:35`) or `Number(x)` (`src/components/product/SingleProduct.vue:140`).
### `src/utils/validator.ts` — rules (ant-design-vue rule objects)
| Rule | Pattern / constraint | Message | Line |
|---|---|---|---|
| `message(field)` | — | `" مقدار {field} نا معتبر است "` | `3` |
| `mobile` | `/^(\+98\|0098\|98\|0)?9\d{9}$/g` | `message` | `5-8` |
| `phone` | `/^((\+98\|0098\|98\|0)\d1)?\d{8}$/g` | `message` | `9-12` |
| `fa` | `/^([؀-ۿﮊپچگ‌ ])+$/` | `" مقدار {field} باید فارسی باشد "` | `13-16` |
| `mail` | `type:'email'` | `message` | `17` |
| `url` | `type:'url'` | `message` | `18` |
| `username` | `/^(?!.*\.\.)(?!.*\.$)[^\W][\w.]{0,29}$/gim` | `" مقدار {field} باید شامل حروف و اعداد انگلیسی و _ باشد "` | `19-22` |
| `required` | `required:true` | `" لطفا {field} را وارد کنید "` | `23` |
| `sheba` | `/^IR\d{24}$/gim` | `message` | `24` |
| `card` | `/^\d{16}$/gim` | `message` | `25` |
| `zip` | `/^\d{10}$/gim` | `message` | `26` |
| `num` | `/^\d*$/gim` | `message` | `27` |
| `len(field,len)` | `len` | `" مقدار {field} باید {len} کارکتر باشد "` | `28` |
| `max(field,max)` | `max` | `" مقدار {field} باید کمتر از {max} کارکتر باشد "` | `29` |
| `min(field,min)` | `min` | `" مقدار {field} باید بیشتر از {min} کارکتر باشد "` | `30` |
Only `required` and `mobile` have call sites (LoginView, RegisterView).
### `src/utils/orderStatus.ts` — enums (exact values)
| Const | Content | Line |
|---|---|---|
| `ORDER_STATUS_LABELS` | `SUSPENDED:'معلق'`, `PLACED:'ثبت سفارش'`, `ADMIN_APPROVED:'تایید مدیر سیستم'`, `SOURCING:'درحال تامین'`, `PREPARING:'درحال آماده سازی'`, `DELIVERED_TO_AGENT:'تحویل مسئول فروش'`, `DELIVERED_TO_CUSTOMER:'تحویل مشتری'`, `CANCELLED:'لغو شده'` | `8-17` |
| `ORDER_STATUS_COLORS` | `PLACED {#3B82F6,#EFF6FF}`, `SOURCING {#EAB308,#FEFCE8}`, `PREPARING {#F97316,#FFF7ED}`, `DELIVERED_TO_AGENT {#06B6D4,#ECFEFF}`, `DELIVERED_TO_CUSTOMER {#22C55E,#F0FDF4}` — SUSPENDED/ADMIN_APPROVED/CANCELLED deliberately uncoloured | `23-29` |
| `ORDER_STEPS` | `['ثبت سفارش','آماده سازی','تحویل مشتری','لغو شده']` | `35` |
| `orderStepIndex(status)` | PLACED/ADMIN_APPROVED/SUSPENDED→0; SOURCING/PREPARING→1; DELIVERED_TO_AGENT/DELIVERED_TO_CUSTOMER→2; CANCELLED→3; default 0 | `37-54` |
### Other constants
| Const | Value | Line |
|---|---|---|
| `VERIFICATION_LABELS` | UNVERIFIED/STEP_ONE_SUBMITTED/STEP_TWO_SUBMITTED/VERIFIED → احراز نشده / مرحله اول ثبت شده / مرحله دوم ثبت شده / تایید شده | `src/views/user/ProfileView.vue:75-80` |
| `SOURCE_LABELS` | ledger source types (see §6.10) | `src/views/user/TransactionsView.vue:112-121` |
| `isUserLoggedIn()` | `localStorage.getItem('appToken')` | `src/utils/checkAccess.ts:4-6` |
| Theme colours | `primary #1C2D56`, `secondary #8D6A46`, `danger #E31A1A`, `ink #14161A`, `wine #7A1B33`, `canvas #FFFFFF`, `hairline #ECEDEF` (+ 30 more) | `themeConfig/colors.json:1-38` |
| Theme vars | `prefix: 'montzar'`, `font-size 14px`, `border-radius-base 5px`, `height-lg 42px`, `checkbox-size 20px` | `themeConfig/themeConfig.ts:15-43` |
---
## 9. Components (21 files + 1 icons.json)
| File | Purpose / API |
|---|---|
| `src/components/icon/Icon.vue` | Inline-SVG icon from `icons.json` map; props `icon, color?, size?`; `tempIcon` is a computed (bug-fix noted `:19-34`), `applySize()` sets width/height after `nextTick` (`:36-47`). |
| `src/components/input/OtpInput.vue` | **OTP input.** Props `defaultFocus, numInputs=4, separator, inputClasses, inputType, shouldAutoFocus=false, placeholder, disabled`; emits `on-change`, `on-complete`. Keycodes BACKSPACE 8 / LEFT 37 / RIGHT 39 / DELETE 46 (`:6-9`); paste handling `:67-83`; completion when joined length === numInputs (`:42-47`). |
| `src/components/input/SingleOtpInput.vue` | One OTP cell: `maxlength=1`, `pattern="[0-9]"`, `min=0 max=9`, default `inputType='tel'`; only numeric keycodes + `[8,9,13,37,39,46,86]` pass (`:62-75`); focus selects (`:79-82`). |
| `src/components/search/SearchInput.vue` | Debounced search field, `useDebounce(value, 700)` (`:42`); emits `search` on enter, icon click and debounce; clear button. **Known bug preserved:** watcher tests `if (value)` (the ref object, always truthy) not `value.value` — clear icon never auto-hides (`:8-11, 43-47`). |
| `src/components/search/Filters.vue` | Filter sheet. Brand toggle-chips derived from products (`:95-101`); gram range via ant `Slider` `range reverse :step="0.01"`, bounds `[floor(min), ceil(max)]` of all `rashWeightGrams`, fallback `[0,100]` (`:103-107`), seeded once when `limit_gram` empty (`:114-122`). Buttons "پاک کردن فیلتر‌ها" (`resetFilters:true`) / "اعمال فیلتر" (`:75-77, 131-150`). Dropped: colour filter, wage-percent range, discount toggle (`:18-25`). |
| `src/components/ui/DropDown.vue` | Accordion, props `text?`, local `show` ref (`:24-30`). |
| `src/components/ui/Navigation.vue` | Bottom tab bar (§4). |
| `src/components/ui/PageBar.vue` | Sticky bar; props `title`, `back=true`, `backTo?`; slots `trailing`, `below`; `onBack` = `router.push({name: backTo})` else `router.back()` (`:34-50`). |
| `src/components/ui/Container.vue` | `<main class="px-[16px] max-w-screen-md m-auto ...">`; registered globally as `container` (`src/main.ts:31`). |
| `src/components/ui/ContentWrapper.vue` | margin-top from `transitionStore.headerHeight` — **no importers**. |
| `src/components/ui/ScrollFetch.vue` | Infinite scroll via `useInfiniteScroll(distance:50)`, default EmptyState — **no importers**. |
| `src/components/ui/ScrollFetchSearch.vue` | Same, `favoritEmpty.svg` image; used by `NewestView.vue:41`. |
| `src/components/ui/ScrollFetchAfterSearch.vue` | Same, `ordersEmpty.svg` — **no importers**. |
| `src/components/Spinner.vue` | ant `Spin` wrapper; props `tip, size='large', cls, delay, spinning, indicator, fixed=true`; brand-logo indicator by default. |
| `src/components/DialogVant.vue` | vant Dialog, 2-column button grid; slots `img/title/button`; **`show` is not a declared prop** — works via attribute fallthrough (`:1-10`). |
| `src/components/DialogVantOneButton.vue` | Single-action variant; used by `src/App.vue:22`. |
| `src/components/NotifyVant.vue` | vant Notify toast; props `modelValue, timeOut, text?, color?`; auto-hides after `timeOut` unless `-1` (`:34-44`). |
| `src/components/EmptyState.vue` | ant `Empty description="موردی یافت نشد"`, optional image. |
| `src/components/ViewAll.vue` | "مشاهده همه" router-link — **no importers**. |
| `src/components/product/SingleProduct.vue` | Product detail (route + drawer). See §6 notes below. |
| `src/components/product/ProductCard.vue` | Catalogue card: image (v-lazy), fav toggle, `ناموجود` / `پیش‌سفارش` flag, title, `{min_gram} گرم`, `اجرت {wage}٪`; opens its own 100vh Drawer with `SingleProduct` (`:53-62`). |
| `src/components/product/CartItem.vue` | Cart line: qty stepper (`+` disabled when `!canIncrement`; at qty 1 the `-` becomes a trash/remove), note button toggling "توضیحات"/"ویرایش توضیحات" (`:32-63, 82-91`). |
| `src/components/product/OrderCard.vue` | Order row: coloured mark + status pill, `{items.length} آیتم`, `toFixed(2) گرم`, `jdate(createdAt)`, "جزئیات" (`:15-48`). |
| `src/components/product/OrderItemCard.vue` | Order line: image via `useOrderItemProduct` lookup, `{quantity} عدد`, `{computedWeightGrams} گرم`, `{wagePercentSnapshot} ٪ اجرت` (`:30-43`). |
| `src/components/product/GallerySlider.vue` | Pointer-Events swipe gallery; `v-model:index`; props `slides, alt, fit='cover', showCounter=false`; emits `tap`; `DRAG_COMMIT_RATIO 0.18`, `TAP_MAX_PX 6`; track pinned `direction: ltr` for RTL correctness (`:74-192`). |
| `src/components/product/Category.vue` | Legacy category tile with `id === '45'` special-case — **no importers** (`:1-45`). |
| `src/components/home/RateStrip.vue` | **Price ticker.** Props `quote: GoldPriceQuote\|null`; label "نرخ طلای ۱۸ عیار"; value `price(pricePerGramRial.split('.')[0])` + "ریال / گرم"; `jdate(quotedAt)`; placeholder "در انتظار اعلام نرخ" when null (`:6-33`). |
| `src/components/home/AccountStrip.vue` | Balance strip with 3 actions — **no importers** (dead). |
| `src/components/home/CategoryRail.vue` | Horizontal category chips; emits `select`. |
| `src/components/home/ShowcaseCard.vue` | Editorial card; props `value, wide?, badge?`; fav toggle; weight + wage; no price (`:10-48`). |
| `src/components/home/ShowcaseSlider.vue` | Dependency-free hero slider; autoplay 6000 ms (`:81-84`), pointer drag, `DRAG_COMMIT_RATIO 0.18`, `TAP_MAX_PX 6` (`:102-103`), dots; `ShowcaseSlide {key, eyebrow, title, subtitle, cta, image?, categoryId?, link?}` (`:55-66`). |
| `src/components/home/SectionHead.vue` | Title + subtitle + optional action button. |
| `src/components/slider/HomeSlider.vue` | Swiper banner + popup drawer; slide types `popup`/`link`/`discount`; preserved bug: sets `query.discount` instead of `has_discount` (`:83-92`) — **no importers**. |
| `src/components/slider/Slider.vue` | Swiper gallery + zoom drawer + GSAP-era SVG curve maths; preserved bug: `showPopup(item)` called with one arg so `index` is always undefined (`:1-9, 60-67`) — **no importers**. |
**Missing component categories (do not exist in this app):** numeric/price input with thousands-separator masking, amount/gram input, date picker, file uploader, image uploader.
### Composables
| File | Purpose | Key detail |
|---|---|---|
| `src/composables/useProduct.ts` | Adapts `ProductSummary` → card contract | `isVariantOrderable = qty>0 \|\| isPreOrder` (`:25-27`); `getCheapestVariant` = lowest `defaultWagePercent` among orderable, fallback all (`:29-40`); `hasDiscountWage` always null (`:56`); `has_warranty` always false (`:64`) |
| `src/composables/useProductDetail.ts` | "Detail" fetch = full list + client-side `find` (no single-product endpoint) | `:16-29` |
| `src/composables/useProducts.ts` | List + fake pagination; `totalPages` always 0; `getProducts(loading, isPull=true)` returns immediately | `:24-35` |
| `src/composables/useOrderItemProduct.ts` | Module-level cached `Map<variantId, {productTitle, variantTitle, imageUrl}>` built from `listProducts()` | `:14-31` |
| `src/composables/useRefresh.ts` | Auto-applies a waiting service worker | `:7-17` |
### Layouts
| File | Behaviour |
|---|---|
| `src/layout/MasterLayout.vue` | `<router-view/>` + `<Navigation/>` + a primary-coloured overscroll strip; tracks width via `useResizeObserver` → `windowDataStore`. Known issue: it renders its own `router-view`, so App.vue's keep-alive/suspense wrapper is bypassed (`:3-7`). |
| `src/layout/FullPageLayout.vue` | Bare `<router-view/>` — selected by `meta.layout === 'FullPage'` (`src/App.vue:57-62`). |
---
## 10. Live price / sockets / polling
| Aspect | Finding |
|---|---|
| Mechanism | **Pull-on-mount only.** `api.getLatestGoldPrice()` (GET `/api/pricing/latest?karatPerMille=750`) is called once per view mount. |
| Call sites | `src/views/HomeView.vue:252` (+ pull-to-refresh `:266-269`); `src/views/user/DashboardView.vue:213` (+ pull-to-refresh `:219-222`). |
| WebSocket / SSE | **none** — no `WebSocket`, `socket.io`, `EventSource` anywhere in `src/**`. |
| Polling | **none** — no `setInterval` refresh for price. The only `setInterval` is the hero autoplay (`src/components/home/ShowcaseSlider.vue:81`); the only price-adjacent `setTimeout` is the OTP countdown (`src/views/user/LoginView.vue:115`). |
| Display | `src/components/home/RateStrip.vue:13-19` — integer part only (`split('.')[0]`), thousands-separated, `ریال / گرم`, with Jalali `quotedAt`. |
| Karat | fixed 750 (18k) default; no karat selector anywhere. |
| Multi-row ticker | explicitly dropped (`src/views/user/DashboardView.vue:28-31`). |
---
## 11. Env / config / PWA
| Item | Value | Line |
|---|---|---|
| `envDir` | repo root (`../..`) | `vite.config.ts:9,15` |
| `USER_APP_PORT` | 5181 (fallback in config), declared in root `.env` | `vite.config.ts:11`; `.env:26` |
| `API_PORT` | 3000 | `vite.config.ts:12`; `.env:24` |
| Dev server | `host 0.0.0.0`, `strictPort: true` | `vite.config.ts:44-49` |
| Proxy | `/api` → `http://localhost:3000`, `/uploads` → same | `vite.config.ts:50-53` |
| `import.meta.env.*` | **no `VITE_*` variables are read anywhere in `src/**`** | — |
| Less vars | `modifyVars: $themeLessVars`, `javascriptEnabled: true` | `vite.config.ts:55-62` |
| PWA strategy | `VitePWA` `injectManifest`, `srcDir: 'src'`, `filename: 'sw.ts'`, `base '/'`, `includeAssets ['favicon.ico']`, `devOptions.enabled true` | `vite.config.ts:20-27` |
| PWA manifest | name/short_name/description `Montzar`; `theme_color`/`background_color` = `$themeLessVars.primary` (`#1C2D56`); `start_url '/'`; `display 'standalone'`; icons 192 + 512 + 512-maskable | `vite.config.ts:28-41` |
| Service worker | `precacheAndRoute(self.__WB_MANIFEST)` + `SKIP_WAITING` message handler | `src/sw.ts:9-14` |
| Auto-update | `useRefresh()` applies a waiting SW immediately | `src/composables/useRefresh.ts:8-16` |
| Version dialog | localStorage key `version` vs `package.json` version → "نسخه جدید منتزر منتشر شد!" + "به روز رسانی" (updateSW + reload) | `src/App.vue:66-81, 33-38` |
| `public/manifest.json` | `{ "gcm_sender_id": "464066939468" }` only (legacy FCM leftover; not linked from `index.html`) | `public/manifest.json:1-3` |
| `index.html` | title `طلای منتزر`, `theme-color #1C2D56`, `maximum-scale=1.0, user-scalable=no`, apple web-app meta | `index.html:6-11` |
| localStorage keys (all) | `appCart`, `gold-bank:customerId`, `gold-bank:mobile`, `appToken`, `version` | `cartStore.ts:16`; `session.ts:22-25`; `App.vue:69` |
| Global registrations | Pinia, router, `vue3-lazyload` (loading+error placeholder = `montzar-logo.png`), global `container` component | `src/main.ts:22-31` |
| Style imports | `tailwind.css` → `ant-design-vue/dist/antd.less` → `style.less` → `vant/lib/index.css` | `src/main.ts:15-18` |
| Docker | `Dockerfile`, `nginx.conf`, `.dockerignore` present at app root | — |
---
## 12. CANDIDATE PROTECTED — new features / new design not in a 2023-era reference PWA
These are deliberate additions or redesigns in the target. Treat as protected unless explicitly told otherwise.
### 12.1 Entirely new pages / routes
| Item | Path(s) | Evidence |
|---|---|---|
| **Notifications page** | route `/notifications` name `notifications`; `src/views/user/NotificationsView.vue` | `src/router/index.ts:39`; view header states "New nav destination (bottom nav redesign) — no legacy equivalent" (`NotificationsView.vue:1-6`) |
### 12.2 New / redesigned widgets and sections
| Item | Path | Evidence |
|---|---|---|
| **Live 18k rate strip** (`RateStrip`) on home + dashboard | `src/components/home/RateStrip.vue`; used `src/views/HomeView.vue:44`, `src/views/user/DashboardView.vue:118` | Replaces legacy's multi-row `GET /price` ticker (`DashboardView.vue:28-31`) |
| **Editorial hero slider** (dependency-free, pointer-drag, 6 s autoplay) | `src/components/home/ShowcaseSlider.vue` | Replaces the Swiper `HomeSlider` (now unused) — `ShowcaseSlider.vue:1-13` |
| **Admin-managed home banner** consuming `GET /home/slider` | `src/api/client.ts:168-170`; `src/views/HomeView.vue:155, 176-201, 257-263` | New endpoint + new fallback-to-category hero logic |
| **ShowcaseCard / ShowcaseSlider / CategoryRail / SectionHead / AccountStrip** — whole `components/home/` family | `src/components/home/*` | New design-system components; `ShowcaseCard.vue:1-9` |
| **"برگزیده منتزر" featured section** (wide hero card + 2-col grid) | `src/views/HomeView.vue:56-68` | New home section |
| **"تازه‌ها" horizontal reel** on home | `src/views/HomeView.vue:70-86` | New home section |
| **Trust badges row** (خرید امن / اصالت کالا / ارسال بیمه‌شده) | `src/views/HomeView.vue:93-106`; also on product detail `src/components/product/SingleProduct.vue:193-196` | New widget |
| **`PageBar`** — shared translucent sticky bar replacing the legacy dark AppHeader | `src/components/ui/PageBar.vue:1-9` | Whole-app chrome redesign; AppHeader deleted (`src/router/index.ts:74-79`) |
| **Redesigned 5-tab bottom nav** with CSS sliding indicator (replaces 4-tab GSAP SVG notch) | `src/components/ui/Navigation.vue:1-10`; `src/stores/navStore.ts:25-31` | Explicit "Redesigned bottom nav — replaces the old GSAP SVG-notch bar" |
| **`GallerySlider`** — Pointer-Events gallery rewrite with LTR-pinned track + full-screen teleported preview with thumbnails | `src/components/product/GallerySlider.vue:1-47`; preview `src/components/product/SingleProduct.vue:239-274` | From-scratch rewrite, not a port |
| **Variant chips with per-variant images + stock badges + "نمایش همه" collapse** (`VARIANT_PREVIEW_COUNT = 6`) | `src/components/product/SingleProduct.vue:105-154, 370-374, 383-393` | New variant-picker design |
| **Stock / pre-order model in UI** (`qty`, `isPreOrder`, `isVariantOrderable`, cart `capQty`/`maxQty` clamping, disabled `+`) | `src/composables/useProduct.ts:25-27,57-62`; `src/stores/cartStore.ts:56-93`; `src/components/product/CartItem.vue:82-88`; `src/components/product/SingleProduct.vue:466-480` | New business rule not in a 2023 reference |
| **Pull-to-refresh** (vant `PullRefresh`) on Home, Cart, Search, Orders, Dashboard | `HomeView.vue:38-42`; `CartView.vue:35-39`; `SearchView.vue:65-69`; `OrdersView.vue:51`; `DashboardView.vue:51` | New interaction |
| **IntersectionObserver incremental rendering** (`PAGE_SIZE = 20`) replacing server pagination | `src/views/search/SearchView.vue:169-204`; `src/views/NewestView.vue:49-71` | New rendering strategy |
| **Ledger-based "اسناد" screen** (LedgerEntry + CREDIT/DEBIT + confirmed state + detail drawer) | `src/views/user/TransactionsView.vue:1-19, 112-140` | Replaces legacy Tahesab/Cash documents |
| **Order detail as bottom Drawer with 4-step tracker + `OrderItemCard` lookup join** | `src/views/user/OrdersView.vue:67-141`; `src/composables/useOrderItemProduct.ts` | Redesigned flow (was a full-screen ant Modal) |
| **Order status chips using the new `OrderStatus` enum (9 chips)** | `src/views/user/OrdersView.vue:167-177`; `src/utils/orderStatus.ts:8-17` | New enum, new labels |
| **Dotlottie "new version" update dialog** | `src/App.vue:22-39, 46` (`@lottiefiles/dotlottie-vue`, remote lottie.host asset) | New dependency + new UX |
| **Balance card with بدهکار/بستانکار seal** on dashboard | `src/views/user/DashboardView.vue:55-78, 176-180` | Redesigned; also duplicated in unused `AccountStrip.vue` |
| **Brand-logo Spinner indicator** | `src/components/Spinner.vue:5` | New brand chrome |
### 12.3 New/changed data contracts worth protecting
| Item | Path |
|---|---|
| `GET /home/slider` + `AdminSlide` type | `src/api/client.ts:152-157, 168-170` |
| `ProductVariantSummary.qty / isPreOrder / imageId / image` | `src/api/client.ts:29-40` |
| `ProductSummary.productCode / collections[]` | `src/api/client.ts:65-74` |
| `GoldPriceQuote` (`karatPerMille`, `pricePerGramRial`, `quotedAt`) | `src/api/client.ts:144-150` |
| `session` localStorage schema `gold-bank:*` alongside legacy `appToken` | `src/stores/session.ts:11-32` |
### 12.4 Deliberate regressions / stubs (documented in-file — verify before "fixing")
| Item | Path |
|---|---|
| OTP code is **not** verified; any 4 digits log in as the matched customer | `src/views/user/LoginView.vue:152-160` |
| Registration posts nothing (no create-customer endpoint) | `src/views/user/RegisterView.vue:117-125` |
| Favourites are in-memory only, no backend | `src/stores/favoritesStore.ts:1-5` |
| Placeholder home slides in `configStore` (used only if `HomeSlider` were mounted — it isn't) | `src/stores/configStore.ts:39-58` |
| `SearchInput` clear-icon truthiness bug preserved | `src/components/search/SearchInput.vue:8-11` |
| `HomeSlider` `query.discount` vs `has_discount` bug preserved | `src/components/slider/HomeSlider.vue:85-90` |
| `Slider.vue` `showPopup` index bug preserved | `src/components/slider/Slider.vue:2-9` |
| `MasterLayout` bypasses App.vue's keep-alive/suspense | `src/layout/MasterLayout.vue:3-7` |
| `DialogVant` / `DialogVantOneButton` rely on attribute fallthrough for `show` | `src/components/DialogVant.vue:2-9` |
### 12.5 Dead code (no importers) — candidates for removal, not parity gaps
`src/components/home/AccountStrip.vue`, `src/components/product/Category.vue`, `src/components/slider/HomeSlider.vue`, `src/components/slider/Slider.vue`, `src/components/ui/ContentWrapper.vue`, `src/components/ui/ScrollFetch.vue`, `src/components/ui/ScrollFetchAfterSearch.vue`, `src/components/ViewAll.vue`, `src/stores/loadingStore.ts`, `api.getBalance` (`src/api/client.ts:163`), `gsap` dependency (`package.json:15`).
---
## Summary counts
| Metric | Count |
|---|---|
| Named routes | 13 (+2 redirects) |
| View files under `src/views` | 14 |
| API functions | 9 (1 uncalled) |
| Pinia stores | 9 (1 unused) |
| Reusable components | 34 `.vue` under `src/components` (8 with no importers) |
| Composables | 5 |
| Util modules | 4 |
| Persisted localStorage keys | 5 |
+700
View File
@@ -0,0 +1,700 @@
# Inventory — apps/user-app-v2 (target user app v2)
Read-only evidence inventory. All `path:line` references are relative to `apps/user-app-v2/`.
Scope: every file under `src/`, plus root config. `node_modules`, `dist` ignored.
Totals: **15 route entries** (13 named + 2 redirects) · **14 view files** · **8 API functions** · **9 Pinia stores** · **41 components** · **6 composables**.
---
## 1. Stack & versions
Source: `package.json:1-38`.
| Field | Value | Line |
|---|---|---|
| name / version / private | `user-app-v2` / `0.0.1` / true | `package.json:2-4` |
| scripts | `dev: vite` · `build: vue-tsc -b && vite build` · `lint: eslint "src/**/*.{ts,vue}"` | `package.json:6-8` |
| vue | `^3.5.0` | `package.json:20` |
| vue-router | `^4.4.0` | `package.json:21` |
| pinia | `^2.2.0` | `package.json:17` |
| ant-design-vue | `^3.2.20` | `package.json:14` |
| vant | `^4.0.3` | `package.json:19` |
| swiper | `^7.4.1` | `package.json:18` |
| gsap | `^3.9.1` (declared; **no import anywhere in `src/`** — dead dep) | `package.json:15` |
| moment-jalaali | `^0.10.0` | `package.json:16` |
| @vueuse/core | `^9.13.0` | `package.json:13` |
| vue3-lazyload | `^0.2.5-beta` | `package.json:22` |
| @lottiefiles/dotlottie-vue | `^0.4.15` | `package.json:23` |
| @ant-design/icons-vue | `^6.1.0` (declared; unused in `src/` — app uses own `Icon.vue`) | `package.json:11` |
| @gold-bank/shared-types | `workspace:*` | `package.json:12` |
| vite / plugin-vue | `^5.4.0` / `^5.1.0` | `package.json:32`, `:26` |
| vite-plugin-pwa | `^0.20.5` | `package.json:34` |
| workbox-precaching / -window | `^7.1.0` / `^7.1.0` | `package.json:35-36` |
| tailwindcss | pinned exact `3.0.18` (no caret) | `package.json:30` |
| less / postcss / autoprefixer | `^4.1.2` / `^8.4.5` / `^10.4.2` | `package.json:28`, `:29`, `:27` |
| typescript / vue-tsc | `^5.5.0` / `^2.1.0` | `package.json:31`, `:33` |
### 1a. Difference vs sibling `apps/user-app`
`apps/user-app/package.json` is **byte-identical** to this one (same deps, same versions, same scripts; only `"name"` differs). The divergence is entirely in `src/`, not in dependencies.
Notable: there is **no test runner, no eslint dependency** (the `lint` script invokes `eslint` which is not a declared dep), and **no axios** — HTTP is raw `fetch` (`src/api/client.ts:6`).
TS config: `tsconfig.json:1-14` extends `../../tsconfig.base.json`; `target ES2020`, `module ESNext`, `moduleResolution bundler`, `noEmit: true`, `include: ["src"]`. Ambient types at `src/env.d.ts:1-9` (`vite/client`, `vite-plugin-pwa/client`, `vite-plugin-pwa/vue`, `*.vue` shim), plus `src/types/moment-jalaali-shim.d.ts` and `src/types/swiper-shim.d.ts`.
---
## 2. HTTP layer
Single file: `src/api/client.ts` (146 lines). No axios, no service-per-domain split.
| Aspect | Reality | Evidence |
|---|---|---|
| Base URL | Hardcoded constant `const BASE = '/api'` — relative, resolved by the Vite dev proxy or nginx | `src/api/client.ts:3` |
| Transport | Native `fetch`, wrapped in `request<T>()` | `src/api/client.ts:5-15` |
| Default headers | `{'Content-Type': 'application/json'}`, spread-overridable by caller `options` | `src/api/client.ts:7-8` |
| **Request interceptor** | **NONE** | — |
| **Response interceptor** | **NONE** | — |
| **Auth token attach** | **NONE.** No `Authorization` header is ever sent. `customerId` is passed as a URL path segment / body field instead | `src/api/client.ts:6-9` |
| **Token refresh** | **NONE.** No refresh logic exists anywhere | — |
| **401 handling** | **NONE.** `!res.ok` throws a generic `Error` regardless of status; no redirect to login on 401 | `src/api/client.ts:10-13` |
| **Error toast** | **NONE at the HTTP layer.** Errors bubble as thrown `Error`; every call site either `catch{console.log(e)}` or lets it escape | `src/api/client.ts:12` |
| Error message shape | `` `API ${path} failed (${res.status}): ${body}` `` where `body = await res.text()` | `src/api/client.ts:12` |
| Credentials / cookies | Not set (`credentials` never passed) → browser default `same-origin` | `src/api/client.ts:6-9` |
| Timeout / retry / abort | None | — |
### 2a. Every API function
All exported on the `api` object at `src/api/client.ts:137-146`.
| # | Function | Method | URL | Params / payload | Return type | Line |
|---|---|---|---|---|---|---|
| 1 | `listProducts()` | GET | `/api/catalog/products` | none | `ProductSummary[]` | `:138` |
| 2 | `listCustomers()` | GET | `/api/customers` | none | `CustomerSummary[]` | `:139` |
| 3 | `getCustomer(id)` | GET | `/api/customers/{id}` | path `id: string` | `CustomerDetail` | `:140` |
| 4 | `getBalance(customerId)` | GET | `/api/accounting/customers/{customerId}/balance` | path `customerId: string` | `CustomerBalanceDto` | `:141` |
| 5 | `getLedgerEntries(customerId)` | GET | `/api/customers/{customerId}/ledger-entries` | path `customerId: string` | `LedgerEntrySummary[]` | `:142` |
| 6 | `createOrder(dto)` | **POST** | `/api/orders` | JSON body `CreateOrderDto` = `{customerId, items:[{productVariantId, quantity, wagePercent}]}` | `OrderSummary` | `:143` |
| 7 | `getCustomerOrders(customerId)` | GET | `/api/customers/{customerId}/orders` | path `customerId: string` | `OrderDetail[]` | `:144` |
| 8 | `getLatestGoldPrice(karatPerMille = 750)` | GET | `/api/pricing/latest?karatPerMille={n}` | query `karatPerMille`, **default 750** | `GoldPriceQuote` | `:145` |
**`getBalance` (#4) is declared but never called anywhere in `src/`** — dead. Balance is read from `getCustomer().goldDebtGrams` / `.fiatDebtRial` instead (`src/views/user/DashboardView.vue:24-28`).
### 2b. Response DTO shapes (declared locally, not from shared-types)
| Interface | Fields | Line |
|---|---|---|
| `ProductVariantSummary` | `id, title, rashWeightGrams, defaultWagePercent, qty?, isPreOrder?` | `:17-28` |
| `CategorySummary` | `id, title, parentId` | `:30-34` |
| `BrandSummary` | `id, title` | `:36-39` |
| `CollectionSummary` | `id, title, description, isActive` | `:41-46` |
| `ProductSummary` | `id, title, description, productCode?, variants[], images: Array<{url}>, category, brand, collections[]` | `:48-60` |
| `LedgerEntrySummary` | `id, currency, direction, sourceType, amount, confirmed, createdAt, note` | `:62-71` |
| `CustomerSummary` | `id, fullName, mobile, goldDebtGrams, fiatDebtRial, goldCreditLimitGrams, isActive, isArchived` | `:73-82` |
| `OrderSummary` | `id, orderNumber, status, totalGoldWeightGrams, isSuspendedOverCreditLimit` | `:84-90` |
| `OrderItemDetail` | `id, productVariantId, quantity, rashWeightGramsSnapshot, wagePercentSnapshot, discountPercentSnapshot, computedWeightGrams, flatRialCharge` | `:92-101` |
| `OrderDetail` | `id, orderNumber, status, totalGoldWeightGrams, wageWeightGrams, flatRialCharge, isSuspendedOverCreditLimit, deliveredAt, createdAt, items[]` | `:103-114` |
| `CustomerDetail` | `id, fullName, mobile, nationalCode, cityId, goldCreditLimitGrams, goldDebtGrams, fiatDebtRial, verificationStep, createdAt` | `:116-127` |
| `GoldPriceQuote` | `id, karatPerMille, pricePerGramRial, source, quotedAt` | `:129-135` |
---
## 3. Router — every route
Source: `src/router/index.ts:8-46`. History mode: `createWebHistory()` (`:9`). Lazy `() => import(...)` for every component.
| path | name | component file | meta |
|---|---|---|---|
| `/home` | `home` | `src/views/HomeView.vue` | *(none)* → MasterLayout, no guard | `:18` |
| `/product/:id` | `singleProduct` | `src/components/product/SingleProduct.vue` (`props: true`) | `layout: 'FullPage'` | `:20-25` |
| `/newest` | `newestProduct` | `src/views/NewestView.vue` | `layout: 'FullPage'` | `:26` |
| `/cart` | `cart` | `src/views/CartView.vue` | *(none)* → MasterLayout, **no auth guard** | `:27` |
| `/login` | `login` | `src/views/user/LoginRegisterView.vue` | `layout: 'FullPage'` | `:28` |
| `/user` | `userPage::hasSubRoute` | `src/views/user/DashboardView.vue` | *(none)* → **no auth guard** (renders a signed-out invite state) | `:29` |
| `/orders` | `ordersList` | `src/views/user/OrdersView.vue` | `requiresAuth: true` | `:30` |
| `/transactions` | `transactionsList` | `src/views/user/TransactionsView.vue` | `requiresAuth: true` | `:31-36` |
| `/profile` | `profileData` | `src/views/user/ProfileView.vue` | `requiresAuth: true` | `:37` |
| `/favorites` | `favoritesList` | `src/views/user/FavoritesView.vue` | `requiresAuth: true` | `:38` |
| `/notifications` | `notifications` | `src/views/user/NotificationsView.vue` | *(none)* — **no auth guard** | `:39` |
| `/search` | `search` | `src/views/search/SearchView.vue` | *(none)* | `:40` |
| `/collection/:id?` | `collection` | `src/views/search/CollectionView.vue` | *(none)*; reads `?page=<title>` query for the bar title | `:41` |
| `/` | — | *redirect* → `{name:'home'}` | — | `:43` |
| `/:pathMatch(.*)*` | — | *catch-all redirect* → `{name:'home'}` | — | `:44` |
**No route declares a `title` meta and no route declares a level/KYC requirement.** The only meta keys in use are `layout` and `requiresAuth`.
`scrollBehavior` (`:12-15`): returns `savedPosition` on back/forward, else `{top: 0}`.
---
## 4. Navigation (bottom tab bar)
Items are defined as store state at `src/stores/navStore.ts:25-31`, rendered by `src/components/ui/Navigation.vue:11-31`.
| index | label | route name | path | icon | gating |
|---|---|---|---|---|---|
| 1 | خانه | `home` | `/home` | `vuesax-linear:home-2` | none |
| 2 | جستجو | `search` | `/search` | `vuesax-linear:search-normal-1` | none |
| 3 | سبد خرید | `cart` | `/cart` | `vuesax-linear:bag-happy` | none |
| 4 | سفارشات | `ordersList` | `/orders` | `vuesax-linear:receipt-text` | **`requiresAuth`** → tapping while signed out redirects to `/login?redirect=/orders` |
| 5 | پروفایل | `userPage::hasSubRoute` | `/user` | `vuesax-linear:user` | none (Dashboard shows a signed-out invite) |
Mechanics:
- The tab list is **not filtered by auth** — all five always render (`Navigation.vue:20-28`); gating happens only in the router guard.
- Visibility: `v-show="nav.showNav"` (`Navigation.vue:12`). `showNav` is written by `router.afterEach` (`src/router/index.ts:95-101`): visible only when the destination route name is one of the five `navBarRoutes`, otherwise hidden.
- Active indicator: pure CSS transform `translateX(-(activeLink-1)*100%)` — negative because the app is RTL (`Navigation.vue:40-44`).
- **Stale comment**: `Navigation.vue:1-10` documents "5 tabs (Home, Search, Likes, Notifications, Profile)" which does not match the actual store list (Home, Search, Cart, Orders, Profile). Cosmetic only.
- Dashboard also offers 4 in-page nav rows → `ordersList` / `transactionsList` / `profileData` / `favoritesList` via `goToDetails(id)` (`src/views/user/DashboardView.vue` script `:32-41`).
---
## 5. Guards
All guard logic lives in `src/router/index.ts`. **There is no KYC guard, no level/tier guard, and no role guard.**
| Guard | Rule | Line |
|---|---|---|
| `beforeEach` — already-logged-in | `to.name === 'login' && session.customerId` → redirect `{name:'home'}` | `:56-58` |
| `beforeEach` — auth required | `to.meta.requiresAuth && !session.customerId` → redirect `{name:'login', query:{redirect: to.fullPath}}` | `:59-61` |
| `beforeEach` — page transition | `to.name === 'singleProduct'` → `nav.setPgaTransition('slideDown')`, else `undefined` | `:64-65` |
| `afterEach` — transition flag | `setTransitionFlag(false)`, `setTransitionEnded(false)`, then `setTransitionFlag(true)` after 100ms. File comment `:73-79` states this half is **inert** (AppHeader was deleted, nothing reads `transitionFlag`) | `:84-88` |
| `afterEach` — tab bar | `setActiveLink(index)` + `setNavVisibility(true)` when the destination is a nav route; `setNavVisibility(false)` otherwise | `:95-101` |
Redirect consumption: `LoginView.vue` reads `route.query.redirect` and pushes it after a successful OTP step, falling back to `{name:'home'}` (`src/views/user/LoginView.vue:161-162`).
Auth source of truth: `useSessionStore().customerId`, hydrated from `localStorage['gold-bank:customerId']` at store creation (`src/stores/session.ts:11`). A separate legacy-compat key `appToken` is written on login (`session.ts:25`) and read by `isUserLoggedIn()` (`src/utils/checkAccess.ts:5`), which is used only by SingleProduct's add-to-cart gate (`SingleProduct.vue` template `:237`).
**Gap**: KYC/verification exists in the data (`CustomerDetail.verificationStep`, labels at `src/views/user/ProfileView.vue:57-62`) but is **displayed only** — nothing is gated on it.
---
## 6. Views — every file under `src/views` (recursive)
| # | File | Purpose (one line) |
|---|---|---|
| 1 | `src/views/HomeView.vue` (348 L) | Home tab: brand bar + live 18k rate, hero coverflow of categories, category grid, "محصولات جدید" carousel + vertical list, product drawer. |
| 2 | `src/views/CartView.vue` (490 L) | Basket: item rows, per-item note sheet, total weight, confirm dialog, POST order, success step. |
| 3 | `src/views/NewestView.vue` (120 L) | Full "جدیدترین محصولات" grid with client-side incremental windowing. |
| 4 | `src/views/search/SearchView.vue` (389 L) | Search tab: text search, category chips, filter sheet, infinite-window results grid. |
| 5 | `src/views/search/CollectionView.vue` (100 L) | Products belonging to one collection id, filtered client-side. |
| 6 | `src/views/user/DashboardView.vue` (568 L) | "میز کار": balance hero (grams + rial debt), 4 settings rows, live rate row, logout/login dialogs. |
| 7 | `src/views/user/LoginRegisterView.vue` (14 L) | Thin switcher between `LoginView` and `RegisterView` on a local `showRegister` flag. |
| 8 | `src/views/user/LoginView.vue` (318 L) | 2-step mobile → OTP login (OTP verification is stubbed). |
| 9 | `src/views/user/RegisterView.vue` (218 L) | 7-field store-registration form; **submit is stubbed — persists nothing**. |
| 10 | `src/views/user/OrdersView.vue` (484 L) | Order list with status chips + order-number search, detail drawer with 4-step tracker. |
| 11 | `src/views/user/TransactionsView.vue` (342 L) | "اسناد" ledger-entry list with credit/debit marks and a detail drawer. |
| 12 | `src/views/user/ProfileView.vue` (216 L) | Read-only account profile card (name, mobile, national code, verification step). |
| 13 | `src/views/user/FavoritesView.vue` (110 L) | Grid of favourited products (favourites are in-memory only). |
| 14 | `src/views/user/NotificationsView.vue` (51 L) | **Static empty-state stub only** — no data source, no API call, no state. |
### 6.1 `HomeView.vue`
| Aspect | Detail |
|---|---|
| API calls | `api.listProducts()` GET `/api/catalog/products` and `memory.init()` (which itself calls `listProducts`) in `Promise.all` — **the catalogue is fetched twice per load** (`HomeView.vue:196`). Then `api.getLatestGoldPrice()` GET `/api/pricing/latest?karatPerMille=750` in a separate try (`:203`). |
| Error state | Catalogue failure: `catch{console.log(e)}` — silent, list stays empty (`:198-200`). Price failure: `goldPrice = null` → RateStrip prints "در انتظار اعلام نرخ" (`:204-206`; `RateStrip.vue:12`). |
| Computed slices | `reelProducts = products.slice(0, REEL_LIMIT=6)`; `listProducts = products.slice(0, LIST_LIMIT=4)` (`:149-154`). |
| Hero slides | `memory.cats.slice(0,5)` mapped to `{key,title,image,categoryId}`; image = first product image in that category (`:159-171`). |
| Conditional rendering | Hero `v-if="slides.length"` (`:37`); categories `v-if="memory.cats.length"` (`:45`); products section `v-if="newestProducts.length"` (`:50`); empty state `v-if="!loading && !products.length"` → box icon + "هنوز محصولی در مجموعه ثبت نشده است." (`:73-76`). |
| Pull-to-refresh | vant `PullRefresh` wrapping the body; `onRefresh()` re-runs `loadData()` (`:30`, `:209-212`). |
| Reveal choreography | `--reveal-delay` CSS var, `REVEAL_STEP_MS = 70`, module-scope `hasRevealed` so it plays once per app load, not per tab switch (`:114`, `:136-145`, `:214-220`). |
| Navigation out | Category/slide tap → seeds `filters.query.cats=[id]` then `router.push({name:'search'})` (`:178-188`). "See all" → `{name:'newestProduct'}` (`:190-192`). |
| Product open | Opens an ant `Drawer` (bottom, `height="100vh"`) containing `SingleProduct` — **not** a route push (`:80-89`, `:173-176`). |
| Buttons disabled/loading | None on this page. |
| Confirm dialogs | None. |
| Prices | **No rial price is printed for any product** — deliberate (`:12-17`). Only weight (گرم) and wage (٪). |
### 6.2 `CartView.vue`
| Aspect | Detail |
|---|---|
| API calls | On mount and on pull-refresh: `cart.init()` → `api.listProducts()` GET `/api/catalog/products` to re-sync lines and drop stale ones (`CartView.vue:228`, `:243`; `src/stores/cartStore.ts:39-55`). On submit: `api.createOrder(...)` **POST `/api/orders`**. |
| POST payload | `{ customerId: session.customerId, items: cart.items.map(i => ({ productVariantId: i.vid, quantity: i.qty, wagePercent: parseFloat(variant.defaultWagePercent) || 0 })) }` (script `:220-231` / tail `:1-11`). The per-item **`note` is NOT sent** — it is stored locally only. |
| Auth gate | `submitOrder()` first checks `session.customerId`; if absent, closes the confirm dialog and opens `loginNotify` (script `:214-217`). |
| Login dialog | Text: "برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید." Buttons: "ورود/ثبت نام" → `{name:'login'}`, "بیخیال" → close (`:129-140`). |
| Confirm dialog | `DialogVant`, title "آیا مطمئن به ثبت سفارش هستید ؟", buttons "بله" → `submitOrder()`, "بی خیال" → close (`:118-127`). |
| Trigger | The sticky "ثبت نهایی سفارش" button only sets `cartData.notify = true` — it does not submit directly (`:93`). |
| Loading | `Spinner :spinning="cartData.loadingPage"` tip "درحال دریافت اطلاعات" (`:32`); `loadingPage` is set true during submit, false in `finally` (script `:219`, `:237`). **The submit button itself is never `disabled`** — only masked by the spinner overlay. |
| Success behaviour | `cartData.orderID = order.orderNumber`; `cartData.step = 2`; `cart.clear()` (script `:232-234`). Step 2 panel shows tick icon, "سفارش شما ثبت شد", the order number, hint "برای پیگیری وضعیت، به فهرست سفارش‌ها مراجعه کنید." and a CTA → `router.push({name:'ordersList'})` then resets `step = 1` (`:68-79`, script `:207-210`). |
| Error handling | `catch{console.log(e)}` — **a failed POST shows the user nothing**; the cart is not cleared and the view silently stays on step 1 (script `:235-236`). |
| Note sheet | Bottom `Drawer` height `300px`, title "توضیح برای {product.title}", a `Textarea :rows="4"` placeholder "مثلاً سایز یا جزئیات سفارش", submit "ثبت توضیحات". **No validation rules on the note field** (`:98-116`). Saved via `cart.updateFeild(note, index, row, 'note')` (script `:198-203`). |
| Totals | `cart.total()` = `Σ parseFloat(variant.rashWeightGrams) * qty`, `.toFixed(2)`, labelled "جمع وزن … گرم" (`:86-92`; `cartStore.ts:88-95`). **No rial total.** |
| Sticky bar visibility | `v-if="cart.items.length > 0 && cartData.step === 1"` (`:84`). |
| Empty state | Bag icon, "سبد خرید شما خالی است.", CTA "دیدن محصولات" → `{name:'home'}` (`:58-64`). |
| Pagination / filters | None. |
### 6.3 `SearchView.vue`
| Aspect | Detail |
|---|---|
| API calls | `api.listProducts()` GET `/api/catalog/products` on mount and on pull-refresh (`SearchView.vue:178-190`, `:238`). **The whole catalogue is fetched once; all filtering is client-side.** |
| Text filter | `p.title.includes(q.text)` — substring, case-sensitive, **no digit normalisation, no trimming** (`:128`). |
| Category filter | `q.cats.includes(p.category.id)` (`:129`). |
| Brand filter | `q.brands.includes(p.brand.id)` (`:130`). |
| Gram range filter | Applies only when `q.limit_gram.length === 2`: keeps products with **any** variant whose `rashWeightGrams` falls inclusively inside `[min,max]` (`:131-137`). |
| Unused filter fields | `colors`, `limit_percent`, `ciny_id`, `has_discount`, `page` are declared in `FiltersQuery` but never read by this filter chain (`src/stores/filtersStore.ts:9-19`). |
| Pagination | **No server params.** Client windowing: `PAGE_SIZE = 20`, `visibleCount` grows by 20 when an `IntersectionObserver` sentinel (`rootMargin: '400px'`) enters view (`:146-166`). Window resets to 20 on any filter change (`:169-171`). |
| Chips | "همه" (clears `cats`) + one chip per distinct category derived from the fetched catalogue (`:19-36`, `:109-115`). Filter chip shows `${filterKeys.length} فیلتر` when active, else "فیلتر" (`:10-17`). |
| Empty state | `v-if="!filteredProducts.length && !loadingPage"` → search icon + "محصولی با این مشخصات پیدا نشد." Explicitly NOT chained to the sentinel (commented fix at `:70-74`). |
| Error state | `loadProducts()` has `try/finally` with **no `catch`** — a network failure rejects unhandled (`:178-185`). |
| Filter sheet side-effects | Opening hides the tab bar (`nav.setNavVisibility(false)`, `:198-201`); closing restores it; `onBeforeRouteLeave` restores it too, to fix a leak where navigating away with the sheet open left the app with no tab bar (`:222-236`). |
| Bar back button | While the sheet is open, the PageBar back button closes the sheet instead of leaving the route, via a capture-phase click handler (`:213-220`). |
| `availableCollections` | Computed at `:117-123` but **never used in the template** — dead. |
### 6.4 `views/user/LoginView.vue`
| Aspect | Detail |
|---|---|
| Step 1 API | `api.listCustomers()` GET `/api/customers`, then client-side match `c.mobile === formModel.username && c.isActive && !c.isArchived` (`LoginView.vue:131-133`). **The entire customer list is downloaded to the browser to check one phone number.** |
| Step 2 API | **None.** `sendToken(_otpCode)` ignores the code entirely and calls `session.login(matchedCustomer.id, matchedCustomer.mobile)` — any 4 digits log you in (`:153-160`, documented at `:14-16`). |
| Form field | `username` — label "تلفن همراه", `<Input type="number">` (`:63`). |
| Validation rules | `required('تلفن همراه')` → message `" لطفا تلفن همراه را وارد کنید "` (`utils/validator.ts:23`); `mobile('تلفن همراه')` → pattern `/^(\+98|0098|98|0)?9\d{9}$/g`, message `" مقدار تلفن همراه نا معتبر است "` (`validator.ts:3-8`). Rules array at `LoginView.vue:61`. |
| Not-found message | Notify toast, red (`background-color: #DC2626`), text "اطلاعات کاربری در سیستم وجود ندارد." auto-dismiss 2000 ms (`:22`, `:103`, `:135-137`). |
| OTP input | `OtpInput` `:num-inputs="4"`, `inputType="tel"`, `should-auto-focus`, `@on-complete="sendToken"` (`:40-48`). |
| Resend timer | `timer = 120` seconds on entering step 2, decremented by a self-rescheduling `setTimeout` (`:112-122`, `:140-141`). Display: "امکان دریافت مجدد رمز در {mm} ثانیه دیگر" with `counterZeroShow` zero-padding under 10 (`:50-53`, `:170`); else "برای دریافت رمز عبور مجدد تلاش کنید". |
| Button | Label `body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'` (`:172`). **Disabled when** `body.loading || (timer > 0 && body.step === 2)` (`:70`). Loading class `is-loading` (`:69`). |
| Success | `router.push(route.query.redirect ?? {name:'home'})`, then toast "با موفقیت وارد شدید" (`:161-164`). |
| Digit conversion / masks | **None.** Persian digits typed into the mobile field are not converted; no separators, no input mask. |
| Switch to register | "ثبت نام کنید" → `emit('register', true)` (`:79`). |
### 6.5 `views/user/RegisterView.vue`
| Field (`name`) | Label | Control | Rules | Line |
|---|---|---|---|---|
| `fname` | نام | `Input` | `required('نام')` | `:37-39` |
| `lname` | نام خانوادگی | `Input` | `required('نام خانوادگی')` | `:40-42` |
| `mobile` | شماره موبایل | `Input type="number"` | `[mobile('شماره موبایل'), required('شماره موبایل')]` | `:43-49` |
| `store_name` | نام فروشگاه | `Input` | `required('نام فروشگاه')` | `:50-52` |
| `phone` | تلفن فروشگاه | `Input type="number"` | `[required('تلفن فروشگاه')]` — **`phone()` pattern validator is NOT applied** | `:53-55` |
| `address` | آدرس پستی | `Textarea :rows="3"` | `required('آدرس')` (note: label says "آدرس پستی", message says "آدرس") | `:56-58` |
| `city` | شهر | `Input` (plain text) | `required('شهر')` | `:59-61` |
- Submit button "ثبت اطلاعات", `type="submit"`, **never disabled** (`:63`).
- `register()` sets `profileData.loading` true, then just sets `registeredInfo = true`. **No API call — nothing is persisted** (`:117-125`, documented at `:15-19`).
- Success dialog: `closeOnClickOverlay: false`, image `registerDelay.svg`, text "ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.", button "متوجه شدم" → `{name:'home'}` (`:74-86`).
- Spinner tip "در حال ثبت اطلاعات" (`:25`).
- Dropped from legacy (documented `:5-13`): the country/province/city cascading picker, `WorkActionSheet`, `POST /cities/list`.
### 6.6 `views/user/DashboardView.vue`
| Aspect | Detail |
|---|---|
| API calls | `api.getCustomer(session.customerId)` GET `/api/customers/{id}` — **only when signed in** (script `:50-52`). Then `api.getLatestGoldPrice()` GET `/api/pricing/latest?karatPerMille=750`, `catch → null` (script `:53-57`). |
| Calculations | `isDebtor = parseFloat(goldDebtGrams) > 0`; `grams = Math.abs(parseFloat(goldDebtGrams))`; `remainText = isDebtor ? 'بدهکار' : 'بستانکار'` (script `:24-28`). Rial debt printed via `price(customer.fiatDebtRial)` (thousands separators) + " ریال" (template `:32`). |
| Signed-in hero | Full name, `session.mobile`, a seal badge بدهکار/بستانکار, the gram figure + unit, the rial-debt row (template `:14-35`). |
| Signed-out state | Invite card: "حساب گرمی شما" / "برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید." / CTA "ورود / ثبت‌نام" → `{name:'login'}` (template `:38-47`). |
| Nav rows | 4 rows, each `button` + chevron: سفارشات / اسناد / پروفایل / علاقمندی → `goToDetails(0..3)` (template `:52-94`). Each row checks `session.customerId` first; if absent it opens `data.loginNotify` instead of navigating (script `:32-41`). |
| Logout | Exit icon in the bar, only `v-if="session.customerId"` (template `:52-60` of the header block). Opens `data.notify` dialog "آیا می خواهید از برنامه خارج شوید؟" → "بله" runs `session.logout()` + push `{name:'home'}`; "بی خیال" closes (template `:102-111`, script `:43-47`). |
| Login-nudge dialog | Long text "برای دسترسی به تمامی امکانات نرم افزار منتزر…" → "ورود/ثبت نام" / "بیخیال" (template `:113-127`). |
| Live rate | `<RateStrip :quote="goldPrice" />` — full-width card variant (template `:98`). |
| Pull-to-refresh | vant `PullRefresh` → `onRefresh()` re-runs `loadDashboard()` (template `:5`, script `:60-63`). |
| Error state | `loadDashboard` has **no try/catch around `getCustomer`** — a failed customer fetch rejects unhandled and the reveal animation never fires (script `:49-52`). |
| Explicitly dropped | Multi-role/sub-account switching, "سفارش مشتریان", "سفارش ساخت" (PreOrder), external Tahesab report, multi-row world-price ticker — all documented at `:9-27`. |
### 6.7 `views/user/OrdersView.vue`
| Aspect | Detail |
|---|---|
| API call | `api.getCustomerOrders(session.customerId)` GET `/api/customers/{id}/orders`; early-returns when signed out (script `:52-60`). |
| Filters | 9 status chips built from `ORDER_STATUS_LABELS`: `ALL` ("همه"), `SUSPENDED`, `PLACED`, `ADMIN_APPROVED`, `SOURCING`, `PREPARING`, `DELIVERED_TO_AGENT`, `DELIVERED_TO_CUSTOMER`, `CANCELLED` (script `:23-33`). **All client-side** — no query params sent. |
| Search | `SearchInput` placeholder "جستجوی شماره سفارش"; filter is `o.orderNumber.includes(searchTerm)` — substring, no digit normalisation (script `:44`). |
| Pagination | **None** — every order renders. |
| Empty state | `v-else-if="!loading"` → box icon + "سفارشی با این مشخصات پیدا نشد." (template `:38-41`). |
| Loading | `Spinner :spinning="loading"` tip "درحال دریافت اطلاعات" (template `:29`). |
| Pull-to-refresh | Yes, `onRefresh()` (template `:30`, script `:74-77`). |
| Detail drawer | Bottom `Drawer height="100vh"`. Contains a 4-step tracker (`ORDER_STEPS` = ثبت سفارش / آماده سازی / تحویل مشتری / لغو شده) with `is-done` for `index <= currentStep` and `is-active` for equality (template `:66-78`); headline facts — order number, status pill coloured from `ORDER_STATUS_COLORS`; specs — "تعداد کالاها" `items.length` آیتم, "وزن سفارش" `parseFloat(totalGoldWeightGrams).toFixed(2)` گرم, "تاریخ سفارش" `jdate(createdAt)` (template `:81-109`); items rendered via `OrderItemCard` (template `:112-117`). |
| Error state | `try/finally` with no `catch` (script `:55-59`). |
### 6.8 `views/user/TransactionsView.vue`
| Aspect | Detail |
|---|---|
| API call | `api.getLedgerEntries(session.customerId)` GET `/api/customers/{id}/ledger-entries` on mount; early-returns when signed out (script `:42-50`). |
| Source labels | Local map at script `:12-21`: `ORDER_STOCK`→سفارش, `ORDER_PRE_ORDER`→سفارش ساخت, `REPAYMENT_MELTED_GOLD`→طلای آب‌شده, `REPAYMENT_TRANSFER`→حواله, `REPAYMENT_CHEQUE`→چک, `REPAYMENT_CASH`→پولی, `WALLET_BONUS`→پاداش, `ADJUSTMENT`→اصلاحیه. Fallback: raw `sourceType`. |
| Credit/debit | `isCredit = direction === 'CREDIT'` → down-arrow + `is-in`; else up-arrow + `is-out` (template `:12-13`, script `:32`). |
| Amount format | `currency === 'GOLD_GRAM' ? \`${amount} گرم\` : \`${price(amount)} ریال\`` — **grams are printed raw without separators; rial gets thousands separators** (script `:34-35`). |
| Date | `jdate(createdAt)` → Jalali `jYYYY/jMM/jDD`, Western digits (template `:27`). |
| Confirmed state | "تایید شده" / "در انتظار تایید" with `is-pending` class (template `:28-30`). |
| Header count | `SectionHead title="گردش حساب"` subtitle `${entries.length} سند` (template `:8`). |
| Filters / pagination | **None.** |
| Detail | Tapping a row opens a Drawer via `openEntry` (script `:37-40`). |
### 6.9 `views/user/ProfileView.vue`
- API: `api.getCustomer(session.customerId)` GET `/api/customers/{id}`, on mount only, early-return when signed out (`:70-78`).
- **Read-only** — no form, no inputs, no save, no PATCH/PUT anywhere.
- Fields rendered: نام و نام خانوادگی, تلفن همراه, کد ملی, وضعیت احراز هویت (`:21-38`).
- `VERIFICATION_LABELS` (`:57-62`): `UNVERIFIED`→احراز نشده, `STEP_ONE_SUBMITTED`→مرحله اول ثبت شده, `STEP_TWO_SUBMITTED`→مرحله دوم ثبت شده, `VERIFIED`→تایید شده. Fallback: raw value.
- `isVerified = verificationStep === 'VERIFIED'` drives an `is-ok`/`is-pending` seal (`:13-15`, `:68`).
- Empty state `v-else-if="!loading"`: "اطلاعات حساب در دسترس نیست." (`:41-44`).
- `try/finally`, no `catch` (`:72-77`).
### 6.10 `views/user/FavoritesView.vue`
- API: `api.listProducts()` GET `/api/catalog/products` then filtered by `favorites.isFavorite(p.id)` (`:42`, `:44-51`).
- Favourites are **in-memory Pinia only, not persisted and not server-backed** (`src/stores/favoritesStore.ts:1-5`) → the list is empty after every reload.
- Header: `SectionHead "ذخیره‌شده‌ها"` subtitle `${n} محصول` (`:8`).
- Empty state: heart icon, "هنوز محصولی را به علاقه‌مندی‌ها اضافه نکرده‌اید.", CTA "دیدن محصولات" → `{name:'home'}` (`:14-20`).
- No filters, no pagination.
### 6.11 `views/user/NotificationsView.vue`
Entire file is 51 lines: a `PageBar title="اعلان‌ها" back-to="home"` and a hardcoded empty state "اعلانی برای نمایش وجود ندارد." (`:1-17`). **No script state, no API, no store.** Route `/notifications` is reachable but no UI links to it.
### 6.12 `views/NewestView.vue`
- Data via `useProducts()` → `api.listProducts()` (`src/composables/useProducts.ts:24-35`).
- `getProducts(loading, isPull)` **returns immediately when `isPull` is true** — so `pull()` never re-fetches (`useProducts.ts:25`). `totalPages` is permanently `0` (`:19`).
- Client windowing: `PAGE_SIZE = 20`, `visibleCount` grows on `@fetch` from `ScrollFetchSearch`; only falls through to `getProducts(loadingPull, true)` (a no-op) once everything fetched is visible (`NewestView.vue:54-56`, `:62-70`).
- `productsData.flag` = `productList.length < 1`, passed to `ScrollFetchSearch` as the empty flag (`useProducts.ts:30`).
- Empty state: box icon + "هنوز محصولی در مجموعه ثبت نشده است." (`:23-26`).
- Errors: `catch{console.log(e)}` inside the composable (`useProducts.ts:31-33`).
### 6.13 `views/search/CollectionView.vue`
- API: `api.listProducts()` GET `/api/catalog/products`, then `p.collections.some(c => c.collectionId === route.params.id)` (`:41-43`, `:45-52`).
- Title: `route.query.page` as the bar title, fallback "مجموعه" (`:39`).
- Empty state: "در این مجموعه محصولی ثبت نشده است." (`:13-16`).
- No pagination, no filters, no `catch`.
---
## 7. Stores (Pinia) — 9 stores
| Store (`defineStore` id) | File | State | Actions | Endpoints | Persistence |
|---|---|---|---|---|---|
| `cart` | `src/stores/cartStore.ts` | `items: CartItem[]` (`{pid, vid, product, qty, note?}`), hydrated from `localStorage['appCart']` | `init`, `addToCart`, `remove`, `updateFeild`, `find_index`, `isInCart`, `variant`, `total`, `clear` (`:36-99`) | `init()` → GET `/api/catalog/products` (`:41`) | **`localStorage['appCart']`**, written by `persist()` after every mutation (`:16`, `:27-29`) |
| `config` | `src/stores/configStore.ts` | `config.main_slider: Slide[]` (3 hardcoded placeholder slides), `cats: HomeCategory[]`, `loaded: boolean` (`:61-65`) | `setCats`, `init` (`:66-91`) | `init()` → GET `/api/catalog/products`, derives distinct categories (`:75-86`) | None |
| `favorites` | `src/stores/favoritesStore.ts` | `ids: string[]` | `toggle`, `isFavorite` (`:12-20`) | **None — no favourites endpoint exists** (`:1-5`) | **None** (lost on reload) |
| `filters` | `src/stores/filtersStore.ts` | `query: FiltersQuery` = `{colors[], brands[], ciny_id[], limit_percent[], limit_gram[], has_discount, text?, cats?, page}` (`:9-31`) | `reset()` (`:36-38`) | None | None |
| `loading` | `src/stores/loadingStore.ts` | `loading: boolean` | `setLoading` | None | None. **Never imported anywhere in `src/` — dead store.** |
| `nav` | `src/stores/navStore.ts` | `navBarRoutes: NavItem[5]`, `activeLink = 1`, `showNav = true`, `pageTransition = 'right'` (`:24-35`) | `setNavVisibility`, `setActiveLink`, `setPgaTransition` (`:36-46`) | None | None |
| `session` | `src/stores/session.ts` | `customerId`, `mobile` — both hydrated from localStorage at creation (`:11-12`) | `setCustomerId`, `login`, `logout` (`:14-33`) | None (login is client-side matching in `LoginView`) | **`localStorage`**: `gold-bank:customerId`, `gold-bank:mobile`, and legacy-compat `appToken` (= customerId) (`:21-25`, cleared at `:29-32`) |
| `transition` | `src/stores/transitionStore.ts` | `transitionFlag`, `transitionEnded`, `headerHeight` | `setTransitionFlag`, `setTransitionEnded`, `setHeaderHeight` | None | None. Written by `router.afterEach`, but `transitionFlag`/`transitionEnded` are **read by nothing** (router comment `src/router/index.ts:73-79`); `headerHeight` is read only by `ContentWrapper.vue` and the three `ScrollFetch*` wrappers. |
| `windowData` | `src/stores/windowDataStore.ts` | `width`, `pullingDown` | `setWidth`, `setPullingDown` | None | None. `width` written by `MasterLayout` resize observer (`:35-44`), read only by `slider/Slider.vue:37-48`. `pullingDown` is written by nothing. |
Other localStorage keys: `version` (app-version check, `src/App.vue:69-74`).
---
## 8. Shared utils
### 8.1 `src/utils/filters.ts` — formatters
| Function | Behaviour | Exact output | Line |
|---|---|---|---|
| `price(val)` | Thousands separators via `val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')`. Falsy input (incl. `0`, `''`) returned as-is | `1234567` → `1,234,567` | `:7-12` |
| `priceUnit(val)` | Same separators + `" تومان "` suffix. Guard is `['string','number'].includes(typeof val)` so **`0` IS formatted here** (unlike `price`) | `1000` → `1,000 تومان ` | `:14-19` |
| `priceUnitRialLess(val)` | Same separators + a trailing space, no unit | `1000` → `1,000 ` | `:21-26` |
| `jdate(val)` | `moment(val).format('jYYYY/jMM/jDD')`; parses ISO 8601 natively | `2025-03-21T…` → `1404/01/01` | `:28-36` |
**Critical characteristics:**
- **Jalali dates render with WESTERN digits**, not Persian numerals — explicitly preserved, with a warning not to switch to `Intl` `fa-IR` (`:1-4`).
- There is **no Persian→English digit conversion utility anywhere in the app** (grep for `۰-۹`/`٠-٩` conversion: none). Numeric inputs accept whatever the keyboard produces and are passed straight through.
- There is **no gram/weight formatter**. Weights are printed with ad-hoc `.toFixed(2)` (`cartStore.ts:94`; `OrdersView.vue` template `:102`) or raw (`TransactionsView.vue` script `:35`; `SingleProduct.vue` template `:135`).
- There is **no input mask / separator-on-type component** anywhere. `price()` is display-only.
- `priceUnit` and `priceUnitRialLess` are **exported but never imported** — dead.
### 8.2 `src/utils/validator.ts` — validators (ant-design-vue rule objects)
| Export | Rule | Message | Line |
|---|---|---|---|
| `message(field)` | — | `` ` مقدار ${field} نا معتبر است ` `` | `:3` |
| `mobile(field)` | `/^(\+98\|0098\|98\|0)?9\d{9}$/g` | `message(field)` | `:5-8` |
| `phone(field)` | `/^((\+98\|0098\|98\|0)\d1)?\d{8}$/g` | `message(field)` | `:9-12` |
| `fa(field)` | `/^([؀-ۿﮊپچگ‌ ])+$/` | `` ` مقدار ${field} باید فارسی باشد ` `` | `:13-16` |
| `mail(field)` | `{type:'email'}` | `message(field)` | `:17` |
| `url(field)` | `{type:'url'}` | `message(field)` | `:18` |
| `username(field)` | `/^(?!.*\.\.)(?!.*\.$)[^\W][\w.]{0,29}$/gim` | `` ` مقدار ${field} باید شامل حروف و اعداد انگلیسی و _ باشد ` `` | `:19-22` |
| `required(field)` | `{required:true}` | `` ` لطفا ${field} را وارد کنید ` `` (note the double space) | `:23` |
| `sheba(field)` | `/^IR\d{24}$/gim` | `message(field)` | `:24` |
| `card(field)` | `/^\d{16}$/gim` | `message(field)` | `:25` |
| `zip(field)` | `/^\d{10}$/gim` | `message(field)` | `:26` |
| `num(field)` | `/^\d*$/gim` | `message(field)` | `:27` |
| `len(field, len)` | `{len}` | `` ` مقدار ${field} باید ${len} کارکتر باشد ` `` | `:28` |
| `max(field, max)` | `{max}` | `` ` مقدار ${field} باید کمتر از ${max} کارکتر باشد ` `` | `:29` |
| `min(field, min)` | `{min}` | `` ` مقدار ${field} باید بیشتر از ${min} کارکتر باشد ` `` | `:30` |
Only `required` and `mobile` are actually used (LoginView, RegisterView). **There is no national-code (کد ملی) validator**, no IBAN use, no card use.
Note: the `/g` and `/gim` flags on these patterns are a latent statefulness bug with `RegExp.lastIndex` if a rule object is reused across `.test()` calls — ported from legacy as-is.
### 8.3 `src/utils/orderStatus.ts` — constants/enums with exact values
`ORDER_STATUS_LABELS` (`:8-17`):
| Key | Label |
|---|---|
| `SUSPENDED` | معلق |
| `PLACED` | ثبت سفارش |
| `ADMIN_APPROVED` | تایید مدیر سیستم |
| `SOURCING` | درحال تامین |
| `PREPARING` | درحال آماده سازی |
| `DELIVERED_TO_AGENT` | تحویل مسئول فروش |
| `DELIVERED_TO_CUSTOMER` | تحویل مشتری |
| `CANCELLED` | لغو شده |
`ORDER_STATUS_COLORS` (`:23-29`) — only 5 of 8 statuses are coloured; `SUSPENDED`, `ADMIN_APPROVED`, `CANCELLED` are deliberately uncoloured (`:20-22`):
| Key | font | background |
|---|---|---|
| `PLACED` | `#3B82F6` | `#EFF6FF` |
| `SOURCING` | `#EAB308` | `#FEFCE8` |
| `PREPARING` | `#F97316` | `#FFF7ED` |
| `DELIVERED_TO_AGENT` | `#06B6D4` | `#ECFEFF` |
| `DELIVERED_TO_CUSTOMER` | `#22C55E` | `#F0FDF4` |
`ORDER_STEPS = ['ثبت سفارش', 'آماده سازی', 'تحویل مشتری', 'لغو شده']` (`:35`).
`orderStepIndex(status)` (`:37-54`): `PLACED|ADMIN_APPROVED|SUSPENDED` → `0`; `SOURCING|PREPARING` → `1`; `DELIVERED_TO_AGENT|DELIVERED_TO_CUSTOMER` → `2`; `CANCELLED` → `3`; default `0`.
### 8.4 Other constant sets (declared inside views, not centralised)
- `VERIFICATION_LABELS` — `src/views/user/ProfileView.vue:57-62` (see §6.9).
- `SOURCE_LABELS` — `src/views/user/TransactionsView.vue` script `:12-21` (see §6.8).
- Order filter tabs — `src/views/user/OrdersView.vue` script `:23-33`.
- Nav items — `src/stores/navStore.ts:25-31`.
- Colour tokens — `themeConfig/colors.json` (see §11).
### 8.5 `src/utils/checkAccess.ts`
`isUserLoggedIn()` → `localStorage.getItem('appToken')` (`:4-6`). The file header claims "always returns falsy for now" — **stale**, since `session.login()` writes `appToken` (`src/stores/session.ts:25`).
---
## 9. Components (41 files)
### 9.1 Input components
| Component | Purpose | Key details |
|---|---|---|
| `src/components/input/OtpInput.vue` (138 L) | N-box OTP container | Props `numInputs`, `inputType`, `inputClasses`, `separator`, `shouldAutoFocus`, `defaultFocus`, `placeholder`, `disabled`; emits `on-complete`. Handles change/keydown/paste/focus/blur across boxes (`:4-21`). |
| `src/components/input/SingleOtpInput.vue` (117 L) | One OTP box | `min="0" max="9" maxlength="1" pattern="[0-9]"` (`:6-11`). Truncates to 1 char (`:53-59`). Keydown whitelist: numerics 48-57 / 96-105 plus `[8,9,13,37,39,46,86]` (backspace, tab, enter, ←, →, delete, `v`) — everything else `preventDefault` (`:61-74`). `input.select()` on focus (`:78-81`). **No Persian-digit acceptance or conversion.** |
| `src/components/search/SearchInput.vue` (108 L) | Debounced search box | Emits `search` on Enter, on icon click, and on a **700 ms** `useDebounce` watcher (`:30-35`). Clear button emits `search(undefined)` (`:24-28`). **Bug**: `if (value)` at `:32` tests the `Ref` object (always truthy) rather than `value.value`, so the clear button appears after any keystroke and never hides. |
**There is no numeric/price input component, no thousands-separator-on-type input, no uploader/file-picker component, and no date-picker component anywhere in `src/`.** All numeric entry uses bare `<Input type="number">` from ant-design-vue.
### 9.2 Price / rate
| Component | Details |
|---|---|
| `src/components/home/RateStrip.vue` (166 L) | The only price ticker. Props `{quote: GoldPriceQuote \| null, compact?: boolean}` (`:8`). Displays `price(perGram)` + "ریال / گرم" + `jdate(quotedAt)`; when `quote` is null shows "در انتظار اعلام نرخ" (`:8-14`). `perGram = quote.pricePerGramRial.split('.')[0]` — the decimal tail is **truncated, not rounded** (`:12`). Label "نرخ طلای ۱۸ عیار" is hardcoded although the karat is a parameter. `compact` variant is a pill for the home header; default is the dashboard card. |
### 9.3 Product components
| Component | Details |
|---|---|
| `src/components/product/SingleProduct.vue` (1315 L) | Product detail. Used both as a route component (`/product/:id`) and inside Drawers from HomeView/ProductCard. Data via `useProductDetail(id)`. Sticky header turns solid once scrolled past the gallery height minus 56 px (`:44-60`). Stock labels: `qty>0` → `"{qty} عدد موجود"` / `is-in`; else `isPreOrder` → "پیش‌سفارش" / `is-pre`; else "ناموجود" / `is-out` (`:139-146`). Wage label: whole → bare, fractional → `.toFixed(2)` (`:133-137`). Variant chips render when `variants.length > 1` (template `:146`). Add-to-cart button `:disabled="!is_exist"`, label `is_exist ? 'افزودن به سفارش' : 'ناموجود'`, click gated on `isUserLoggedIn()` else opens `loginNotify` (template `:232-241`). Once in cart, the CTA becomes a link "مشاهده سبد خرید" → `/cart` and a qty stepper replaces the category pill (template `:213-230`, `:243-245`). Stepper: `increment` via `cart.updateFeild(qty+1,…)`; `decrement` removes the line at qty 1 (`:162-169`). Full-screen preview teleported to `<body>` with body-scroll lock and cleanup on unmount (`:118-131`, `:199-201`). Success toast "محصول با موفقیت به سبد خرید افزوده شد", 3000 ms (template tail `:58-61`). |
| `src/components/product/ProductCard.vue` (243 L) | Grid card; opens a `SingleProduct` Drawer on click; favourite toggle; declares an unused `wageBefore` prop (`:10-13`). |
| `src/components/product/CartItem.vue` (242 L) | One basket row with the qty stepper. **`decrement()` calls `updateFeild(qty-1, …)` with no floor** (`:16-18`) — unlike SingleProduct's stepper, which removes at 1. `index` is captured **once at setup** (`:11`), so it goes stale if earlier rows are removed. |
| `src/components/product/OrderCard.vue` (163 L) | Order summary row; status label + colour; `jdate`; emits `view`. |
| `src/components/product/OrderItemCard.vue` (144 L) | One order line; resolves the product/variant title + image via `useOrderItemProduct()` (client-side join against the catalogue). |
| `src/components/product/GallerySlider.vue` (287 L) | Pointer-capture swipe gallery. `v-model:index`, props `slides`, `alt`, `fit: 'cover'\|'contain'`, `showCounter` (default **false**, to avoid overlapping the caller's own chrome — `:10-19`). `DRAG_COMMIT_RATIO = 0.18` of track width; `TAP_MAX_PX = 6` distinguishes tap from swipe and suppresses `tap` after a drag (`:52-53`). |
| `src/components/product/Category.vue` (45 L) | Category tile that writes `filters.query.cats = [id]`. **Not imported anywhere — dead.** |
### 9.4 Home components
| Component | Purpose |
|---|---|
| `src/components/home/HeroCarousel.vue` (275 L) | Coverflow hero. Autoplay default true; `SWIPE_THRESHOLD = 46`, `NEIGHBOUR_SCALE = 0.76`, `NEIGHBOUR_SHIFT = 36.6`, `NEIGHBOUR_OPACITY = 0.75` (`:29-36`). Side cards tuck *behind* the centre card. |
| `src/components/home/ProductCarousel.vue` (298 L) | Peeking product rail ending in a "see all" cell. `SWIPE_THRESHOLD = 46`, `NEIGHBOUR_SCALE = 0.78`, `NEIGHBOUR_SHIFT = 92.5`, `NEIGHBOUR_OPACITY = 0.62` (`:27-37`); cells loop (`:41-44`). |
| `src/components/home/CategoryGrid.vue` (95 L) | Category tile grid; emits `select`. |
| `src/components/home/ProductPanel.vue` (117 L) | Wide horizontal product row used in the home list. |
| `src/components/home/RateStrip.vue` | See §9.2. |
| `src/components/home/SectionHead.vue` (59 L) | Title + subtitle + optional action; used by NewestView, CollectionView, FavoritesView, TransactionsView. |
| `src/components/home/AccountStrip.vue` (229 L) | Balance strip (grams + rial). **Not imported anywhere — dead** (DashboardView inlines the equivalent markup instead). |
| `src/components/home/CategoryRail.vue` (94 L) | Horizontal category rail. **Not imported — dead** (replaced by `CategoryGrid`). |
| `src/components/home/ShowcaseCard.vue` (156 L) | Large product card. **Not imported — dead.** |
| `src/components/home/ShowcaseSlider.vue` (201 L) | Auto-advancing banner slider, 6000 ms interval (`:28-31`). **Not imported — dead** (replaced by `HeroCarousel`). |
### 9.5 UI / shared
| Component | Purpose |
|---|---|
| `src/components/ui/PageBar.vue` (117 L) | Standard top bar. Props `title`, `back` (default **true**), `backTo?`. `onBack()` pushes `{name: backTo}` when given, else `router.back()` (`:38-41`). Slots `trailing`, `below`. |
| `src/components/ui/Navigation.vue` (119 L) | Bottom tab bar — see §4. |
| `src/components/ui/Container.vue` (13 L) | Registered globally as `<container>` in `src/main.ts:28`; optional `padding` prop. |
| `src/components/ui/DropDown.vue` (97 L) | Collapsible section used by the filter sheet. |
| `src/components/ui/ScrollFetch.vue` (41 L) | `useInfiniteScroll` wrapper, `distance: 50`. **Not imported — dead.** |
| `src/components/ui/ScrollFetchSearch.vue` (50 L) | Same, used by `NewestView` only. |
| `src/components/ui/ScrollFetchAfterSearch.vue` (48 L) | Same + `isSearch` prop. **Not imported — dead.** |
| `src/components/ui/ContentWrapper.vue` (12 L) | Reads `transitionStore.headerHeight`. **Not imported — dead.** |
| `src/components/DialogVant.vue` (142 L) | Two-button vant dialog; slots `img`/`title`/`button`; props `timeOut`, `text1..3`. |
| `src/components/DialogVantOneButton.vue` (104 L) | One-button variant; used by `App.vue` for the update prompt. |
| `src/components/NotifyVant.vue` (50 L) | Toast. `v-model` + `timeOut`; `timeOut === -1` disables auto-dismiss (`:21`). |
| `src/components/Spinner.vue` (116 L) | ant `Spin` wrapper; props `tip`, `size` (default `'large'`), `cls`, `delay`, `spinning`, `indicator`, `fixed` (default **true**). |
| `src/components/EmptyState.vue` (32 L) | ant `Empty` wrapper; used by the `ScrollFetch*` family. |
| `src/components/ViewAll.vue` (19 L) | "see all" link. **Not imported — dead.** |
| `src/components/icon/Icon.vue` (54 L) | Inline-SVG icon by name from `icons.json`. `tempIcon` is a **computed** (`:30`) — the header documents a fixed bug where it was a plain ref and froze when the `icon` prop changed on a mounted instance (the cart trash/minus swap). `applySize()` sets width/height after `nextTick` (`:32-43`). |
| `src/components/icon/icons.json` | **1024 icons**, all `vuesax-linear:*`. |
| `src/components/slider/HomeSlider.vue` (146 L) | Swiper banner with popup/link/discount click types, autoplay 4000 ms (`:32`). **Not imported — dead.** Carries a documented ported bug: sets `filters.query.discount` while the real field is `has_discount`, so the discount branch never filtered anything (`:50-55`). |
| `src/components/slider/Slider.vue` (139 L) | Swiper gallery with SVG curve math off `windowDataStore.width`. **Not imported — dead.** |
### 9.6 Composables
| File | Purpose | Key facts |
|---|---|---|
| `src/composables/useProduct.ts` | Adapts `ProductSummary` to the card contract | Default variant = **cheapest by `defaultWagePercent`** (`:24-29`) with **no orderability preference**. `is_exist = qty > 0 \|\| isPreOrder` (`:46`). `hasDiscountWage` always `null`, `has_warranty` always `false` — no backend (`:40`, `:48`). `image` = `product.images[0].url` only (`:33`). |
| `src/composables/useProductDetail.ts` | Single-product fetch | **No detail endpoint exists**: fetches the whole catalogue and finds by id client-side (`:16-29`). |
| `src/composables/useProducts.ts` | Product list for NewestView | `getProducts(loading, isPull)` no-ops when `isPull` (`:25`); `totalPages` always `0`. |
| `src/composables/useOrderItemProduct.ts` | Variant-id → title/image lookup | Module-level `cache` ref, loaded once from `listProducts()` (`:14-31`) — **never invalidated** for the app's lifetime. |
| `src/composables/useRefresh.ts` | SW auto-update | `watchEffect` auto-applies a waiting SW as soon as `offlineReady \|\| needRefresh` (`:16`). |
**Bug note**: `App.vue:84` calls `useRefresh()` **inside `onMounted`**, outside the setup scope, so its `watchEffect` is not bound to the component lifecycle.
---
## 10. Live price / sockets / polling
**There is no WebSocket, no SSE, no EventSource, and no polling timer for prices anywhere in `src/`.**
| Aspect | Reality | Evidence |
|---|---|---|
| Mechanism | A plain one-shot `fetch` of `GET /api/pricing/latest?karatPerMille=750` | `src/api/client.ts:145` |
| Call sites | Exactly two: `HomeView.loadData()` and `DashboardView.loadDashboard()` | `src/views/HomeView.vue:203`; `src/views/user/DashboardView.vue` script `:54` |
| Refresh trigger | Only (a) component mount and (b) the user pulling to refresh that page. No interval, no visibility-change listener, no focus listener. | `HomeView.vue:209-216`; `DashboardView.vue` script `:60-63`, `:75-83` |
| Staleness UX | The quote's own `quotedAt` is shown as a Jalali **date only** (no time), so a several-hours-old rate looks current | `src/components/home/RateStrip.vue:14` |
| Failure | `catch → goldPrice = null` → "در انتظار اعلام نرخ" | `HomeView.vue:204-206`; `RateStrip.vue:12` |
| Karat | Always the default `750` (18k); the label "نرخ طلای ۱۸ عیار" is hardcoded | `client.ts:145`; `RateStrip.vue:5` |
The only recurring timers in the app are decorative: `HeroCarousel` autoplay, `ShowcaseSlider` 6000 ms (dead component), `HomeSlider` 4000 ms (dead component), and `LoginView`'s 120-second OTP countdown (`src/views/user/LoginView.vue:112-122`).
---
## 11. Env / config / PWA
### 11.1 Environment
**The app has no `.env` and no `.env.example` of its own.** `vite.config.ts:9-12` loads the **monorepo-root** `.env` via `loadEnv('development', rootDir, '')` and sets `envDir: rootDir` (`:15`).
| Key | Read by | Default | Evidence |
|---|---|---|---|
| `USER_APP_V2_PORT` | dev-server port | `5182` | `vite.config.ts:11`; root `.env:27` = `5182` |
| `API_PORT` | proxy target | `3000` | `vite.config.ts:12`; root `.env:24` = `3000` |
**No `VITE_*` variable is referenced anywhere in `src/`** — there is no runtime-configurable API base URL. The base is the hardcoded `'/api'` (`src/api/client.ts:3`).
Dev proxy (`vite.config.ts:50-53`): `/api` and `/uploads` → `http://localhost:{apiPort}`. `strictPort: true` (`:49`) — fails loudly rather than moving ports. `host: '0.0.0.0'` (`:45`).
Production (`nginx.conf`): `/api/uploads/`, `/api/`, `/uploads/` all proxy to `http://goldbank-api:3000` with a Docker-DNS `resolver 127.0.0.11 valid=10s` to survive api-container restarts (`:11`, `:13-44`); SPA fallback `try_files $uri $uri/ /index.html` (`:46-48`); static assets `expires 6M` (`:50-54`). `Dockerfile:1-7`: `nginx:latest` from `docker.arvancloud.ir`, copies `dist`, exposes 80.
### 11.2 PWA
`VitePWA` config at `vite.config.ts:20-42`:
| Setting | Value |
|---|---|
| strategy | `injectManifest`, `srcDir: 'src'`, `filename: 'sw.ts'` |
| base | `/` |
| includeAssets | `['favicon.ico']` |
| devOptions | `{enabled: true, type: 'module'}` — **the SW runs in dev too** |
| manifest name / short_name / description | `Montzar` / `Montzar` / `Montzar` |
| theme_color / background_color | `$themeLessVars.primary` = `#1C2D56` |
| start_url / display | `/` / `standalone` |
| icons | `icon-192.png` 192², `/icon-512.png` 512², `icon-512.png` 512² `purpose: 'any maskable'` |
`src/sw.ts:1-14`: listens for `{type:'SKIP_WAITING'}` → `self.skipWaiting()`, then `precacheAndRoute(self.__WB_MANIFEST)`. Nothing else — **no runtime caching rules, no API caching, no offline fallback page**.
`public/manifest.json` contains only `{"gcm_sender_id": "464066939468"}` (`:1-3`) — a legacy FCM artifact; it is **not** the PWA manifest (that one is generated by the plugin). Push/Firebase is not initialised (`src/App.vue:4-6`).
App-version update flow (`src/App.vue:66-86`): compares `localStorage['version']` against `package.json` `version`; on mismatch shows a Lottie dialog "نسخه جدید منتزر منتشر شد!" with an "به روز رسانی" button that stores the new version, calls `updateServiceWorker()` and `window.location.reload()`. **Since `package.json` version is a static `0.0.1`, this dialog can only fire if the version is bumped at release time.**
### 11.3 Theme
- `themeConfig/themeConfig.ts:15-43` — ant-design-vue Less vars: `primary-color: secondary (#8D6A46)`, `link-color`/`heading-color: primary (#1C2D56)`, `error-color: #E31A1A`, `border-radius-base: 5px`, `height-lg: 42px`, `transition-duration: .5s`. Font scale from `curve.font = 14`.
- `themeConfig/colors.json` — 37 tokens. Legacy brand: `primary #1C2D56`, `secondary #8D6A46`, `danger #E31A1A`. **Redesign palette** (added on top of the legacy set): `ink #14161A`, `inkSoft #22262E`, `inkMuted #5B616E`, `inkFaint #8A909C`, `wine #7A1B33`, `wineDeep #5E1427`, `wineSoft #A33C55`, `wineWash #FBF2F4`, `canvas #FFFFFF`, `canvasSoft #FAFAFA`, `hairline #ECEDEF`, `hairlineSoft #F4F5F6` (`:26-37`).
- `tailwind.config.js:8-16` — content `./index.html`, `./src/**/*.{html,js,ts,vue}`; `darkMode: 'media'` (never used); colors = tailwind defaults merged with `colors.json`.
- `index.html:8-13` — `theme-color` / `background-color` / body background hardcoded `#1C2D56`; title "طلای منتزر"; viewport locked `maximum-scale=1.0, user-scalable=no`.
- `src/App.vue:94-104` — `#app { direction: rtl !important; user-select: none; color: @primary }`.
- Fonts: `src/assets/styles/fonts.less` + two families on disk — **IRANSans FaNum (6 weights, woff+woff2)** and **Yekan Bakh FaNum (7 weights, eot/ttf/woff)**. IRANSans is present in `user-app-v2` only.
---
## 12. Candidate PROTECTED items — new features / new design
These are items where `apps/user-app-v2` **diverges from the sibling `apps/user-app`** by adding something that does not exist there, or by a deliberate redesign. Verified by a full file-tree and per-file diff of the two apps.
### 12.1 New component files (exist ONLY in user-app-v2)
| Path | What it is |
|---|---|
| `src/components/home/HeroCarousel.vue` (275 L) | **New** coverflow hero with pointer-drag, wrap-around stack geometry and autoplay. Replaces `ShowcaseSlider` (which still exists but is now dead here). |
| `src/components/home/ProductCarousel.vue` (298 L) | **New** peeking 3-up product rail whose final cell is a "see all" CTA. No equivalent in the sibling app. |
| `src/components/home/CategoryGrid.vue` (95 L) | **New** category tile grid. Replaces `CategoryRail` (now dead here). |
| `src/components/home/ProductPanel.vue` (117 L) | **New** wide horizontal product row for the home list. |
### 12.2 New font family (only in user-app-v2)
`src/assets/styles/fonts/iransans/` — 12 files, IRANSans FaNum in 6 weights (woff + woff2). Not present in the sibling app.
### 12.3 Redesigned flows / sections
| Item | Path | Nature of the change |
|---|---|---|
| **Home page structure** | `src/views/HomeView.vue:1-20`, `:37-76` | Fully restructured: `HeroCarousel` + `CategoryGrid` + `ProductCarousel` + `ProductPanel` list, replacing the sibling's `ShowcaseSlider` + `CategoryRail` + featured/grid/newest three-band layout. New slice constants `REEL_LIMIT=6`, `LIST_LIMIT=4` (`:149-150`). |
| **First-paint reveal choreography** | `HomeView.vue:114`, `:136-145`, `:214-220`; `CartView.vue:165`, `:174-185`, `:227-245`; `DashboardView.vue` script `:65-83` | A `--reveal-delay` staggered entrance driven by a **module-scope `hasRevealed` flag** so it plays once per app load, not per tab switch. **Exists only in v2** — the sibling has no reveal system on any of the three screens. |
| **Dashboard layout** | `src/views/user/DashboardView.vue:29-45`, template `:14-98` | Second visual pass: one full hero card (balance + identity + rial-debt row) plus four wide settings-style rows with chevrons, replacing the sibling's "card + `SectionHead` + 4-cell tile grid". The sibling's `list[]` tile-config array is gone. `remainText` shortened from "بدهکار گرمی"/"بستانکار گرمی" → "بدهکار"/"بستانکار" (script `:25`). |
| **Compact RateStrip variant** | `src/components/home/RateStrip.vue:8`, `:47-103` | New `compact` prop: a navy-tinted pill for the home header (`fade(@primary, 6%)` background, navy border) next to the logo. The sibling has only the dark full-width card. Used at `HomeView.vue:26`. |
| **SingleProduct sticky solid header** | `src/components/product/SingleProduct.vue:39-60`, template `:42-68` | New scroll-driven header that turns solid once the sheet has covered the photo, measured off the gallery's own height minus `HEADER_SOLID_OFFSET_PX = 56`. Not in the sibling. |
| **SingleProduct category deep-link pill** | `SingleProduct.vue:22`, `:96`, `:185-190`, template `:227-230` | New: when the item is not yet in the cart, the action bar's secondary pill deep-links to search filtered by this product's category (seeds `filtersStore` then pushes `{name:'search'}`). Not in the sibling. |
| **Pill-shaped search field** | `src/components/search/SearchInput.vue:64-75` | Restyled: `height 48px`, `border-radius 999px`, `background @canvas` (outlined pill on white) vs the sibling's 44 px / 14 px-radius filled `@hairlineSoft` box. |
| **Lazy-load placeholder** | `src/main.ts:7`, `:24-27` | Uses `Vector.svg` as the vue3-lazyload loading/error mark; the sibling uses `montzar-logo.png` with an explicit TODO to swap it back. v2 is the intended end-state here. |
### 12.4 IMPORTANT — regressions where v2 is BEHIND the sibling
These are **not** protected new features; they are capabilities the sibling `apps/user-app` has that v2 lacks. Flagged here so a parity pass does not mistake them for intentional v2 design.
| Missing in v2 | Present in sibling | Evidence |
|---|---|---|
| **Admin-managed home banner** | `getHomeSlider()` GET `/home/slider` + `AdminSlide` interface + `adminSlides` state + absolute-URL vs in-app-path slide link handling in HomeView | v2 `src/api/client.ts:137-146` has 8 functions; sibling has 9 (`apps/user-app/src/api/client.ts:170`). v2 `HomeView.vue` has no `adminSlides`. |
| **Stock ceiling on cart quantity** | `capQty()`, `maxQty()`, an orderability guard in `addToCart()` that refuses out-of-stock non-pre-order variants, and clamping inside `updateFeild(...,'qty')` | v2 `src/stores/cartStore.ts:56-78` has none of these; a user can increment a cart line past available stock. |
| **Per-variant images** | `ProductImageSummary` (with `id`, `sortOrder`, `role: FEATURED\|FEATURED_MOBILE\|GALLERY\|GALLERY_MOBILE`), `ProductVariantSummary.imageId`/`.image`, images returned sorted by `sortOrder`, `useProduct.image` preferring the variant's own photo, a `watch` that scrolls the gallery to the selected variant's image | v2 `src/api/client.ts:56` types images as bare `Array<{url}>`; `src/composables/useProduct.ts:33` reads `images[0]` only. |
| **Orderable-first default variant** | `isVariantOrderable()` + a filtered candidate pool, so the page does not open on a disabled button | v2 `src/composables/useProduct.ts:24-29` picks the cheapest variant regardless of stock. |
| **Variant chip preview cap + per-chip stock** | `VARIANT_PREVIEW_COUNT = 6` with a "نمایش همه" toggle, per-chip thumbnails, per-chip موجود/پیش‌سفارش/ناموجود badges | v2 `SingleProduct.vue` template `:146-157` renders every variant as a plain text chip. |
### 12.5 Stubbed / non-functional surfaces (candidate gaps, documented in-code)
| Surface | Status | Evidence |
|---|---|---|
| OTP verification | Any 4 digits log in; the code is never checked | `src/views/user/LoginView.vue:153-160` |
| Registration | Submits nothing; no create-customer endpoint | `src/views/user/RegisterView.vue:117-125` |
| Favourites | In-memory only; lost on reload; no endpoint | `src/stores/favoritesStore.ts:1-5` |
| Notifications | Static empty state, no data source | `src/views/user/NotificationsView.vue:1-17` |
| Home banner slides | 3 hardcoded placeholders from local artwork | `src/stores/configStore.ts:39-58` |
| Profile editing | Read-only; no write endpoint used | `src/views/user/ProfileView.vue` |
| Promotions / discounts | `hasDiscountWage` always `null`; no data | `src/composables/useProduct.ts:40` |
| Server-side pagination | No endpoint pages; all windowing is client-side | `src/composables/useProducts.ts:25`; `SearchView.vue:146-166` |
| `getBalance()` API fn | Declared, never called | `src/api/client.ts:141` |
### 12.6 Dead code inventory (not imported anywhere in `src/`)
Components: `home/AccountStrip.vue`, `home/CategoryRail.vue`, `home/ShowcaseCard.vue`, `home/ShowcaseSlider.vue`, `product/Category.vue`, `slider/HomeSlider.vue`, `slider/Slider.vue`, `ui/ScrollFetch.vue`, `ui/ScrollFetchAfterSearch.vue`, `ui/ContentWrapper.vue`, `ViewAll.vue`.
Stores: `loadingStore.ts`. Utils: `priceUnit`, `priceUnitRialLess`, and 12 of 15 validators. API: `getBalance`. Deps: `gsap`, `@ant-design/icons-vue`. Computed: `SearchView.availableCollections` (`:117-123`).
---
## 13. The stray folder at the monorepo root
**`E:\gold bank\gold-banking-monorepo\user-app-v2\` — it exists.** It is a **stray partial copy, not a build output and not a workspace package.**
Contents — exactly two files, nothing else (no `src/`, no `index.html`, no `dist/`, no `node_modules/`):
| File | Size | mtime | What it is |
|---|---|---|---|
| `user-app-v2/package.json` | 966 B | 2025-09-18 12:54 | **Byte-identical** to `apps/user-app-v2/package.json`. |
| `user-app-v2/vite.config.ts` | 1553 B | 2025-09-18 12:54 | An **older revision** of `apps/user-app-v2/vite.config.ts`. |
The vite config differs from the live one in three ways:
1. **Hardcoded `port: 5179`** (`user-app-v2/vite.config.ts:37`) vs. the live config reading `USER_APP_V2_PORT` from the root `.env` (`= 5182`).
2. **No `strictPort`**, so it would silently drift to another port.
3. **Hardcoded proxy targets** `http://localhost:3000` (`:39-40`) vs. the live `http://localhost:${apiPort}`, and **no `envDir`/`loadEnv`** block at all.
It also imports `./themeConfig/themeConfig` (`:4`) — a path that **does not exist** at the root, so this config could not even be loaded from where it sits.
Why it is inert:
- `pnpm-workspace.yaml` declares packages as `apps/*` and `packages/*` only — the root `user-app-v2/` is **not** in any workspace glob, so pnpm ignores it.
- The root `package.json` `build:release` script filters `--filter user-app-v2`, which resolves to the workspace package at `apps/user-app-v2` (same `"name"`), not this folder.
- Nothing references it; it has no entry point.
**Conclusion: a leftover from the 2025-09-18 move of the app into `apps/`. Safe to delete, but NOT modified by this inventory as instructed.** Its only residual risk is confusion — a developer opening `user-app-v2/vite.config.ts` from the root would read stale port 5179 and stale hardcoded proxy targets.