# 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()` | `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=` 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.