First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
613 lines
51 KiB
Markdown
613 lines
51 KiB
Markdown
# 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<T>()` | `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 <path> failed (<status>): <body>")` | `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` / "تلفن همراه", `<Input type="number">` | `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` | `<main class="px-[16px] max-w-screen-md m-auto ...">`; 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<variantId, {productTitle, variantTitle, imageUrl}>` built from `listProducts()` | `:14-31` |
|
|
| `src/composables/useRefresh.ts` | Auto-applies a waiting service worker | `:7-17` |
|
|
|
|
### Layouts
|
|
|
|
| File | Behaviour |
|
|
|---|---|
|
|
| `src/layout/MasterLayout.vue` | `<router-view/>` + `<Navigation/>` + 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 `<router-view/>` — 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 |
|