# Inventory — apps/user-app (target user app v1) All `path:line` references are relative to `apps/user-app/`. Scope: `src/**`, root config. `node_modules`/`dist` excluded. --- ## 1. Stack & versions Source: `package.json:1-38` | Field | Value | Line | |---|---|---| | name / version / private | `user-app` / `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` | ### Dependencies (`package.json:10-24`) | Package | Version | Line | |---|---|---| | @ant-design/icons-vue | ^6.1.0 | `package.json:11` | | @gold-bank/shared-types | workspace:* | `package.json:12` | | @vueuse/core | ^9.13.0 | `package.json:13` | | ant-design-vue | ^3.2.20 | `package.json:14` | | gsap | ^3.9.1 | `package.json:15` | | moment-jalaali | ^0.10.0 | `package.json:16` | | pinia | ^2.2.0 | `package.json:17` | | swiper | ^7.4.1 | `package.json:18` | | vant | ^4.0.3 | `package.json:19` | | vue | ^3.5.0 | `package.json:20` | | vue-router | ^4.4.0 | `package.json:21` | | vue3-lazyload | ^0.2.5-beta | `package.json:22` | | @lottiefiles/dotlottie-vue | ^0.4.15 | `package.json:23` | ### devDependencies (`package.json:25-37`) | Package | Version | Line | |---|---|---| | @vitejs/plugin-vue | ^5.1.0 | `package.json:26` | | autoprefixer | ^10.4.2 | `package.json:27` | | less | ^4.1.2 | `package.json:28` | | postcss | ^8.4.5 | `package.json:29` | | tailwindcss | 3.0.18 (pinned) | `package.json:30` | | typescript | ^5.5.0 | `package.json:31` | | vite | ^5.4.0 | `package.json:32` | | vue-tsc | ^2.1.0 | `package.json:33` | | vite-plugin-pwa | ^0.20.5 | `package.json:34` | | workbox-precaching / workbox-window | ^7.1.0 | `package.json:35-36` | Notes: `gsap` is declared but **unused** in `src/**` (the GSAP notch nav was replaced — `src/components/ui/Navigation.vue:1-10`). `swiper` is used only by `src/components/slider/HomeSlider.vue:37` and `src/components/slider/Slider.vue:42`, both of which have **no importers** (dead components). --- ## 2. HTTP layer Single file: `src/api/client.ts`. **No axios, no interceptors, no token attach, no refresh, no 401 handling, no error toasts.** | Aspect | Value | Line | |---|---|---| | Client | bare `fetch` wrapper `request()` | `src/api/client.ts:5-15` | | Base URL | const `BASE = '/api'` (relative; dev-proxied) | `src/api/client.ts:3` | | Default headers | `{'Content-Type':'application/json'}` only | `src/api/client.ts:7` | | Auth header | **none** — no Authorization/Bearer anywhere in the app | `src/api/client.ts:5-15` | | Refresh token | **none** | — | | 401 handling | **none** — any non-2xx throws `Error("API failed (): ")` | `src/api/client.ts:10-13` | | Error toast | **none** at the client layer; each caller does `console.log(e)` or a silent `catch` | e.g. `src/views/HomeView.vue:248`, `src/stores/cartStore.ts:52` | | Credentials / cookies | not set | — | | Retry / timeout / abort | none | — | ### Every API function | Function | Method | URL | Params / payload | Returns | Line | |---|---|---|---|---|---| | `api.listProducts()` | GET | `/api/catalog/products` | none | `ProductSummary[]` | `src/api/client.ts:160` | | `api.listCustomers()` | GET | `/api/customers` | none | `CustomerSummary[]` | `src/api/client.ts:161` | | `api.getCustomer(id)` | GET | `/api/customers/{id}` | path `id` | `CustomerDetail` | `src/api/client.ts:162` | | `api.getBalance(customerId)` | GET | `/api/accounting/customers/{customerId}/balance` | path `customerId` | `CustomerBalanceDto` | `src/api/client.ts:163` | | `api.getLedgerEntries(customerId)` | GET | `/api/customers/{customerId}/ledger-entries` | path `customerId` | `LedgerEntrySummary[]` | `src/api/client.ts:164` | | `api.createOrder(dto)` | POST | `/api/orders` | body `CreateOrderDto` = `{customerId, items:[{productVariantId, quantity, wagePercent}]}` | `OrderSummary` | `src/api/client.ts:165`; payload built at `src/views/CartView.vue:198-208` | | `api.getCustomerOrders(customerId)` | GET | `/api/customers/{customerId}/orders` | path `customerId` | `OrderDetail[]` | `src/api/client.ts:166` | | `api.getLatestGoldPrice(karatPerMille=750)` | GET | `/api/pricing/latest?karatPerMille={n}` | query `karatPerMille`, default **750** | `GoldPriceQuote` | `src/api/client.ts:167` | | `api.getHomeSlider()` | GET | `/api/home/slider` | none (public) | `AdminSlide[]` | `src/api/client.ts:170` | **Total: 9 API functions.** `api.getBalance` is declared but has **zero call sites** in `src/**`. ### DTO / response interfaces (`src/api/client.ts`) | Interface | Key fields | Line | |---|---|---| | `ProductImageSummary` | `id, url, sortOrder, role: 'FEATURED'\|'FEATURED_MOBILE'\|'GALLERY'\|'GALLERY_MOBILE'` | `17-22` | | `ProductVariantSummary` | `id, title, rashWeightGrams, defaultWagePercent, qty?, isPreOrder?, imageId?, image?` | `24-41` | | `CategorySummary` | `id, title, parentId` | `43-47` | | `BrandSummary` | `id, title` | `49-52` | | `CollectionSummary` | `id, title, description, isActive` | `54-59` | | `ProductSummary` | `id, title, description, productCode?, variants[], images[], category, brand, collections[]` | `61-75` | | `LedgerEntrySummary` | `id, currency, direction, sourceType, amount, confirmed, createdAt, note` | `77-86` | | `CustomerSummary` | `id, fullName, mobile, goldDebtGrams, fiatDebtRial, goldCreditLimitGrams, isActive, isArchived` | `88-97` | | `OrderSummary` | `id, orderNumber, status, totalGoldWeightGrams, isSuspendedOverCreditLimit` | `99-105` | | `OrderItemDetail` | `id, productVariantId, quantity, rashWeightGramsSnapshot, wagePercentSnapshot, discountPercentSnapshot, computedWeightGrams, flatRialCharge` | `107-116` | | `OrderDetail` | `id, orderNumber, status, totalGoldWeightGrams, wageWeightGrams, flatRialCharge, isSuspendedOverCreditLimit, deliveredAt, createdAt, items[]` | `118-129` | | `CustomerDetail` | `id, fullName, mobile, nationalCode, cityId, goldCreditLimitGrams, goldDebtGrams, fiatDebtRial, verificationStep, createdAt` | `131-142` | | `GoldPriceQuote` | `id, karatPerMille, pricePerGramRial, source, quotedAt` | `144-150` | | `AdminSlide` | `image, link` | `152-157` | --- ## 3. Router — every route Source: `src/router/index.ts:16-45`. History: `createWebHistory()` (`:9`). `scrollBehavior`: restore `savedPosition` on back/forward, else `{top:0}` (`:12-15`). | path | name | component file | meta | |---|---|---|---| | `/home` | `home` | `src/views/HomeView.vue` | — (Master layout, no guard) — `:18` | | `/product/:id` | `singleProduct` | `src/components/product/SingleProduct.vue` (`props:true`) | `layout: 'FullPage'` — `:19-25` | | `/newest` | `newestProduct` | `src/views/NewestView.vue` | `layout: 'FullPage'` — `:26` | | `/cart` | `cart` | `src/views/CartView.vue` | — (**no auth guard**; login enforced in-view) — `:27` | | `/login` | `login` | `src/views/user/LoginRegisterView.vue` | `layout: 'FullPage'` — `:28` | | `/user` | `userPage::hasSubRoute` | `src/views/user/DashboardView.vue` | — (**no guard**; view renders a signed-out invite) — `:29` | | `/orders` | `ordersList` | `src/views/user/OrdersView.vue` | `requiresAuth: true` — `:30` | | `/transactions` | `transactionsList` | `src/views/user/TransactionsView.vue` | `requiresAuth: true` — `:31-36` | | `/profile` | `profileData` | `src/views/user/ProfileView.vue` | `requiresAuth: true` — `:37` | | `/favorites` | `favoritesList` | `src/views/user/FavoritesView.vue` | `requiresAuth: true` — `:38` | | `/notifications` | `notifications` | `src/views/user/NotificationsView.vue` | — (no guard) — `:39` | | `/search` | `search` | `src/views/search/SearchView.vue` | — `:40` | | `/collection/:id?` | `collection` | `src/views/search/CollectionView.vue` | — (optional param; title via `?page=`) — `:41` | | `/` | — | redirect → `{name:'home'}` | `:43` | | `/:pathMatch(.*)*` | — | redirect → `{name:'home'}` | `:44` | **13 named routes + 2 redirects.** No `meta.title` and **no `meta.level` / level requirement anywhere** — there is no KYC/level gating in the router. --- ## 4. Navigation Bottom tab bar: `src/components/ui/Navigation.vue`; item list lives in `src/stores/navStore.ts:25-31`. | index | label | name | path | icon | |---|---|---|---|---| | 1 | خانه | `home` | `/home` | `vuesax-linear:home-2` — `navStore.ts:26` | | 2 | جستجو | `search` | `/search` | `vuesax-linear:search-normal-1` — `navStore.ts:27` | | 3 | سبد خرید | `cart` | `/cart` | `vuesax-linear:bag-happy` — `navStore.ts:28` | | 4 | سفارشات | `ordersList` | `/orders` | `vuesax-linear:receipt-text` — `navStore.ts:29` | | 5 | پروفایل | `userPage::hasSubRoute` | `/user` | `vuesax-linear:user` — `navStore.ts:30` | - Gating: **none per tab** — every tab is a plain `router-link` (`Navigation.vue:19-28`). `/orders` is gated by the router's `requiresAuth` and bounces to `/login`. - Visibility: bar is `v-show="nav.showNav"` (`Navigation.vue:12`). `router.afterEach` sets `showNav=true` + `setActiveLink(index)` only for the five tab routes, else `false` (`src/router/index.ts:95-101`). - Active indicator: CSS transform `translateX(-(activeLink-1)*100%)`, negative because the app is RTL (`Navigation.vue:40-44`). - SearchView hides the bar while its filter sheet is open (`src/views/search/SearchView.vue:228`) and restores it in `onBeforeRouteLeave` (`:262-264`). - Other nav surfaces: home bar cart/search links `src/views/HomeView.vue:25-34`; dashboard 4-tile grid `src/views/user/DashboardView.vue:184-200`; `AccountStrip` 3 action buttons (`src/components/home/AccountStrip.vue:32-43`) — **AccountStrip has no importer** (dead). --- ## 5. Guards | Guard | Rule | Line | |---|---|---| | `router.beforeEach` — already-logged-in | `to.name === 'login' && session.customerId` → redirect `{name:'home'}` | `src/router/index.ts:56-58` | | `router.beforeEach` — auth | `to.meta.requiresAuth && !session.customerId` → `{name:'login', query:{redirect: to.fullPath}}` | `src/router/index.ts:59-61` | | `router.beforeEach` — transition | `to.name==='singleProduct'` → `nav.setPgaTransition('slideDown')`, else `undefined` | `src/router/index.ts:64-65` | | `router.afterEach` | resets transition flags (100 ms cycle, inert — no consumer), sets active tab + nav visibility | `src/router/index.ts:80-102` | | `onBeforeRouteLeave` (SearchView) | restores nav visibility if the filter sheet is open | `src/views/search/SearchView.vue:262-264` | | In-view login gate (Cart) | `submitOrder()` → if `!session.customerId` open `loginNotify` dialog instead of posting | `src/views/CartView.vue:191-195` | | In-view login gate (Dashboard) | `goToDetails()` → if `!session.customerId` open `data.loginNotify` dialog | `src/views/user/DashboardView.vue:191-195` | | In-view login gate (product) | add-to-cart: `isUserLoggedIn() ? addToCart() : (loginNotify = true)` | `src/components/product/SingleProduct.vue:227`; helper `src/utils/checkAccess.ts:4-6` (`localStorage.getItem('appToken')`) | | Post-login redirect | `route.query.redirect` honoured, else `{name:'home'}` | `src/views/user/LoginView.vue:161-162` | **KYC / verification-level guard: does not exist.** `verificationStep` is read-only display text in `src/views/user/ProfileView.vue:32,54` with labels at `:75-80`. No route, button or flow is gated on it. Credit-limit (`goldCreditLimitGrams`, `isSuspendedOverCreditLimit`) is likewise never enforced client-side. --- ## 6. Views — all 14 files under `src/views` (recursive) | File | Purpose | |---|---| | `src/views/HomeView.vue` | Redesigned shop-first home: brand bar, live rate strip, hero slider, category rail, featured grid, newest reel, trust badges. | | `src/views/NewestView.vue` | "جدیدترین محصولات" grid with incremental reveal. | | `src/views/CartView.vue` | Basket → per-item note → confirm → POST order → success step. | | `src/views/search/SearchView.vue` | Search + category chips + filter sheet + collection tiles + result grid. | | `src/views/search/CollectionView.vue` | Products of one collection (client-side filter by route param). | | `src/views/user/LoginRegisterView.vue` | Toggle shell between LoginView and RegisterView. | | `src/views/user/LoginView.vue` | Mobile → OTP login (OTP verification stubbed). | | `src/views/user/RegisterView.vue` | 7-field registration form (submit stubbed, no endpoint). | | `src/views/user/DashboardView.vue` | "میز کار": balance card, 4-tile access grid, rate strip, logout dialog. | | `src/views/user/OrdersView.vue` | Order list, 9 status chips, number search, detail drawer with 4-step tracker. | | `src/views/user/TransactionsView.vue` | "اسناد" ledger list + detail drawer. | | `src/views/user/ProfileView.vue` | Read-only identity card (4 fields + verification seal). | | `src/views/user/FavoritesView.vue` | Local favourites filtered against the catalogue. | | `src/views/user/NotificationsView.vue` | Static empty state only — no data source. | ### 6.1 `HomeView.vue` | Aspect | Detail | Line | |---|---|---| | API | `api.listProducts()` GET `/api/catalog/products` + `memory.init()` (same endpoint) in `Promise.all` | `243-249` | | API | `api.getLatestGoldPrice()` GET `/api/pricing/latest?karatPerMille=750`; on throw → `goldPrice=null` | `251-255` | | API | `api.getHomeSlider()` GET `/api/home/slider`; on throw → `adminSlides=null` | `257-263` | | Derived data | `featured = products[0]`; `gridProducts = slice(1,5)`; `newestProducts = slice(5,13)` | `160-162` | | Hero source | admin slides when non-empty (filtered by `s.image`), else first 3 categories with rotating copy `SLIDE_COPY` (`مجموعه منتزر`/`دیدن مجموعه`, `انتخاب سردبیر`/`مشاهده`, `پیشنهاد این هفته`/`بررسی کنید`) | `170-202` | | Slide click | absolute `http(s)` link → `window.open(link,'_blank','noopener')`; other link → `router.push(link)`; else set `filters.query.cats=[categoryId]` → `/search` | `219-235` | | Category click | `filters.query.cats=[cat.id]` → `router.push({name:'search'})` | `214-217` | | Buttons | cart icon (badge = `cart.items.length`), search icon, `همه محصولات`→`/search`, `مشاهده همه`→`/newest`, ShowcaseCard→drawer | `25-34, 61, 75, 63-66` | | Pull-to-refresh | vant `PullRefresh` → `loadData()` | `38-42, 266-269` | | Empty state | `v-if="!loading && !products.length"` → "هنوز محصولی در مجموعه ثبت نشده است." | `88-91` | | Conditional render | hero only if `slides.length`; rail only if `memory.cats.length`; featured section if `featured`; newest section if `newestProducts.length` | `46, 48, 56, 70` | | Detail | ant `Drawer` bottom, `height=100vh`, hosts `SingleProduct` | `110-119` | | Not present | no price on any card, no form, no validation, no pagination params | file header `13-19` | ### 6.2 `CartView.vue` | Aspect | Detail | Line | |---|---|---| | API | `cart.init()` on mount + on pull-refresh → `api.listProducts()` (re-sync/prune) | `224-226, 219-222`; store `src/stores/cartStore.ts:39-55` | | API | `api.createOrder({customerId: session.customerId, items:[{productVariantId: item.vid, quantity: item.qty, wagePercent: parseFloat(variant.defaultWagePercent) \|\| 0}]})` POST `/api/orders` | `198-208` | | Steps | `step===1` basket, `step===2` success | `35, 66` | | Calculation shown | total weight only: `cart.total()` = `Σ parseFloat(rashWeightGrams)*qty`, `.toFixed(2)`, unit گرم. **No rial total.** | `86-89`; `src/stores/cartStore.ts:124-131` | | Form | note textarea, `rows=4`, placeholder "مثلاً سایز یا جزئیات سفارش"; **no validation rules** | `107-112` | | Note submit | `cart.updateFeild(note, tempRowIndex, tempRow, 'note')`, closes drawer, clears model | `176-181` | | Confirm dialog | `DialogVant` "آیا مطمئن به ثبت سفارش هستید ؟" — "بله"→`submitOrder`, "بی خیال"→close; `timeOut:-1`, `closeOnClickOverlay` | `116-125` | | Login dialog | "برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید." — "ورود/ثبت نام"→`/login`, "بیخیال"→close | `127-138` | | Loading | `Spinner :spinning="cartData.loadingPage"` tip "درحال دریافت اطلاعات"; `loadingPage` true during POST | `32, 213, 216` | | Submit bar | rendered only when `cart.items.length > 0 && step === 1`; button is **not** disabled while posting (only the Spinner overlays) | `82, 91` | | Success | shows `cartData.orderID` (= `order.orderNumber`), `cart.clear()`, CTA "فهرست سفارش‌ها" → `/orders` then `step=1` | `209-211, 185-188` | | Error | `catch { console.log(e) }` — **no user-visible error message on order failure** | `212-214` | | Empty | "سبد خرید شما خالی است." + CTA "دیدن محصولات" → `/home` | `56-62` | ### 6.3 `search/SearchView.vue` | Aspect | Detail | Line | |---|---|---| | API | `api.listProducts()` on mount and on pull-refresh | `206-213, 215-218, 266` | | Filters (all client-side) | `text` → `p.title.includes(text)`; `cats` → `cats.includes(p.category.id)`; `brands` → `brands.includes(p.brand.id)`; `limit_gram` (len 2) → any variant weight within `[min,max]` | `153-167` | | Pagination | **client-side window only**, `PAGE_SIZE = 20`, grown by `IntersectionObserver` with `rootMargin:'400px'`; reset to 20 on any filter change. No server page/limit params. | `174-199` | | Chips | "فیلتر" (shows `${filterKeys.length} فیلتر` when active), "همه" (clears cats), one chip per available category | `23-50` | | Collections | tiles → `router.push({path:'/collection/'+id, query:{page:title}})` | `71-82` | | Header | `PageBar` title flips to "فیلترها" while the sheet is open; back button closes the sheet instead of navigating | `17, 241-248` | | Nav side-effect | opening the sheet hides the tab bar; closing / leaving restores it | `226-237, 262-264` | | Empty | "محصولی با این مشخصات پیدا نشد." when `!hasMore && !loadingPage` | `103-106` | | Result count | SectionHead subtitle `${filteredProducts.length} محصول` | `84-88` | ### 6.4 `search/CollectionView.vue` | Aspect | Detail | Line | |---|---|---| | API | `api.listProducts()` on mount | `58-65` | | Filter | `p.collections.some(c => c.collectionId === route.params.id)` — client-side | `54-56` | | Title | `route.query.page` else fallback `'مجموعه'` | `52` | | Empty | "در این مجموعه محصولی ثبت نشده است." when `!loadingPage` | `26-29` | | Pagination | none | — | ### 6.5 `NewestView.vue` | Aspect | Detail | Line | |---|---|---| | API | `useProducts().getProducts(loadingPage, false)` → `api.listProducts()` | `59-61`; `src/composables/useProducts.ts:24-35` | | Pagination | `PAGE_SIZE = 20` window; `pull()` grows the window first, only then calls `getProducts(loadingPull, true)` — which is a **no-op** (`isPull` returns early) | `55-71`; `useProducts.ts:25` | | Empty | `ScrollFetchSearch` renders `EmptyState` when `productsData.flag` (= list length < 1) | `src/components/ui/ScrollFetchSearch.vue:19`; `useProducts.ts:29` | ### 6.6 `user/LoginView.vue` | Aspect | Detail | Line | |---|---|---| | API | Step 1: `api.listCustomers()` GET `/api/customers`; match `c.mobile === formModel.username && c.isActive && !c.isArchived` | `131-133` | | API | Step 2: **no request** — OTP not verified; `session.login(matchedCustomer.id, matchedCustomer.mobile)` | `153-160` | | Field | `username` / "تلفن همراه", `` | `62-63` | | Validation | `required('تلفن همراه')` → " لطفا تلفن همراه را وارد کنید "; `mobile('تلفن همراه')` pattern `/^(\+98\|0098\|98\|0)?9\d{9}$/g` → " مقدار تلفن همراه نا معتبر است " | `61`; `src/utils/validator.ts:5-8,23,3` | | Error message | not-found → NotifyVant red (`#DC2626`) "اطلاعات کاربری در سیستم وجود ندارد." (2000 ms) | `22, 103, 135-137` | | OTP | `OtpInput` `:num-inputs="4"`, `inputType="tel"`, auto-focus, `@on-complete="sendToken"` | `40-48` | | Timer | `timer = 120` s on step 2, 1 s countdown; label "امکان دریافت مجدد رمز در {counterZeroShow(timer)} ثانیه دیگر" (zero-padded < 10), else "برای دریافت رمز عبور مجدد تلاش کنید" | `112-122, 50-53, 170` | | Button | label `body.step===1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'`; `:disabled="body.loading \|\| (timer > 0 && body.step === 2)"` | `66-73, 172` | | Success | push `route.query.redirect` or `/home`, then notify "با موفقیت وارد شدید" | `161-164` | | Switch | "ثبت نام کنید" → `emit('register', true)` | `77-80` | | Digit conversion | **none** — no Persian→English digit normalisation on the mobile input | — | ### 6.7 `user/RegisterView.vue` | Field | Label | Control | Validation | 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('تلفن فروشگاه')` only (no `phone()` pattern) | `53-55` | | `address` | آدرس پستی | Textarea rows=3 | `required('آدرس')` | `56-58` | | `city` | شهر | Input (plain text, no picker) | `required('شهر')` | `59-61` | | Aspect | Detail | Line | |---|---|---| | API | **none — stubbed.** `register()` only sets `registeredInfo = true` | `117-125` | | Submit | button "ثبت اطلاعات", `type=submit`, no disabled state | `63` | | Success dialog | "ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید." + "متوجه شدم" → `/home`; `closeOnClickOverlay=false` | `74-86` | | Loading | `Spinner :spinning="profileData.loading"` tip "در حال ثبت اطلاعات" | `25` | ### 6.8 `user/DashboardView.vue` | Aspect | Detail | Line | |---|---|---| | API | `api.getCustomer(session.customerId)` GET `/api/customers/{id}` (only when signed in) | `208-211` | | API | `api.getLatestGoldPrice()`; on throw → null | `212-216` | | Calculations | `isDebtor = parseFloat(goldDebtGrams) > 0`; `grams = Math.abs(parseFloat(goldDebtGrams))`; label `بدهکار گرمی`/`بستانکار گرمی`; seal `بدهکار`/`بستانکار`; rial row `price(customer.fiatDebtRial)` + "ریال" | `176-180, 58-71` | | Access tiles | id 0 سفارشات→`ordersList`, 1 اسناد→`transactionsList`, 2 پروفایل→`profileData`, 3 علاقمندی→`favoritesList`; all `disabled:false` | `184-200` | | Gate | any tile while signed out → `data.loginNotify` dialog (not navigation) | `191-195` | | Logout dialog | "آیا می خواهید از برنامه خارج شوید؟" → "بله" = `session.logout()` + push `/home`; "بی خیال" | `123-132, 202-206` | | Login dialog | long invite copy → "ورود/ثبت نام" / "بیخیال" | `134-148` | | Signed-out block | invite card with CTA "ورود / ثبت‌نام" | `82-93` | | Pull-to-refresh | `PullRefresh` → `loadDashboard()` | `51, 219-222` | | Error | `api.getCustomer` is **not** wrapped in try/catch — a failure rejects `loadDashboard()` | `209-211` | ### 6.9 `user/OrdersView.vue` | Aspect | Detail | Line | |---|---|---| | API | `api.getCustomerOrders(session.customerId)` GET `/api/customers/{id}/orders` | `196-204` | | Filter chips (9) | `ALL` همه, `SUSPENDED` معلق, `PLACED` ثبت سفارش, `ADMIN_APPROVED` تایید مدیر سیستم, `SOURCING` درحال تامین, `PREPARING` درحال آماده سازی, `DELIVERED_TO_AGENT` تحویل مسئول فروش, `DELIVERED_TO_CUSTOMER` تحویل مشتری, `CANCELLED` لغو شده | `167-177` | | Search | `SearchInput` placeholder "جستجوی شماره سفارش" → `o.orderNumber.includes(term)` | `32, 188` | | Filtering | client-side (status + number). **No server-side query params, no pagination.** | `185-190` | | Detail drawer | 4-step tracker `ORDER_STEPS`, headline (orderNumber + coloured status), specs: items count "آیتم", `parseFloat(totalGoldWeightGrams).toFixed(2)` گرم, `jdate(createdAt)`; items via `OrderItemCard` | `86-138` | | Status colours | from `ORDER_STATUS_COLORS` (inline `background`/`color` styles) | `108-113, 210` | | Empty | "سفارشی با این مشخصات پیدا نشد." when `!loading` | `59-62` | | Pull-to-refresh | yes → `loadOrders()` | `51, 218-221` | ### 6.10 `user/TransactionsView.vue` | Aspect | Detail | Line | |---|---|---| | API | `api.getLedgerEntries(session.customerId)` GET `/api/customers/{id}/ledger-entries` | `142-150` | | Source labels | `ORDER_STOCK` سفارش, `ORDER_PRE_ORDER` سفارش ساخت, `REPAYMENT_MELTED_GOLD` طلای آب‌شده, `REPAYMENT_TRANSFER` حواله, `REPAYMENT_CHEQUE` چک, `REPAYMENT_CASH` پولی, `WALLET_BONUS` پاداش, `ADJUSTMENT` اصلاحیه | `112-121` | | Direction | `isCredit = direction === 'CREDIT'` → green/in arrow-down; else red/out arrow-up | `132, 31-33` | | Amount format | `currency === 'GOLD_GRAM' ? "{amount} گرم" : "{price(amount)} ریال"` | `134-135` | | Confirm state | `confirmed ? 'تایید شده' : 'در انتظار تایید'` | `47-49` | | Detail drawer | height 420px, 5 rows: نوع سند / مبلغ / وضعیت / تاریخ / توضیحات (`note \|\| '—'`) | `63-97` | | Empty | "سندی برای حساب شما ثبت نشده است." | `56-59` | | Filters / pagination | none | — | ### 6.11 `user/ProfileView.vue` | Aspect | Detail | Line | |---|---|---| | API | `api.getCustomer(session.customerId)` (skipped entirely when signed out) | `88-96` | | Fields (read-only) | نام و نام خانوادگی, تلفن همراه, کد ملی, وضعیت احراز هویت | `40-56` | | Verification labels | `UNVERIFIED` احراز نشده, `STEP_ONE_SUBMITTED` مرحله اول ثبت شده, `STEP_TWO_SUBMITTED` مرحله دوم ثبت شده, `VERIFIED` تایید شده | `75-80` | | Seal class | `is-ok` when `verificationStep === 'VERIFIED'`, else `is-pending` | `31-33, 86` | | Empty | "اطلاعات حساب در دسترس نیست." when `!loading` | `59-62` | | Editing / uploads | **none** — no edit form, no document upload, no bank fields | header `6-17` | ### 6.12 `user/FavoritesView.vue` | Aspect | Detail | Line | |---|---|---| | API | `api.listProducts()` on mount | `55-62` | | Filter | `allProducts.filter(p => favorites.isFavorite(p.id))` — local store, **not persisted** | `53`; `src/stores/favoritesStore.ts:9-21` | | Count | SectionHead subtitle `${favoriteProducts.length} محصول` | `19` | | Empty | "هنوز محصولی را به علاقه‌مندی‌ها اضافه نکرده‌اید." + CTA "دیدن محصولات" → `/home` | `25-31` | ### 6.13 `user/NotificationsView.vue` Static: `PageBar "اعلان‌ها" back-to="home"` + empty state "اعلانی برای نمایش وجود ندارد." — no API, no store, no props (`:9-17`). ### 6.14 `user/LoginRegisterView.vue` Shell: `showRegister` ref toggled by the `register` event from either child (`:7-13`). --- ## 7. Pinia stores | Store | id | State | Actions | Persistence | |---|---|---|---|---| | `src/stores/cartStore.ts` | `cart` | `items: CartItem[] {pid, vid, product, qty, note?}` (`:8-14,32-34`) | `init()` (GET `/catalog/products`, prune dead lines `:39-55`), `addToCart` (refuses non-orderable `:56-72`), `capQty` (`:79-84`), `maxQty` (`:87-93`), `remove`, `updateFeild` (clamps `qty` `:101-114`), `find_index`, `isInCart`, `variant`, `total()` (`:124-131`), `clear()` | **localStorage key `appCart`**, JSON, written by `persist()` on every mutation (`:16-29`) | | `src/stores/session.ts` | `session` | `customerId`, `mobile` (hydrated from localStorage `:11-12`) | `setCustomerId`, `login(customerId, mobile)`, `logout()` | localStorage keys **`gold-bank:customerId`**, **`gold-bank:mobile`**, **`appToken`** (= customerId, legacy compat) (`:20-32`) | | `src/stores/configStore.ts` | `config` | `config.main_slider: Slide[]` (3 hardcoded placeholder slides `:39-58`), `cats: HomeCategory[]`, `loaded` | `setCats`, `init()` — derives distinct categories from `api.listProducts()` (`:73-91`) | none | | `src/stores/favoritesStore.ts` | `favorites` | `ids: string[]` | `toggle(id)`, `isFavorite(id)` | **none — in-memory only**, lost on reload (`:1-21`) | | `src/stores/filtersStore.ts` | `filters` | `query: {colors[], brands[], ciny_id[], limit_percent[], limit_gram[], has_discount, text?, cats?, page:0}` (`:9-31`) | `reset()` | none | | `src/stores/navStore.ts` | `nav` | `navBarRoutes[5]`, `activeLink:1`, `showNav:true`, `pageTransition:'right'` | `setNavVisibility`, `setActiveLink`, `setPgaTransition` | none | | `src/stores/loadingStore.ts` | `loading` | `loading:false` | `setLoading` | none — **no importers (dead)** | | `src/stores/transitionStore.ts` | `transition` | `transitionFlag`, `transitionEnded`, `headerHeight` | `setTransitionFlag`, `setTransitionEnded`, `setHeaderHeight` | none; `transitionFlag` is inert (`src/router/index.ts:72-79`) | | `src/stores/windowDataStore.ts` | `windowData` | `width:0`, `pullingDown:false` | `setWidth`, `setPullingDown` | none | Unused filter fields (declared, never applied): `colors`, `limit_percent`, `ciny_id`, `has_discount`, `page` (`src/stores/filtersStore.ts:1-6`). --- ## 8. Shared utils ### `src/utils/filters.ts` — formatters | Function | Behaviour | Line | |---|---|---| | `price(val)` | thousands separator via `replace(/\B(?=(\d{3})+(?!\d))/g, ',')`; returns `val` unchanged when falsy (so `0` / `''` pass through) | `7-12` | | `priceUnit(val)` | same separator + `" تومان "` suffix | `14-19` | | `priceUnitRialLess(val)` | same separator + trailing space | `21-26` | | `jdate(val)` | `moment(val).format('jYYYY/jMM/jDD')` — parses ISO 8601 | `28-36` | **Digits: Western only.** File header explicitly forbids swapping to `fa-IR` (`:1-4`). There is **no Persian↔English digit converter anywhere in `src/**`**, and **no gram/weight formatter** — weights are printed raw or via ad-hoc `parseFloat(x).toFixed(2)` (`src/views/user/OrdersView.vue:123`, `src/components/product/OrderCard.vue:35`) or `Number(x)` (`src/components/product/SingleProduct.vue:140`). ### `src/utils/validator.ts` — rules (ant-design-vue rule objects) | Rule | Pattern / constraint | Message | Line | |---|---|---|---| | `message(field)` | — | `" مقدار {field} نا معتبر است "` | `3` | | `mobile` | `/^(\+98\|0098\|98\|0)?9\d{9}$/g` | `message` | `5-8` | | `phone` | `/^((\+98\|0098\|98\|0)\d1)?\d{8}$/g` | `message` | `9-12` | | `fa` | `/^([؀-ۿﮊپچگ‌ ])+$/` | `" مقدار {field} باید فارسی باشد "` | `13-16` | | `mail` | `type:'email'` | `message` | `17` | | `url` | `type:'url'` | `message` | `18` | | `username` | `/^(?!.*\.\.)(?!.*\.$)[^\W][\w.]{0,29}$/gim` | `" مقدار {field} باید شامل حروف و اعداد انگلیسی و _ باشد "` | `19-22` | | `required` | `required:true` | `" لطفا {field} را وارد کنید "` | `23` | | `sheba` | `/^IR\d{24}$/gim` | `message` | `24` | | `card` | `/^\d{16}$/gim` | `message` | `25` | | `zip` | `/^\d{10}$/gim` | `message` | `26` | | `num` | `/^\d*$/gim` | `message` | `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` have call sites (LoginView, RegisterView). ### `src/utils/orderStatus.ts` — enums (exact values) | Const | Content | Line | |---|---|---| | `ORDER_STATUS_LABELS` | `SUSPENDED:'معلق'`, `PLACED:'ثبت سفارش'`, `ADMIN_APPROVED:'تایید مدیر سیستم'`, `SOURCING:'درحال تامین'`, `PREPARING:'درحال آماده سازی'`, `DELIVERED_TO_AGENT:'تحویل مسئول فروش'`, `DELIVERED_TO_CUSTOMER:'تحویل مشتری'`, `CANCELLED:'لغو شده'` | `8-17` | | `ORDER_STATUS_COLORS` | `PLACED {#3B82F6,#EFF6FF}`, `SOURCING {#EAB308,#FEFCE8}`, `PREPARING {#F97316,#FFF7ED}`, `DELIVERED_TO_AGENT {#06B6D4,#ECFEFF}`, `DELIVERED_TO_CUSTOMER {#22C55E,#F0FDF4}` — SUSPENDED/ADMIN_APPROVED/CANCELLED deliberately uncoloured | `23-29` | | `ORDER_STEPS` | `['ثبت سفارش','آماده سازی','تحویل مشتری','لغو شده']` | `35` | | `orderStepIndex(status)` | PLACED/ADMIN_APPROVED/SUSPENDED→0; SOURCING/PREPARING→1; DELIVERED_TO_AGENT/DELIVERED_TO_CUSTOMER→2; CANCELLED→3; default 0 | `37-54` | ### Other constants | Const | Value | Line | |---|---|---| | `VERIFICATION_LABELS` | UNVERIFIED/STEP_ONE_SUBMITTED/STEP_TWO_SUBMITTED/VERIFIED → احراز نشده / مرحله اول ثبت شده / مرحله دوم ثبت شده / تایید شده | `src/views/user/ProfileView.vue:75-80` | | `SOURCE_LABELS` | ledger source types (see §6.10) | `src/views/user/TransactionsView.vue:112-121` | | `isUserLoggedIn()` | `localStorage.getItem('appToken')` | `src/utils/checkAccess.ts:4-6` | | Theme colours | `primary #1C2D56`, `secondary #8D6A46`, `danger #E31A1A`, `ink #14161A`, `wine #7A1B33`, `canvas #FFFFFF`, `hairline #ECEDEF` (+ 30 more) | `themeConfig/colors.json:1-38` | | Theme vars | `prefix: 'montzar'`, `font-size 14px`, `border-radius-base 5px`, `height-lg 42px`, `checkbox-size 20px` | `themeConfig/themeConfig.ts:15-43` | --- ## 9. Components (21 files + 1 icons.json) | File | Purpose / API | |---|---| | `src/components/icon/Icon.vue` | Inline-SVG icon from `icons.json` map; props `icon, color?, size?`; `tempIcon` is a computed (bug-fix noted `:19-34`), `applySize()` sets width/height after `nextTick` (`:36-47`). | | `src/components/input/OtpInput.vue` | **OTP input.** Props `defaultFocus, numInputs=4, separator, inputClasses, inputType, shouldAutoFocus=false, placeholder, disabled`; emits `on-change`, `on-complete`. Keycodes BACKSPACE 8 / LEFT 37 / RIGHT 39 / DELETE 46 (`:6-9`); paste handling `:67-83`; completion when joined length === numInputs (`:42-47`). | | `src/components/input/SingleOtpInput.vue` | One OTP cell: `maxlength=1`, `pattern="[0-9]"`, `min=0 max=9`, default `inputType='tel'`; only numeric keycodes + `[8,9,13,37,39,46,86]` pass (`:62-75`); focus selects (`:79-82`). | | `src/components/search/SearchInput.vue` | Debounced search field, `useDebounce(value, 700)` (`:42`); emits `search` on enter, icon click and debounce; clear button. **Known bug preserved:** watcher tests `if (value)` (the ref object, always truthy) not `value.value` — clear icon never auto-hides (`:8-11, 43-47`). | | `src/components/search/Filters.vue` | Filter sheet. Brand toggle-chips derived from products (`:95-101`); gram range via ant `Slider` `range reverse :step="0.01"`, bounds `[floor(min), ceil(max)]` of all `rashWeightGrams`, fallback `[0,100]` (`:103-107`), seeded once when `limit_gram` empty (`:114-122`). Buttons "پاک کردن فیلتر‌ها" (`resetFilters:true`) / "اعمال فیلتر" (`:75-77, 131-150`). Dropped: colour filter, wage-percent range, discount toggle (`:18-25`). | | `src/components/ui/DropDown.vue` | Accordion, props `text?`, local `show` ref (`:24-30`). | | `src/components/ui/Navigation.vue` | Bottom tab bar (§4). | | `src/components/ui/PageBar.vue` | Sticky bar; props `title`, `back=true`, `backTo?`; slots `trailing`, `below`; `onBack` = `router.push({name: backTo})` else `router.back()` (`:34-50`). | | `src/components/ui/Container.vue` | `
`; registered globally as `container` (`src/main.ts:31`). | | `src/components/ui/ContentWrapper.vue` | margin-top from `transitionStore.headerHeight` — **no importers**. | | `src/components/ui/ScrollFetch.vue` | Infinite scroll via `useInfiniteScroll(distance:50)`, default EmptyState — **no importers**. | | `src/components/ui/ScrollFetchSearch.vue` | Same, `favoritEmpty.svg` image; used by `NewestView.vue:41`. | | `src/components/ui/ScrollFetchAfterSearch.vue` | Same, `ordersEmpty.svg` — **no importers**. | | `src/components/Spinner.vue` | ant `Spin` wrapper; props `tip, size='large', cls, delay, spinning, indicator, fixed=true`; brand-logo indicator by default. | | `src/components/DialogVant.vue` | vant Dialog, 2-column button grid; slots `img/title/button`; **`show` is not a declared prop** — works via attribute fallthrough (`:1-10`). | | `src/components/DialogVantOneButton.vue` | Single-action variant; used by `src/App.vue:22`. | | `src/components/NotifyVant.vue` | vant Notify toast; props `modelValue, timeOut, text?, color?`; auto-hides after `timeOut` unless `-1` (`:34-44`). | | `src/components/EmptyState.vue` | ant `Empty description="موردی یافت نشد"`, optional image. | | `src/components/ViewAll.vue` | "مشاهده همه" router-link — **no importers**. | | `src/components/product/SingleProduct.vue` | Product detail (route + drawer). See §6 notes below. | | `src/components/product/ProductCard.vue` | Catalogue card: image (v-lazy), fav toggle, `ناموجود` / `پیش‌سفارش` flag, title, `{min_gram} گرم`, `اجرت {wage}٪`; opens its own 100vh Drawer with `SingleProduct` (`:53-62`). | | `src/components/product/CartItem.vue` | Cart line: qty stepper (`+` disabled when `!canIncrement`; at qty 1 the `-` becomes a trash/remove), note button toggling "توضیحات"/"ویرایش توضیحات" (`:32-63, 82-91`). | | `src/components/product/OrderCard.vue` | Order row: coloured mark + status pill, `{items.length} آیتم`, `toFixed(2) گرم`, `jdate(createdAt)`, "جزئیات" (`:15-48`). | | `src/components/product/OrderItemCard.vue` | Order line: image via `useOrderItemProduct` lookup, `{quantity} عدد`, `{computedWeightGrams} گرم`, `{wagePercentSnapshot} ٪ اجرت` (`:30-43`). | | `src/components/product/GallerySlider.vue` | Pointer-Events swipe gallery; `v-model:index`; props `slides, alt, fit='cover', showCounter=false`; emits `tap`; `DRAG_COMMIT_RATIO 0.18`, `TAP_MAX_PX 6`; track pinned `direction: ltr` for RTL correctness (`:74-192`). | | `src/components/product/Category.vue` | Legacy category tile with `id === '45'` special-case — **no importers** (`:1-45`). | | `src/components/home/RateStrip.vue` | **Price ticker.** Props `quote: GoldPriceQuote\|null`; label "نرخ طلای ۱۸ عیار"; value `price(pricePerGramRial.split('.')[0])` + "ریال / گرم"; `jdate(quotedAt)`; placeholder "در انتظار اعلام نرخ" when null (`:6-33`). | | `src/components/home/AccountStrip.vue` | Balance strip with 3 actions — **no importers** (dead). | | `src/components/home/CategoryRail.vue` | Horizontal category chips; emits `select`. | | `src/components/home/ShowcaseCard.vue` | Editorial card; props `value, wide?, badge?`; fav toggle; weight + wage; no price (`:10-48`). | | `src/components/home/ShowcaseSlider.vue` | Dependency-free hero slider; autoplay 6000 ms (`:81-84`), pointer drag, `DRAG_COMMIT_RATIO 0.18`, `TAP_MAX_PX 6` (`:102-103`), dots; `ShowcaseSlide {key, eyebrow, title, subtitle, cta, image?, categoryId?, link?}` (`:55-66`). | | `src/components/home/SectionHead.vue` | Title + subtitle + optional action button. | | `src/components/slider/HomeSlider.vue` | Swiper banner + popup drawer; slide types `popup`/`link`/`discount`; preserved bug: sets `query.discount` instead of `has_discount` (`:83-92`) — **no importers**. | | `src/components/slider/Slider.vue` | Swiper gallery + zoom drawer + GSAP-era SVG curve maths; preserved bug: `showPopup(item)` called with one arg so `index` is always undefined (`:1-9, 60-67`) — **no importers**. | **Missing component categories (do not exist in this app):** numeric/price input with thousands-separator masking, amount/gram input, date picker, file uploader, image uploader. ### Composables | File | Purpose | Key detail | |---|---|---| | `src/composables/useProduct.ts` | Adapts `ProductSummary` → card contract | `isVariantOrderable = qty>0 \|\| isPreOrder` (`:25-27`); `getCheapestVariant` = lowest `defaultWagePercent` among orderable, fallback all (`:29-40`); `hasDiscountWage` always null (`:56`); `has_warranty` always false (`:64`) | | `src/composables/useProductDetail.ts` | "Detail" fetch = full list + client-side `find` (no single-product endpoint) | `:16-29` | | `src/composables/useProducts.ts` | List + fake pagination; `totalPages` always 0; `getProducts(loading, isPull=true)` returns immediately | `:24-35` | | `src/composables/useOrderItemProduct.ts` | Module-level cached `Map` built from `listProducts()` | `:14-31` | | `src/composables/useRefresh.ts` | Auto-applies a waiting service worker | `:7-17` | ### Layouts | File | Behaviour | |---|---| | `src/layout/MasterLayout.vue` | `` + `` + a primary-coloured overscroll strip; tracks width via `useResizeObserver` → `windowDataStore`. Known issue: it renders its own `router-view`, so App.vue's keep-alive/suspense wrapper is bypassed (`:3-7`). | | `src/layout/FullPageLayout.vue` | Bare `` — selected by `meta.layout === 'FullPage'` (`src/App.vue:57-62`). | --- ## 10. Live price / sockets / polling | Aspect | Finding | |---|---| | Mechanism | **Pull-on-mount only.** `api.getLatestGoldPrice()` (GET `/api/pricing/latest?karatPerMille=750`) is called once per view mount. | | Call sites | `src/views/HomeView.vue:252` (+ pull-to-refresh `:266-269`); `src/views/user/DashboardView.vue:213` (+ pull-to-refresh `:219-222`). | | WebSocket / SSE | **none** — no `WebSocket`, `socket.io`, `EventSource` anywhere in `src/**`. | | Polling | **none** — no `setInterval` refresh for price. The only `setInterval` is the hero autoplay (`src/components/home/ShowcaseSlider.vue:81`); the only price-adjacent `setTimeout` is the OTP countdown (`src/views/user/LoginView.vue:115`). | | Display | `src/components/home/RateStrip.vue:13-19` — integer part only (`split('.')[0]`), thousands-separated, `ریال / گرم`, with Jalali `quotedAt`. | | Karat | fixed 750 (18k) default; no karat selector anywhere. | | Multi-row ticker | explicitly dropped (`src/views/user/DashboardView.vue:28-31`). | --- ## 11. Env / config / PWA | Item | Value | Line | |---|---|---| | `envDir` | repo root (`../..`) | `vite.config.ts:9,15` | | `USER_APP_PORT` | 5181 (fallback in config), declared in root `.env` | `vite.config.ts:11`; `.env:26` | | `API_PORT` | 3000 | `vite.config.ts:12`; `.env:24` | | Dev server | `host 0.0.0.0`, `strictPort: true` | `vite.config.ts:44-49` | | Proxy | `/api` → `http://localhost:3000`, `/uploads` → same | `vite.config.ts:50-53` | | `import.meta.env.*` | **no `VITE_*` variables are read anywhere in `src/**`** | — | | Less vars | `modifyVars: $themeLessVars`, `javascriptEnabled: true` | `vite.config.ts:55-62` | | PWA strategy | `VitePWA` `injectManifest`, `srcDir: 'src'`, `filename: 'sw.ts'`, `base '/'`, `includeAssets ['favicon.ico']`, `devOptions.enabled true` | `vite.config.ts:20-27` | | PWA manifest | name/short_name/description `Montzar`; `theme_color`/`background_color` = `$themeLessVars.primary` (`#1C2D56`); `start_url '/'`; `display 'standalone'`; icons 192 + 512 + 512-maskable | `vite.config.ts:28-41` | | Service worker | `precacheAndRoute(self.__WB_MANIFEST)` + `SKIP_WAITING` message handler | `src/sw.ts:9-14` | | Auto-update | `useRefresh()` applies a waiting SW immediately | `src/composables/useRefresh.ts:8-16` | | Version dialog | localStorage key `version` vs `package.json` version → "نسخه جدید منتزر منتشر شد!" + "به روز رسانی" (updateSW + reload) | `src/App.vue:66-81, 33-38` | | `public/manifest.json` | `{ "gcm_sender_id": "464066939468" }` only (legacy FCM leftover; not linked from `index.html`) | `public/manifest.json:1-3` | | `index.html` | title `طلای منتزر`, `theme-color #1C2D56`, `maximum-scale=1.0, user-scalable=no`, apple web-app meta | `index.html:6-11` | | localStorage keys (all) | `appCart`, `gold-bank:customerId`, `gold-bank:mobile`, `appToken`, `version` | `cartStore.ts:16`; `session.ts:22-25`; `App.vue:69` | | Global registrations | Pinia, router, `vue3-lazyload` (loading+error placeholder = `montzar-logo.png`), global `container` component | `src/main.ts:22-31` | | Style imports | `tailwind.css` → `ant-design-vue/dist/antd.less` → `style.less` → `vant/lib/index.css` | `src/main.ts:15-18` | | Docker | `Dockerfile`, `nginx.conf`, `.dockerignore` present at app root | — | --- ## 12. CANDIDATE PROTECTED — new features / new design not in a 2023-era reference PWA These are deliberate additions or redesigns in the target. Treat as protected unless explicitly told otherwise. ### 12.1 Entirely new pages / routes | Item | Path(s) | Evidence | |---|---|---| | **Notifications page** | route `/notifications` name `notifications`; `src/views/user/NotificationsView.vue` | `src/router/index.ts:39`; view header states "New nav destination (bottom nav redesign) — no legacy equivalent" (`NotificationsView.vue:1-6`) | ### 12.2 New / redesigned widgets and sections | Item | Path | Evidence | |---|---|---| | **Live 18k rate strip** (`RateStrip`) on home + dashboard | `src/components/home/RateStrip.vue`; used `src/views/HomeView.vue:44`, `src/views/user/DashboardView.vue:118` | Replaces legacy's multi-row `GET /price` ticker (`DashboardView.vue:28-31`) | | **Editorial hero slider** (dependency-free, pointer-drag, 6 s autoplay) | `src/components/home/ShowcaseSlider.vue` | Replaces the Swiper `HomeSlider` (now unused) — `ShowcaseSlider.vue:1-13` | | **Admin-managed home banner** consuming `GET /home/slider` | `src/api/client.ts:168-170`; `src/views/HomeView.vue:155, 176-201, 257-263` | New endpoint + new fallback-to-category hero logic | | **ShowcaseCard / ShowcaseSlider / CategoryRail / SectionHead / AccountStrip** — whole `components/home/` family | `src/components/home/*` | New design-system components; `ShowcaseCard.vue:1-9` | | **"برگزیده منتزر" featured section** (wide hero card + 2-col grid) | `src/views/HomeView.vue:56-68` | New home section | | **"تازه‌ها" horizontal reel** on home | `src/views/HomeView.vue:70-86` | New home section | | **Trust badges row** (خرید امن / اصالت کالا / ارسال بیمه‌شده) | `src/views/HomeView.vue:93-106`; also on product detail `src/components/product/SingleProduct.vue:193-196` | New widget | | **`PageBar`** — shared translucent sticky bar replacing the legacy dark AppHeader | `src/components/ui/PageBar.vue:1-9` | Whole-app chrome redesign; AppHeader deleted (`src/router/index.ts:74-79`) | | **Redesigned 5-tab bottom nav** with CSS sliding indicator (replaces 4-tab GSAP SVG notch) | `src/components/ui/Navigation.vue:1-10`; `src/stores/navStore.ts:25-31` | Explicit "Redesigned bottom nav — replaces the old GSAP SVG-notch bar" | | **`GallerySlider`** — Pointer-Events gallery rewrite with LTR-pinned track + full-screen teleported preview with thumbnails | `src/components/product/GallerySlider.vue:1-47`; preview `src/components/product/SingleProduct.vue:239-274` | From-scratch rewrite, not a port | | **Variant chips with per-variant images + stock badges + "نمایش همه" collapse** (`VARIANT_PREVIEW_COUNT = 6`) | `src/components/product/SingleProduct.vue:105-154, 370-374, 383-393` | New variant-picker design | | **Stock / pre-order model in UI** (`qty`, `isPreOrder`, `isVariantOrderable`, cart `capQty`/`maxQty` clamping, disabled `+`) | `src/composables/useProduct.ts:25-27,57-62`; `src/stores/cartStore.ts:56-93`; `src/components/product/CartItem.vue:82-88`; `src/components/product/SingleProduct.vue:466-480` | New business rule not in a 2023 reference | | **Pull-to-refresh** (vant `PullRefresh`) on Home, Cart, Search, Orders, Dashboard | `HomeView.vue:38-42`; `CartView.vue:35-39`; `SearchView.vue:65-69`; `OrdersView.vue:51`; `DashboardView.vue:51` | New interaction | | **IntersectionObserver incremental rendering** (`PAGE_SIZE = 20`) replacing server pagination | `src/views/search/SearchView.vue:169-204`; `src/views/NewestView.vue:49-71` | New rendering strategy | | **Ledger-based "اسناد" screen** (LedgerEntry + CREDIT/DEBIT + confirmed state + detail drawer) | `src/views/user/TransactionsView.vue:1-19, 112-140` | Replaces legacy Tahesab/Cash documents | | **Order detail as bottom Drawer with 4-step tracker + `OrderItemCard` lookup join** | `src/views/user/OrdersView.vue:67-141`; `src/composables/useOrderItemProduct.ts` | Redesigned flow (was a full-screen ant Modal) | | **Order status chips using the new `OrderStatus` enum (9 chips)** | `src/views/user/OrdersView.vue:167-177`; `src/utils/orderStatus.ts:8-17` | New enum, new labels | | **Dotlottie "new version" update dialog** | `src/App.vue:22-39, 46` (`@lottiefiles/dotlottie-vue`, remote lottie.host asset) | New dependency + new UX | | **Balance card with بدهکار/بستانکار seal** on dashboard | `src/views/user/DashboardView.vue:55-78, 176-180` | Redesigned; also duplicated in unused `AccountStrip.vue` | | **Brand-logo Spinner indicator** | `src/components/Spinner.vue:5` | New brand chrome | ### 12.3 New/changed data contracts worth protecting | Item | Path | |---|---| | `GET /home/slider` + `AdminSlide` type | `src/api/client.ts:152-157, 168-170` | | `ProductVariantSummary.qty / isPreOrder / imageId / image` | `src/api/client.ts:29-40` | | `ProductSummary.productCode / collections[]` | `src/api/client.ts:65-74` | | `GoldPriceQuote` (`karatPerMille`, `pricePerGramRial`, `quotedAt`) | `src/api/client.ts:144-150` | | `session` localStorage schema `gold-bank:*` alongside legacy `appToken` | `src/stores/session.ts:11-32` | ### 12.4 Deliberate regressions / stubs (documented in-file — verify before "fixing") | Item | Path | |---|---| | OTP code is **not** verified; any 4 digits log in as the matched customer | `src/views/user/LoginView.vue:152-160` | | Registration posts nothing (no create-customer endpoint) | `src/views/user/RegisterView.vue:117-125` | | Favourites are in-memory only, no backend | `src/stores/favoritesStore.ts:1-5` | | Placeholder home slides in `configStore` (used only if `HomeSlider` were mounted — it isn't) | `src/stores/configStore.ts:39-58` | | `SearchInput` clear-icon truthiness bug preserved | `src/components/search/SearchInput.vue:8-11` | | `HomeSlider` `query.discount` vs `has_discount` bug preserved | `src/components/slider/HomeSlider.vue:85-90` | | `Slider.vue` `showPopup` index bug preserved | `src/components/slider/Slider.vue:2-9` | | `MasterLayout` bypasses App.vue's keep-alive/suspense | `src/layout/MasterLayout.vue:3-7` | | `DialogVant` / `DialogVantOneButton` rely on attribute fallthrough for `show` | `src/components/DialogVant.vue:2-9` | ### 12.5 Dead code (no importers) — candidates for removal, not parity gaps `src/components/home/AccountStrip.vue`, `src/components/product/Category.vue`, `src/components/slider/HomeSlider.vue`, `src/components/slider/Slider.vue`, `src/components/ui/ContentWrapper.vue`, `src/components/ui/ScrollFetch.vue`, `src/components/ui/ScrollFetchAfterSearch.vue`, `src/components/ViewAll.vue`, `src/stores/loadingStore.ts`, `api.getBalance` (`src/api/client.ts:163`), `gsap` dependency (`package.json:15`). --- ## Summary counts | Metric | Count | |---|---| | Named routes | 13 (+2 redirects) | | View files under `src/views` | 14 | | API functions | 9 (1 uncalled) | | Pinia stores | 9 (1 unused) | | Reusable components | 34 `.vue` under `src/components` (8 with no importers) | | Composables | 5 | | Util modules | 4 | | Persisted localStorage keys | 5 |