Read-only inventory of all 7 projects (3 reference + 4 target) produced by parallel subagents, every claim cited path:line. No source code changed. - 00-INVENTORY.md consolidated mapping: stack, backend, admin, user apps - PROTECTED.md draft protected list (awaiting user confirmation) - ISSUES.md 26 logged issues (security, money/stock, validation, dead code) - CHANGELOG.md change log scaffold - PROGRESS.md resumable state + Q1-Q5 blocking Phase 1 - _inv-*.md 7 raw per-project inventories (8111 lines) Key finding: the reference is a gold-jewellery B2B/B2C e-commerce system, not a gold bank. Buy/sell, deposit/withdraw, transfer, KYC levels, payment callbacks, cron and websockets do not exist in it, so they cannot be parity gaps. [ref: henza_server, henza_admin, henza_pwa] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
66 KiB
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 |
/product/:id |
singleProduct |
src/components/product/SingleProduct.vue (props: true) |
layout: 'FullPage' |
/newest |
newestProduct |
src/views/NewestView.vue |
layout: 'FullPage' |
/cart |
cart |
src/views/CartView.vue |
(none) → MasterLayout, no auth guard |
/login |
login |
src/views/user/LoginRegisterView.vue |
layout: 'FullPage' |
/user |
userPage::hasSubRoute |
src/views/user/DashboardView.vue |
(none) → no auth guard (renders a signed-out invite state) |
/orders |
ordersList |
src/views/user/OrdersView.vue |
requiresAuth: true |
/transactions |
transactionsList |
src/views/user/TransactionsView.vue |
requiresAuth: true |
/profile |
profileData |
src/views/user/ProfileView.vue |
requiresAuth: true |
/favorites |
favoritesList |
src/views/user/FavoritesView.vue |
requiresAuth: true |
/notifications |
notifications |
src/views/user/NotificationsView.vue |
(none) — no auth guard |
/search |
search |
src/views/search/SearchView.vue |
(none) |
/collection/:id? |
collection |
src/views/search/CollectionView.vue |
(none); reads ?page=<title> query for the bar title |
/ |
— | redirect → {name:'home'} |
— |
/:pathMatch(.*)* |
— | catch-all redirect → {name:'home'} |
— |
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).showNavis written byrouter.afterEach(src/router/index.ts:95-101): visible only when the destination route name is one of the fivenavBarRoutes, 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-10documents "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/favoritesListviagoToDetails(id)(src/views/user/DashboardView.vuescript: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) |
| 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 |
| 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 |
| 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()setsprofileData.loadingtrue, then just setsregisteredInfo = true. No API call — nothing is persisted (:117-125, documented at:15-19).- Success dialog:
closeOnClickOverlay: false, imageregisterDelay.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 anis-ok/is-pendingseal (:13-15,:68).- Empty state
v-else-if="!loading": "اطلاعات حساب در دسترس نیست." (:41-44). try/finally, nocatch(:72-77).
6.10 views/user/FavoritesView.vue
- API:
api.listProducts()GET/api/catalog/productsthen filtered byfavorites.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 whenisPullis true — sopull()never re-fetches (useProducts.ts:25).totalPagesis permanently0(:19).- Client windowing:
PAGE_SIZE = 20,visibleCountgrows on@fetchfromScrollFetchSearch; only falls through togetProducts(loadingPull, true)(a no-op) once everything fetched is visible (NewestView.vue:54-56,:62-70). productsData.flag=productList.length < 1, passed toScrollFetchSearchas 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, thenp.collections.some(c => c.collectionId === route.params.id)(:41-43,:45-52). - Title:
route.query.pageas 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
Intlfa-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.vuetemplate:102) or raw (TransactionsView.vuescript:35;SingleProduct.vuetemplate:135). - There is no input mask / separator-on-type component anywhere.
price()is display-only. priceUnitandpriceUnitRialLessare 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.vuescript:12-21(see §6.8).- Order filter tabs —
src/views/user/OrdersView.vuescript: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 fromcurve.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 withcolors.json.index.html:8-13—theme-color/background-color/ body background hardcoded#1C2D56; title "طلای منتزر"; viewport lockedmaximum-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 inuser-app-v2only.
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:
- Hardcoded
port: 5179(user-app-v2/vite.config.ts:37) vs. the live config readingUSER_APP_V2_PORTfrom the root.env(= 5182). - No
strictPort, so it would silently drift to another port. - Hardcoded proxy targets
http://localhost:3000(:39-40) vs. the livehttp://localhost:${apiPort}, and noenvDir/loadEnvblock 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.yamldeclares packages asapps/*andpackages/*only — the rootuser-app-v2/is not in any workspace glob, so pnpm ignores it.- The root
package.jsonbuild:releasescript filters--filter user-app-v2, which resolves to the workspace package atapps/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.