Files
hedena_gold/docs/parity/_inv-tgt-userappv2.md
T
s.esmaeili ea86636027 Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
2026-10-15 17:19:10 +03:30

701 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.