Files
tehran_taj/docs/parity/_inv-tgt-userappv2.md
s.esmaeiliandClaude Opus 5 71192ba2ba parity(docs): Phase 0 inventory, mapping and draft PROTECTED list
Read-only inventory of all 7 projects (3 reference + 4 target) produced by
parallel subagents, every claim cited path:line. No source code changed.

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

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

[ref: henza_server, henza_admin, henza_pwa]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 18:09:46 +03:30

66 KiB
Raw Permalink Blame History

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). 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)
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() 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.