701 lines
66 KiB
Markdown
701 lines
66 KiB
Markdown
# 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.
|