@@ -305,7 +306,7 @@ import type { ProductSummary, ProductVariantSummary } from '../../api/client'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
-import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
+import loginDialogImage from '../../assets/img/empty/dialogLogin.svg'
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
@@ -362,9 +363,8 @@ const isPreOrder = computed(() => productAdapter.value.isPreOrder.value)
const is_exist = computed(() => productAdapter.value.is_exist.value)
// ── Variant chips ───────────────────────────────────────────────
-// Two rows' worth is shown up front; the rest sit behind "نمایش همه" so a
-// product with 10+ weights does not push the description off the screen.
-const VARIANT_PREVIEW_COUNT = 6
+// Compact pills; extras behind a short toggle so weight lists don't dominate.
+const VARIANT_PREVIEW_COUNT = 8
const showAllVariants = ref(false)
const visibleVariants = computed(() =>
showAllVariants.value ? variants.value : variants.value.slice(0, VARIANT_PREVIEW_COUNT),
@@ -372,18 +372,15 @@ const visibleVariants = computed(() =>
const isOrderable = (v: ProductVariantSummary) => isVariantOrderable(v)
-/**
- * The chip picture: the variant's own image when the admin picked one, else the
- * product's main photo so chips never render as empty boxes. Variants sharing
- * one image (same design, several weights) intentionally show the same picture.
- */
-const variantImage = (v: ProductVariantSummary) =>
- v.image?.url ?? productDetail.productRef.value?.images?.[0]?.url ?? ''
-
const stockChipLabel = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'موجود'
return v.isPreOrder ? 'پیشسفارش' : 'ناموجود'
}
+/** One short glyph/word so the chip stays one line. */
+const stockChipShort = (v: ProductVariantSummary) => {
+ if ((v.qty ?? 0) > 0) return 'موجود'
+ return v.isPreOrder ? 'پیش' : '—'
+}
const stockChipClass = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'is-in'
return v.isPreOrder ? 'is-pre' : 'is-none'
@@ -486,7 +483,7 @@ const decrement = () => {
const addToCart = () => {
// The store refuses an un-orderable variant; only confirm when it accepted.
// Previously the "added to cart" toast fired either way, telling the customer
- // something had been added when nothing had. Kept in step with user-app-v2.
+ // something had been added when nothing had.
if (!cart.addToCart(storeProduct.value)) return
toast.success('محصول با موفقیت به سبد خرید افزوده شد', { title: 'ثبت سفارش' })
}
@@ -511,14 +508,22 @@ onBeforeUnmount(() => {
diff --git a/apps/user-app/src/components/search/Filters.vue b/apps/user-app/src/components/search/Filters.vue
index 3c8299f..63f425e 100644
--- a/apps/user-app/src/components/search/Filters.vue
+++ b/apps/user-app/src/components/search/Filters.vue
@@ -33,11 +33,9 @@
on its header, the × button, or the hardware/browser back
(close-on-popstate).
- STRUCTURE IS SHARED, LOOK IS NOT. The template and script here are kept
- identical to user-app-v2's Filters.vue; each app styles the same markup in
- its own design language (this one: soft-grey sheet with the 22px top radius
- the detail page's sheet uses, white hairline-bordered cards via DropDown,
- @primary 14px-radius buttons and pill chips, round controls).
+ Soft-grey sheet with the 22px top radius the detail page's sheet uses,
+ white hairline-bordered cards via DropDown, @primary 14px-radius buttons
+ and pill chips, round controls.
Choices are a DRAFT until "اعمال فیلتر": nothing touches the filters store
while the sheet is open, so dragging the gram slider no longer re-filters and
@@ -151,40 +149,24 @@ import { Slider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import DropDown from '../ui/DropDown.vue'
import type { FiltersQuery } from '../../stores/filtersStore'
-import type { CatalogFacets, ProductSummary } from '../../api/client'
+import type { ProductSummary } from '../../api/client'
-const props = defineProps<{
- show: boolean
- modelValue: FiltersQuery
- filterKeys: string[]
- /** @deprecated Prefer `facets` — scanning products only works while the whole catalogue is in memory. */
- products?: ProductSummary[]
- /** Brands + gram range from GET /catalog/products/facets (STUB-03). */
- facets?: CatalogFacets | null
-}>()
+const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'update:filterKeys', value: string[]): void
}>()
const availableBrands = computed(() => {
- if (props.facets?.brands?.length) return props.facets.brands
const seen = new Map
()
- for (const p of props.products ?? []) {
+ for (const p of props.products) {
if (p.brand) seen.set(p.brand.id, p.brand)
}
return [...seen.values()]
})
const gramBounds = computed((): [number, number] => {
- if (props.facets?.gramRange) {
- const min = parseFloat(props.facets.gramRange.min)
- const max = parseFloat(props.facets.gramRange.max)
- if (Number.isFinite(min) && Number.isFinite(max) && max >= min) {
- return [Math.floor(min), Math.ceil(max)]
- }
- }
- const weights = (props.products ?? []).flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
+ const weights = props.products.flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
if (weights.length === 0) return [0, 100]
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
diff --git a/apps/user-app/src/components/ui/AppPullRefresh.vue b/apps/user-app/src/components/ui/AppPullRefresh.vue
index f212abd..7a34a61 100644
--- a/apps/user-app/src/components/ui/AppPullRefresh.vue
+++ b/apps/user-app/src/components/ui/AppPullRefresh.vue
@@ -1,82 +1,252 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/user-app/src/components/ui/Navigation.vue b/apps/user-app/src/components/ui/Navigation.vue
index 0aeb067..dca3ed2 100644
--- a/apps/user-app/src/components/ui/Navigation.vue
+++ b/apps/user-app/src/components/ui/Navigation.vue
@@ -10,15 +10,15 @@
-->
-
+
-(nav.activeLink - 1) * 100)
inset: 6px 6px 6px auto;
width: calc((100% - 12px) / var(--count));
border-radius: 16px;
- background: @wineWash;
+ // Slightly deeper than plain @wineWash — just a touch of @wine mixed in.
+ background: color-mix(in srgb, @wineWash 98%, @wine 2%);
transition: transform 0.35s cubic-bezier(0.65, 0, 0.35, 1);
will-change: transform;
}
@@ -98,7 +99,7 @@ const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
}
.tabbar__item.is-active {
- color: @wine;
+ color: @primary;
transform: translateY(-2px);
}
diff --git a/apps/user-app/src/components/ui/PageBar.vue b/apps/user-app/src/components/ui/PageBar.vue
index 8e12fc0..d18d8b0 100644
--- a/apps/user-app/src/components/ui/PageBar.vue
+++ b/apps/user-app/src/components/ui/PageBar.vue
@@ -100,10 +100,10 @@ function onBack() {
.bar__title {
margin: 0;
- font-size: 13.5px;
- font-weight: 600;
+ font-size: 14.5px;
+ font-weight: 800;
color: @ink;
- letter-spacing: 0;
+ letter-spacing: -0.1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
diff --git a/apps/user-app/src/components/ui/RefreshOverlay.vue b/apps/user-app/src/components/ui/RefreshOverlay.vue
deleted file mode 100644
index 83cfbcd..0000000
--- a/apps/user-app/src/components/ui/RefreshOverlay.vue
+++ /dev/null
@@ -1,134 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/user-app/src/composables/useAppUpdate.ts b/apps/user-app/src/composables/useAppUpdate.ts
index 7198575..e9d9f26 100644
--- a/apps/user-app/src/composables/useAppUpdate.ts
+++ b/apps/user-app/src/composables/useAppUpdate.ts
@@ -7,6 +7,9 @@
// and «بهروزرسانی» drops the service worker and every Cache Storage entry,
// then reloads — so the next load is the new release, not a precached copy.
// localStorage is left alone: sign-in, cart and settings survive the update.
+//
+// `available` / `updating` are module-scoped so InstallPromptModal can wait
+// while the update gate is open (same truth, not a second check loop).
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
declare const __APP_BUILD__: number
@@ -23,10 +26,31 @@ const CHECK_EVERY_MS = 5 * 60 * 1000
export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0
export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''
-export function useAppUpdate() {
- const available = ref(null)
- const updating = ref(false)
+/** Shared so PWA install prompt can defer while the update gate is open. */
+export const updateGateAvailable = ref(null)
+const available = updateGateAvailable
+const updating = ref(false)
+let timer: ReturnType | undefined
+let listeners = 0
+
+async function check() {
+ if (import.meta.env.DEV || !runningBuild || available.value) return
+ try {
+ const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
+ if (!res.ok) return
+ const info = (await res.json()) as VersionInfo
+ if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
+ } catch {
+ // Offline or a deploy in progress: try again on the next tick.
+ }
+}
+
+const onVisible = () => {
+ if (document.visibilityState === 'visible') check()
+}
+
+export function useAppUpdate() {
/** What changed between the running build and the server's, newest first. */
const changes = computed(() => {
const info = available.value
@@ -34,18 +58,6 @@ export function useAppUpdate() {
return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
})
- async function check() {
- if (import.meta.env.DEV || !runningBuild || available.value) return
- try {
- const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
- if (!res.ok) return
- const info = (await res.json()) as VersionInfo
- if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
- } catch {
- // Offline or a deploy in progress: try again on the next tick.
- }
- }
-
async function applyUpdate() {
updating.value = true
try {
@@ -65,19 +77,22 @@ export function useAppUpdate() {
window.location.reload()
}
- let timer: ReturnType | undefined
- const onVisible = () => {
- if (document.visibilityState === 'visible') check()
- }
-
onMounted(() => {
- check()
- timer = setInterval(check, CHECK_EVERY_MS)
- document.addEventListener('visibilitychange', onVisible)
+ listeners += 1
+ if (listeners === 1) {
+ check()
+ timer = setInterval(check, CHECK_EVERY_MS)
+ document.addEventListener('visibilitychange', onVisible)
+ }
})
onBeforeUnmount(() => {
- if (timer) clearInterval(timer)
- document.removeEventListener('visibilitychange', onVisible)
+ listeners -= 1
+ if (listeners <= 0) {
+ listeners = 0
+ if (timer) clearInterval(timer)
+ timer = undefined
+ document.removeEventListener('visibilitychange', onVisible)
+ }
})
return { available, changes, updating, applyUpdate, check }
diff --git a/apps/user-app/src/composables/usePagedProducts.ts b/apps/user-app/src/composables/usePagedProducts.ts
deleted file mode 100644
index 14d7ff1..0000000
--- a/apps/user-app/src/composables/usePagedProducts.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-// Server-side catalogue paging (STUB-03 step 4 / docs/CATALOG-PAGINATION-PLAN.md).
-// Replaces "download everything, slice in the browser" for list screens.
-import { computed, ref } from 'vue'
-import { api, type ProductQuery, type ProductSummary } from '../api/client'
-
-export const CATALOG_PAGE_SIZE = 20
-
-export function usePagedProducts() {
- const rows = ref([])
- const total = ref(0)
- const page = ref(0)
- const loading = ref(false)
- const loadingMore = ref(false)
-
- const hasMore = computed(() => rows.value.length < total.value)
-
- /**
- * @param opts.preserveRows — pull-to-refresh: keep the current list on screen
- * until the new page arrives (avoids an empty flash / stuck full-page spinner).
- */
- async function resetAndLoad(query: ProductQuery, opts: { preserveRows?: boolean } = {}) {
- const showFullSpinner = !opts.preserveRows || rows.value.length === 0
- if (showFullSpinner) loading.value = true
- page.value = 1
- try {
- const res = await api.listProductsPaged({
- ...query,
- page: 1,
- limit: query.limit ?? CATALOG_PAGE_SIZE,
- })
- rows.value = res.rows
- total.value = res.total
- } finally {
- loading.value = false
- }
- }
-
- async function loadMore(query: ProductQuery) {
- if (loading.value || loadingMore.value || !hasMore.value) return
- loadingMore.value = true
- const next = page.value + 1
- try {
- const res = await api.listProductsPaged({
- ...query,
- page: next,
- limit: query.limit ?? CATALOG_PAGE_SIZE,
- })
- page.value = next
- // Append; drop accidental duplicates if the catalogue shifted between pages.
- const seen = new Set(rows.value.map((p) => p.id))
- rows.value = [...rows.value, ...res.rows.filter((p) => !seen.has(p.id))]
- total.value = res.total
- } finally {
- loadingMore.value = false
- }
- }
-
- function clear() {
- rows.value = []
- total.value = 0
- page.value = 0
- }
-
- return { rows, total, page, loading, loadingMore, hasMore, resetAndLoad, loadMore, clear }
-}
diff --git a/apps/user-app/src/composables/useProduct.ts b/apps/user-app/src/composables/useProduct.ts
index 16ffc50..eb28e8e 100644
--- a/apps/user-app/src/composables/useProduct.ts
+++ b/apps/user-app/src/composables/useProduct.ts
@@ -2,8 +2,8 @@
// same output shape legacy's Mixins/product/index.ts `useProduct()` produced.
// The legacy card components it was originally shaped for (ProductHorizontal/
// ProductVertical/ProductVerticalFull) are gone, replaced by the redesigned
-// components/product/ProductCard.vue, components/home/ShowcaseCard.vue and
-// components/product/CartItem.vue — all of which consume this same contract.
+// components/product/ProductCard.vue and components/product/CartItem.vue —
+// both of which consume this same contract.
//
// Honest gaps vs. legacy (no backend equivalent exists yet — apps/api was not
// touched to add these):
diff --git a/apps/user-app/src/composables/useProducts.ts b/apps/user-app/src/composables/useProducts.ts
index 421e528..01e99f0 100644
--- a/apps/user-app/src/composables/useProducts.ts
+++ b/apps/user-app/src/composables/useProducts.ts
@@ -1,9 +1,13 @@
-// Newest / list screens: true server paging (STUB-03 step 4).
-// Legacy paginated against POST /get-products; the early Nest port returned the
-// whole catalogue and treated `@fetch` as a no-op. This restores real pages.
-import { reactive } from 'vue'
+// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
+// against POST /get-products (page/limit/count). The new API's GET
+// /catalog/products returns the full active catalog in one flat response with
+// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
+// is a no-op here, and `totalPages` is always 0. A caller that wants new
+// products at the end of the list passes `force` to refresh the cached
+// catalogue instead of fabricating fake pages.
+import { reactive, watch } from 'vue'
import type { ProductSummary } from '../api/client'
-import { usePagedProducts, CATALOG_PAGE_SIZE } from './usePagedProducts'
+import { useCatalogStore } from '../stores/catalogStore'
export interface ProductsData {
totalPages: number
@@ -12,38 +16,28 @@ export interface ProductsData {
}
export function useProducts() {
- const paged = usePagedProducts()
+ const catalog = useCatalogStore()
const productsData: ProductsData = reactive({
totalPages: 0,
- productList: [] as ProductSummary[],
+ productList: catalog.products,
flag: false,
})
- function sync() {
- productsData.productList = paged.rows.value
- productsData.totalPages = Math.ceil(paged.total.value / CATALOG_PAGE_SIZE) || 0
- productsData.flag = paged.total.value < 1 && !paged.loading.value
- }
+ // A refresh from another screen (pull-to-refresh) lands here too.
+ watch(
+ () => catalog.products,
+ (list) => {
+ productsData.productList = list
+ productsData.flag = list.length < 1
+ },
+ )
const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => {
- void force
- if (isPull) {
- if (!paged.hasMore.value || paged.loadingMore.value) return
- loading.value = true
- try {
- await paged.loadMore({ sort: 'newest' })
- sync()
- } catch (e) {
- console.log(e)
- } finally {
- loading.value = false
- }
- return
- }
- loading.value = true
+ if (isPull) return // no further pages exist server-side yet
+ loading.value = force || !catalog.loaded
try {
- await paged.resetAndLoad({ sort: 'newest' })
- sync()
+ productsData.productList = await catalog.load(force)
+ productsData.flag = productsData.productList.length < 1
} catch (e) {
console.log(e)
} finally {
@@ -51,5 +45,5 @@ export function useProducts() {
}
}
- return { productsData, getProducts, hasMore: paged.hasMore }
+ return { productsData, getProducts }
}
diff --git a/apps/user-app/src/composables/usePwaInstall.ts b/apps/user-app/src/composables/usePwaInstall.ts
new file mode 100644
index 0000000..a31b3c2
--- /dev/null
+++ b/apps/user-app/src/composables/usePwaInstall.ts
@@ -0,0 +1,230 @@
+// Brand-first PWA install offer (see docs/superpowers/specs/2026-10-15-pwa-install-prompt-design.md).
+// Shared module state so App.vue and InstallPromptModal share one listener / one clock.
+import { computed, ref, watch, type Ref } from 'vue'
+import type { Router } from 'vue-router'
+import { updateGateAvailable } from './useAppUpdate'
+
+const STORAGE_KEY = 'pwaInstall'
+const REOFFER_MS = 7 * 24 * 60 * 60 * 1000
+const INITIAL_DELAY_MS = 4000
+
+export type PwaGuidePlatform = 'ios' | 'android'
+export type PwaInstallMode = 'offer' | 'guide'
+
+interface Stored {
+ dismissedAt: number | null
+ installed: boolean
+}
+
+interface BeforeInstallPromptEvent extends Event {
+ prompt: () => Promise
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
+}
+
+const visible = ref(false)
+const mode = ref('offer')
+const canNativeInstall = ref(false)
+const platform = ref(detectPlatform())
+
+let deferredPrompt: BeforeInstallPromptEvent | null = null
+let delayTimer: ReturnType | undefined
+let delayElapsed = false
+let bootstrapped = false
+let storageOk = true
+/** When true, van-dialog `@closed` must not write dismissedAt (route/gate hide). */
+let skipPersistOnClose = false
+
+function isStandalone(): boolean {
+ if (typeof window === 'undefined') return false
+ const mq = window.matchMedia?.('(display-mode: standalone)')?.matches
+ const iosStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true
+ return Boolean(mq || iosStandalone)
+}
+
+function detectPlatform(): PwaGuidePlatform {
+ if (typeof navigator === 'undefined') return 'android'
+ const ua = navigator.userAgent || ''
+ const iOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
+ return iOS ? 'ios' : 'android'
+}
+
+function load(): Stored {
+ const fallback: Stored = { dismissedAt: null, installed: false }
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (!raw) return fallback
+ const parsed = JSON.parse(raw) as Partial
+ return {
+ dismissedAt: typeof parsed.dismissedAt === 'number' ? parsed.dismissedAt : null,
+ installed: parsed.installed === true,
+ }
+ } catch {
+ storageOk = false
+ return fallback
+ }
+}
+
+function persist(value: Stored) {
+ if (!storageOk) return
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
+ } catch {
+ storageOk = false
+ }
+}
+
+const stored = ref(load())
+
+function syncInstalledFromEnvironment() {
+ if (!isStandalone()) return
+ if (stored.value.installed) return
+ stored.value = { ...stored.value, installed: true }
+ persist(stored.value)
+}
+
+function eligibilityCore(): boolean {
+ if (!storageOk) return false
+ syncInstalledFromEnvironment()
+ if (stored.value.installed || isStandalone()) return false
+ const dismissedAt = stored.value.dismissedAt
+ if (dismissedAt != null && Date.now() - dismissedAt < REOFFER_MS) return false
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return false
+ return true
+}
+
+const shouldShowPrompt = computed(() => eligibilityCore())
+
+function closeModal(opts?: { silent?: boolean }) {
+ skipPersistOnClose = opts?.silent === true
+ visible.value = false
+ mode.value = 'offer'
+}
+
+function dismiss() {
+ stored.value = { ...stored.value, dismissedAt: Date.now() }
+ persist(stored.value)
+ closeModal({ silent: true })
+}
+
+function markInstalled() {
+ stored.value = { dismissedAt: stored.value.dismissedAt, installed: true }
+ persist(stored.value)
+ deferredPrompt = null
+ canNativeInstall.value = false
+ closeModal({ silent: true })
+}
+
+/** Overlay / ESC close from van-dialog — start the 7-day clock unless silent. */
+function onDialogClosed() {
+ if (skipPersistOnClose) {
+ skipPersistOnClose = false
+ mode.value = 'offer'
+ return
+ }
+ if (!stored.value.installed) {
+ stored.value = { ...stored.value, dismissedAt: Date.now() }
+ persist(stored.value)
+ }
+ mode.value = 'offer'
+}
+
+function openGuide() {
+ mode.value = 'guide'
+}
+
+async function promptInstall(): Promise<'accepted' | 'dismissed' | 'unavailable'> {
+ if (!deferredPrompt) return 'unavailable'
+ const event = deferredPrompt
+ deferredPrompt = null
+ canNativeInstall.value = false
+ try {
+ await event.prompt()
+ const { outcome } = await event.userChoice
+ if (outcome === 'accepted') {
+ markInstalled()
+ return 'accepted'
+ }
+ return 'dismissed'
+ } catch {
+ return 'dismissed'
+ }
+}
+
+async function onPrimaryCta() {
+ if (canNativeInstall.value && deferredPrompt) {
+ await promptInstall()
+ return
+ }
+ openGuide()
+}
+
+function tryOpen(isFullPage: boolean, updateGateOpen: boolean) {
+ if (!delayElapsed) return
+ if (isFullPage || updateGateOpen) {
+ if (visible.value) closeModal({ silent: true })
+ return
+ }
+ if (!eligibilityCore()) return
+ if (visible.value) return
+ mode.value = 'offer'
+ visible.value = true
+}
+
+function onBeforeInstallPrompt(e: Event) {
+ e.preventDefault()
+ deferredPrompt = e as BeforeInstallPromptEvent
+ canNativeInstall.value = true
+}
+
+function onAppInstalled() {
+ markInstalled()
+}
+
+function bootstrapAutoPrompt(router: Router, updateAvailable: Ref) {
+ if (bootstrapped || typeof window === 'undefined') return
+ bootstrapped = true
+ platform.value = detectPlatform()
+ syncInstalledFromEnvironment()
+
+ window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt)
+ window.addEventListener('appinstalled', onAppInstalled)
+
+ const isFullPage = () => router.currentRoute.value.meta?.layout === 'FullPage'
+ const updateGateOpen = () => Boolean(updateAvailable.value)
+
+ delayTimer = setTimeout(() => {
+ delayElapsed = true
+ tryOpen(isFullPage(), updateGateOpen())
+ }, INITIAL_DELAY_MS)
+
+ watch(
+ () => [router.currentRoute.value.fullPath, router.currentRoute.value.meta?.layout, updateAvailable.value] as const,
+ () => tryOpen(isFullPage(), updateGateOpen()),
+ )
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') tryOpen(isFullPage(), updateGateOpen())
+ })
+}
+
+export function usePwaInstall() {
+ function start(router: Router) {
+ bootstrapAutoPrompt(router, updateGateAvailable)
+ }
+
+ return {
+ visible,
+ mode,
+ platform,
+ canNativeInstall,
+ shouldShowPrompt,
+ start,
+ dismiss,
+ markInstalled,
+ openGuide,
+ promptInstall,
+ onPrimaryCta,
+ closeModal,
+ onDialogClosed,
+ }
+}
diff --git a/apps/user-app/src/router/index.ts b/apps/user-app/src/router/index.ts
index eae48a0..c89bae1 100644
--- a/apps/user-app/src/router/index.ts
+++ b/apps/user-app/src/router/index.ts
@@ -224,7 +224,7 @@ router.afterEach((to) => {
// is open (SearchView does, for its filter sheet), and this is what clears
// that override on the way out — the old `if (!nav.showNav)` guard made the
// same write, just phrased as if something else might own the flag.
- const route = nav.navBarRoutes.find((x) => to.name === x.name);
+ const route = nav.visibleNavBarRoutes.find((x) => to.name === x.name);
if (route) {
nav.setActiveLink(route.index);
nav.setNavVisibility(true);
diff --git a/apps/user-app/src/stores/appearanceStore.ts b/apps/user-app/src/stores/appearanceStore.ts
index aee27a6..4dc7f32 100644
--- a/apps/user-app/src/stores/appearanceStore.ts
+++ b/apps/user-app/src/stores/appearanceStore.ts
@@ -14,7 +14,7 @@
// JS-driven motion that reads isReducedMotion (ProductMorph,
// carousel autoplay)
//
-// Kept identical in user-app and user-app-v2.
+// Kept identical in user-app.
import { defineStore } from 'pinia'
export type ThemeChoice = 'system' | 'light' | 'dark'
@@ -24,15 +24,16 @@ interface Stored {
reduceMotion: boolean
}
-export const APPEARANCE_STORAGE_KEY = 'gold-bank:appearance'
+export const APPEARANCE_STORAGE_KEY = 'hedena-gold:appearance'
const darkQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-color-scheme: dark)') : null)
const motionQuery = () => (typeof matchMedia !== 'undefined' ? matchMedia('(prefers-reduced-motion: reduce)') : null)
function load(): Stored {
- // First run: theme follows the system; motion follows the OS's own
- // "reduce motion" accessibility setting.
- const fallback: Stored = { theme: 'system', reduceMotion: !!motionQuery()?.matches }
+ // First run: light theme by default; motion follows the OS's own
+ // "reduce motion" accessibility setting. Users can still pick system/dark
+ // in Appearance settings.
+ const fallback: Stored = { theme: 'light', reduceMotion: !!motionQuery()?.matches }
try {
const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY)
if (!raw) return fallback
diff --git a/apps/user-app/src/stores/catalogStore.ts b/apps/user-app/src/stores/catalogStore.ts
index 143c3e2..cde0ce2 100644
--- a/apps/user-app/src/stores/catalogStore.ts
+++ b/apps/user-app/src/stores/catalogStore.ts
@@ -32,28 +32,12 @@ export const useCatalogStore = defineStore('catalog', () => {
// reason to keep loading the full catalogue.
async function load(force = false): Promise {
- // Prefer a bounded page when a caller still uses the unpaged helper — the
- // real catalogue reaches ~16k rows (STUB-03). Screens that need filters or
- // infinite scroll should call api.listProductsPaged directly.
- const list = await cache.fetch(
- KEYS.catalog,
- async () => {
- const page = await api.listProductsPaged({ page: 1, limit: 100, sort: 'newest' })
- return page.rows
- },
- { force },
- )
+ const list = await cache.fetch(KEYS.catalog, api.listProducts, { force })
products.value = list
loaded.value = true
return list
}
- /** Replace the in-memory slice (home rails, after a paged fetch). */
- function replace(list: ProductSummary[]) {
- products.value = list
- loaded.value = true
- }
-
function byId(id: string): ProductSummary | null {
return products.value.find((p) => p.id === id) ?? null
}
@@ -63,5 +47,5 @@ export const useCatalogStore = defineStore('catalog', () => {
cache.invalidate(KEYS.catalog)
}
- return { products, loaded, categories, load, replace, byId, invalidate }
+ return { products, loaded, categories, load, byId, invalidate }
})
diff --git a/apps/user-app/src/stores/navStore.ts b/apps/user-app/src/stores/navStore.ts
index c28a407..ae33f95 100644
--- a/apps/user-app/src/stores/navStore.ts
+++ b/apps/user-app/src/stores/navStore.ts
@@ -2,7 +2,9 @@
// class) to a real Pinia store, preserving the same state shape and setter
// behavior. The route list itself was redesigned to a 5-tab bottom nav
// (Home, Search, Cart, Orders, Profile) — see components/ui/Navigation.vue.
+// Orders is only shown while signed in (see visibleNavBarRoutes).
import { defineStore } from 'pinia'
+import { useSessionStore } from './session'
interface NavItem {
name: string
@@ -12,8 +14,16 @@ interface NavItem {
index: number
}
+/** Full tab set; indices are rewritten in `visibleNavBarRoutes` for the bar. */
+const ALL_NAV_ROUTES: Omit[] = [
+ { name: 'home', path: '/home', icon: 'vuesax-linear:home-2', label: 'خانه' },
+ { name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', label: 'جستجو' },
+ { name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', label: 'سبد خرید' },
+ { name: 'ordersList', path: '/orders', icon: 'vuesax-linear:receipt-text', label: 'سفارشات' },
+ { name: 'userPage::hasSubRoute', path: '/user', icon: 'vuesax-linear:user', label: 'پروفایل' },
+]
+
interface NavState {
- navBarRoutes: NavItem[]
activeLink: number
showNav: boolean
// legacy passes `undefined` to clear the transition (router/index.ts:270)
@@ -34,19 +44,23 @@ interface NavState {
export const useNavStore = defineStore('nav', {
state: (): NavState => ({
- navBarRoutes: [
- { name: 'home', path: '/home', icon: 'vuesax-linear:home-2', label: 'خانه', index: 1 },
- { name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', label: 'جستجو', index: 2 },
- { name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', label: 'سبد خرید', index: 3 },
- { name: 'ordersList', path: '/orders', icon: 'vuesax-linear:receipt-text', label: 'سفارشات', index: 4 },
- { name: 'userPage::hasSubRoute', path: '/user', icon: 'vuesax-linear:user', label: 'پروفایل', index: 5 },
- ],
activeLink: 1,
showNav: true,
pageTransition: 'right',
entryIndex: null,
}),
getters: {
+ /**
+ * Tabs shown in the bottom bar. سفارشات is omitted until the user is signed in.
+ * Indices are 1-based and contiguous so the sliding indicator stays aligned.
+ */
+ visibleNavBarRoutes(): NavItem[] {
+ const session = useSessionStore()
+ const routes = session.isLoggedIn
+ ? ALL_NAV_ROUTES
+ : ALL_NAV_ROUTES.filter((r) => r.name !== 'ordersList')
+ return routes.map((r, i) => ({ ...r, index: i + 1 }))
+ },
/**
* True when `router.back()` lands somewhere inside this app rather than on
* whatever the person was looking at before they opened it.
diff --git a/apps/user-app/src/utils/filters.ts b/apps/user-app/src/utils/filters.ts
index 587b894..0fdd613 100644
--- a/apps/user-app/src/utils/filters.ts
+++ b/apps/user-app/src/utils/filters.ts
@@ -20,19 +20,3 @@ export function jdate(val: string | undefined | null): string {
}
return val as any
}
-
-/** Jalali date + time for status timelines and similar detail surfaces. */
-export function jdatetime(val: string | undefined | null): string {
- if (val) {
- return moment(val).format('jYYYY/jMM/jDD HH:mm')
- }
- return val as any
-}
-
-/** Compact timeline stamp — e.g. «25 مهر، 11:19» (reference card style). */
-export function jtimelineWhen(val: string | undefined | null): string {
- if (val) {
- return moment(val).format('jD jMMMM، HH:mm')
- }
- return val as any
-}
diff --git a/apps/user-app/src/views/CartView.vue b/apps/user-app/src/views/CartView.vue
index 4de4450..569475f 100644
--- a/apps/user-app/src/views/CartView.vue
+++ b/apps/user-app/src/views/CartView.vue
@@ -90,21 +90,29 @@
-
+
+
توضیح برای {{ cartData.tempRow.product.title }}
@@ -124,7 +132,7 @@
-
+
برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.
@@ -143,7 +151,7 @@ import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
-import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
+import loginDialogImage from '../assets/img/empty/dialogLogin.svg'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
@@ -206,6 +214,9 @@ const submitOrder = async () => {
// Ignored by the server, which takes the customer from the token; still
// sent so older builds keep validating. Removed in Phase 2.
customerId: session.customerId ?? undefined,
+ // Wholesale (عمده) — same as legacy mobile; server also forces this for
+ // customer placements so admin omde list shows the order.
+ channel: 'WHOLESALE',
items: cart.items.map((item) => {
const variant = cart.variant(item)
return {
@@ -231,11 +242,8 @@ const submitOrder = async () => {
}
async function onRefresh() {
- try {
- await cart.init(true)
- } finally {
- refreshing.value = false
- }
+ await cart.init(true)
+ refreshing.value = false
}
onMounted(async () => {
@@ -413,14 +421,31 @@ onMounted(async () => {
}
.sheet {
- padding: 18px 16px 22px;
+ padding: 10px 16px 14px;
+ background: @canvas;
+ border: 1px solid @hairline;
+ border-radius: 22px;
+ box-shadow: 0 10px 28px -12px rgba(20, 22, 26, 0.18);
+}
+
+.sheet__handle {
+ width: 36px;
+ height: 4px;
+ margin: 0 auto 12px;
+ border-radius: 999px;
+ background: @hairline;
}
.sheet__title {
- margin: 0 0 14px;
+ margin: 0 0 10px;
font-size: 13.5px;
font-weight: 800;
color: @ink;
+ line-height: 1.5;
+}
+
+.sheet__field {
+ margin-bottom: 12px;
}
.sheet__cta {
@@ -434,5 +459,48 @@ onMounted(async () => {
font-weight: 700;
color: #fff;
background: @primary;
+ transition: transform 0.15s ease;
+}
+
+.sheet__cta:active {
+ transform: scale(0.98);
+}
+
+
+
+
diff --git a/apps/user-app/src/views/HomeView.vue b/apps/user-app/src/views/HomeView.vue
index 402f173..4b64f4d 100644
--- a/apps/user-app/src/views/HomeView.vue
+++ b/apps/user-app/src/views/HomeView.vue
@@ -4,11 +4,10 @@
Layout intent — shop first, account second:
1. a slim brand bar with search + cart
2. the live 18k rate strip (real: GET /pricing/latest)
- 3. an editorial showcase hero built from real categories
+ 3. spring card carousel hero (admin slider images)
4. the category rail (real: derived from GET /catalog/products)
- 5. "برگزیده" — a wide feature card + a two-column grid
- 6. "تازهها" — a horizontal rail
- 7. the account block (real: GET /customers/:id when signed in)
+ 5. "برگزیده" — staggered 2-col masonry (wireframe: unequal card heights, flush bottoms)
+ 6. "تازهها" — full-width horizontal ProductRow list
Data honesty, carried over from the previous version deliberately:
- the API exposes no curated home payload, no promotions and no per-product
@@ -36,10 +35,10 @@
-
+
-
-
+
-
-
-
-
+
@@ -76,14 +88,8 @@
action-label="مشاهده همه"
@action="goNewest"
/>
-
@@ -117,19 +109,19 @@ import { computed, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
-import { openProduct as openProductMorph } from '../composables/useProductMorph'
import RateStrip from '../components/home/RateStrip.vue'
-import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
+import Carousel from '../components/home/Carousel.vue'
import CategoryRail from '../components/home/CategoryRail.vue'
-import ShowcaseCard from '../components/home/ShowcaseCard.vue'
+import ProductCard from '../components/product/ProductCard.vue'
+import ProductRow from '../components/product/ProductRow.vue'
import SectionHead from '../components/home/SectionHead.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { useReveal } from '../composables/useReveal'
-import { api, type AdminSlide, type GoldPriceQuote, type ProductSummary } from '../api/client'
+import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
@@ -156,9 +148,55 @@ const adminSlides = ref
(reads.peek(KEYS.homeS
// and later returns to this tab do not replay it.
const { revealed, revealDelay, reveal } = useReveal('home')
-const featured = computed(() => products.value[0] ?? null)
-const gridProducts = computed(() => products.value.slice(1, 5))
-const newestProducts = computed(() => products.value.slice(5, 13))
+/**
+ * Featured masonry — matches the staggered wireframe: two equal columns,
+ * unequal card heights (seams don't line up), both columns end flush.
+ *
+ * Height budget (art+body): short 1.15, mid 1.30, tall 1.40, hero 1.55.
+ * Left short+hero+tall+mid = 5.40
+ * Right hero+short+mid+tall = 5.40 → same baseline, no empty well.
+ * Container is LTR so left/right stay stable; card text stays RTL.
+ */
+/**
+ * Even count only — an odd leftover sits alone under one column and leaves
+ * a white well beside it (the orphan the owner asked to drop).
+ */
+const featuredProducts = computed(() => {
+ const items = products.value.slice(0, 8)
+ return items.length % 2 === 1 ? items.slice(0, -1) : items
+})
+/** Last 10 in catalogue order, newest first in the row list. */
+const newestProducts = computed(() => products.value.slice(-10).reverse())
+
+type MasonrySize = 'hero' | 'tall' | 'mid' | 'short' | 'squat'
+type MasonryCol = 'left' | 'right'
+
+const TILES: ReadonlyArray<{ col: MasonryCol; size: MasonrySize }> = [
+ { col: 'left', size: 'short' },
+ { col: 'right', size: 'hero' },
+ { col: 'left', size: 'hero' },
+ { col: 'right', size: 'short' },
+ { col: 'left', size: 'tall' },
+ { col: 'right', size: 'mid' },
+ { col: 'left', size: 'mid' },
+ { col: 'right', size: 'tall' },
+]
+
+const masonryCols = computed(() => {
+ const items = featuredProducts.value
+ const left: { item: ProductSummary; size: MasonrySize }[] = []
+ const right: { item: ProductSummary; size: MasonrySize }[] = []
+
+ items.forEach((item, i) => {
+ const tile = TILES[i] ?? {
+ col: (i % 2 === 0 ? 'left' : 'right') as MasonryCol,
+ size: 'mid' as MasonrySize,
+ }
+ if (tile.col === 'left') left.push({ item, size: tile.size })
+ else right.push({ item, size: tile.size })
+ })
+ return { left, right }
+})
// The hero shows ONLY what the admin uploaded in the slider section
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
@@ -170,12 +208,6 @@ const slides = computed(() =>
.map((s, i) => ({ key: `${i}-${s.image}`, image: s.image })),
)
-// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
-// App.vue) — the card is found from the tap, so the emit chain stays as is.
-function openProduct(product: ProductSummary) {
- openProductMorph(product)
-}
-
function onCategory(cat: HomeCategory) {
filters.query.cats = [cat.id]
router.push({ name: 'search' })
@@ -191,14 +223,15 @@ function goNewest() {
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
- // STUB-03: rails need a small newest page. Pull-to-refresh must drop the
- // cached catalogue first (same rule as before paging) or the screen keeps
- // showing the previous slice while gold/slides refresh.
- (async () => {
- if (force) catalog.invalidate()
- const page = await api.listProductsPaged({ page: 1, limit: 13, sort: 'newest' })
- catalog.replace(page.rows)
- })(),
+ // The home rails read `catalog.products`, so the catalogue is loaded
+ // explicitly. It used to come in as a side effect of `memory.init()`, which
+ // now reads the small facets endpoint instead of the whole catalogue — so
+ // without this call the home screen would render empty rails.
+ //
+ // Still the unpaged list: the rails slice it locally («جدیدترین», per
+ // category). Moving them onto paged reads is step 4 of
+ // docs/CATALOG-PAGINATION-PLAN.md.
+ catalog.load(force),
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
@@ -213,13 +246,8 @@ async function loadData(force = false) {
}
async function onRefresh() {
- try {
- await loadData(true)
- } finally {
- // Always release Vant's PullRefresh, even when a request fails — otherwise
- // the spinner sticks until a full reload (same bug Dashboard already fixed).
- refreshing.value = false
- }
+ await loadData(true)
+ refreshing.value = false
}
onMounted(async () => {
@@ -275,7 +303,7 @@ onActivated(async () => {
.home__barInner {
max-width: 768px;
margin: 0 auto;
- padding: 11px 16px;
+ padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
@@ -283,7 +311,8 @@ onActivated(async () => {
}
.home__logo {
- height: 21px;
+ height: 52px;
+ width: 52px;
object-fit: contain;
}
@@ -317,7 +346,7 @@ onActivated(async () => {
}
.home__body {
- padding-top: 62px;
+ padding-top: 72px;
padding-bottom: 90px;
}
@@ -337,25 +366,28 @@ onActivated(async () => {
margin-top: 28px;
}
-.home__grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 11px;
- margin-top: 11px;
-}
-
-.home__reel {
+/* LTR columns so left/right placement is stable under a page-level RTL. */
+.home__masonry {
display: flex;
+ flex-direction: row;
+ direction: ltr;
+ align-items: flex-start;
gap: 11px;
- overflow-x: auto;
- margin: 0 -16px;
- padding: 0 16px;
- scroll-snap-type: x proximity;
}
-.home__reelItem {
- flex: 0 0 154px;
- scroll-snap-align: start;
+.home__masonryCol {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 11px;
+ direction: rtl;
+}
+
+.home__rows {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
}
.home__empty {
@@ -374,23 +406,4 @@ onActivated(async () => {
font-size: 12.5px;
}
-.home__trust {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 9px;
- margin-top: 26px;
-}
-
-.home__trustItem {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 7px;
- padding: 14px 6px;
- border-radius: 14px;
- background: @hairlineSoft;
- color: @inkMuted;
- font-size: 11px;
- font-weight: 600;
-}
diff --git a/apps/user-app/src/views/NewestView.vue b/apps/user-app/src/views/NewestView.vue
index bcb2f26..932387d 100644
--- a/apps/user-app/src/views/NewestView.vue
+++ b/apps/user-app/src/views/NewestView.vue
@@ -1,9 +1,12 @@
@@ -21,7 +24,7 @@
@@ -30,7 +33,7 @@
diff --git a/apps/user-app/src/views/search/CollectionView.vue b/apps/user-app/src/views/search/CollectionView.vue
index 971cfe8..6aeca6e 100644
--- a/apps/user-app/src/views/search/CollectionView.vue
+++ b/apps/user-app/src/views/search/CollectionView.vue
@@ -1,9 +1,15 @@
@@ -13,15 +19,11 @@
-
-
+
-
-
-
-
-
+
در این مجموعه محصولی ثبت نشده است.
@@ -31,51 +33,37 @@
@@ -97,12 +85,6 @@ onMounted(async () => {
gap: 11px;
}
-.coll__more {
- display: flex;
- justify-content: center;
- padding: 18px 0 4px;
-}
-
.coll__empty {
margin-top: 8px;
padding: 34px 20px;
diff --git a/apps/user-app/src/views/search/SearchView.vue b/apps/user-app/src/views/search/SearchView.vue
index a95d271..a8987f4 100644
--- a/apps/user-app/src/views/search/SearchView.vue
+++ b/apps/user-app/src/views/search/SearchView.vue
@@ -2,9 +2,15 @@
Ported from legacy henza_pwa/src/views/search/searchPage.vue, restyled into
the redesigned design language (see components/home/* and HomeView).
- STUB-03 step 4: filtering and paging are server-side (GET /catalog/products
- with page/limit/search/…). Facets drive the chip row and the Filters sheet.
- UI chrome is unchanged — only the data path moved.
+ All filtering still happens client-side against the one flat product list
+ (no server-side query params exist for any of this — consistent with
+ NewestView's own "single fetch" adaptation from an earlier batch). The
+ filter logic, the Filters sheet, the category chips and the collection
+ tiles are all carried over unchanged; only the chrome and the cards change.
+
+ The collection tiles keep their original destination (/collection/:id with
+ the title in ?page=), and are shown above the results exactly as the legacy
+ ProductList did on this route.
-->
@@ -56,8 +62,8 @@
-->
-
-
+
+
@@ -86,21 +92,23 @@
-
-
-
-
+
-
+
+
-
+
+
محصولی با این مشخصات پیدا نشد.
@@ -108,11 +116,13 @@
+
@@ -125,60 +135,107 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import Filters from '../../components/search/Filters.vue'
-import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
-import { api, type CatalogFacets, type ProductQuery } from '../../api/client'
import { useFiltersStore } from '../../stores/filtersStore'
-import { usePagedProducts } from '../../composables/usePagedProducts'
+import { useCatalogStore } from '../../stores/catalogStore'
const filters = useFiltersStore()
-const { rows, total, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
+const catalog = useCatalogStore()
-const loadingPage = computed(() => loading.value && rows.value.length === 0)
+// Starts true on a cold start: the first render happens before onMounted kicks
+// off the fetch, and with false the "nothing found" box flashed up for a frame
+// before the loading state replaced it. With the catalogue cached it renders at once.
+const loadingPage = ref(!catalog.loaded)
+// Two rows of the two-column grid — enough to fill the screen under the bar.
const SKELETON_CARDS = 6
const refreshing = ref(false)
+const allProducts = computed(() => catalog.products)
const showFilter = ref(false)
const filterKeys = ref
([])
-const facets = ref(null)
-const availableCategories = computed(() => facets.value?.categories ?? [])
-const availableCollections = computed(() => facets.value?.collections ?? [])
+const availableCategories = computed(() => {
+ const seen = new Map()
+ for (const p of allProducts.value) {
+ if (p.category) seen.set(p.category.id, p.category)
+ }
+ return [...seen.values()]
+})
-function toServerQuery(): ProductQuery {
+const availableCollections = computed(() => {
+ const seen = new Map()
+ for (const p of allProducts.value) {
+ for (const c of p.collections) seen.set(c.collection.id, { id: c.collection.id, title: c.collection.title })
+ }
+ return [...seen.values()]
+})
+
+const filteredProducts = computed(() => {
+ let list = allProducts.value
const q = filters.query
- const out: ProductQuery = { sort: 'newest' }
- if (q.text?.trim()) out.search = q.text.trim()
- if (q.cats?.[0]) out.categoryId = q.cats[0]
- if (q.brands.length) out.brandId = q.brands.join(',')
+ if (q.text) list = list.filter((p) => p.title.includes(q.text!))
+ if (q.cats && q.cats.length > 0) list = list.filter((p) => p.category && q.cats!.includes(p.category.id))
+ if (q.brands.length > 0) list = list.filter((p) => p.brand && q.brands.includes(p.brand.id))
if (q.limit_gram.length === 2) {
- out.minGram = q.limit_gram[0]
- out.maxGram = q.limit_gram[1]
+ list = list.filter((p) =>
+ p.variants.some(
+ (v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1],
+ ),
+ )
}
- return out
+ return list
+})
+
+// ── Incremental rendering ────────────────────────────────────────────────
+// The whole catalogue arrives in one request (no server-side paging exists),
+// so the filtering above is instant — but mounting every matching card at once
+// means hundreds of cards and images on a phone. Render a window instead and
+// grow it when the sentinel at the end scrolls into view.
+const PAGE_SIZE = 20
+const visibleCount = ref(PAGE_SIZE)
+const visibleProducts = computed(() => filteredProducts.value.slice(0, visibleCount.value))
+const hasMore = computed(() => visibleCount.value < filteredProducts.value.length)
+
+const sentinel = ref(null)
+let observer: IntersectionObserver | undefined
+
+function mountObserver() {
+ observer?.disconnect()
+ if (!sentinel.value) return
+ observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((e) => e.isIntersecting) && hasMore.value) {
+ visibleCount.value += PAGE_SIZE
+ }
+ },
+ { rootMargin: '400px' },
+ )
+ observer.observe(sentinel.value)
}
-async function loadFacets() {
- try {
- facets.value = await api.getCatalogFacets()
- } catch (e) {
- console.log(e)
- }
-}
+// Any change to the filters restarts the window from the top.
+watch(filteredProducts, () => {
+ visibleCount.value = PAGE_SIZE
+})
-async function reload(opts: { preserveRows?: boolean } = {}) {
+// The sentinel is v-if'd, so re-attach whenever it appears or is replaced.
+watch(sentinel, mountObserver)
+
+onBeforeUnmount(() => observer?.disconnect())
+
+async function loadProducts(force = false) {
+ loadingPage.value = !catalog.loaded
try {
- await resetAndLoad(toServerQuery(), opts)
+ await catalog.load(force)
} catch (e) {
console.log(e)
+ } finally {
+ loadingPage.value = false
}
}
async function onRefresh() {
- try {
- await Promise.all([loadFacets(), reload({ preserveRows: true })])
- } finally {
- refreshing.value = false
- }
+ await loadProducts(true)
+ refreshing.value = false
}
function search(value: string | undefined) {
@@ -191,45 +248,7 @@ function showFilters() {
showFilter.value = true
}
-const sentinel = ref(null)
-let observer: IntersectionObserver | undefined
-
-function mountObserver() {
- observer?.disconnect()
- if (!sentinel.value) return
- observer = new IntersectionObserver(
- (entries) => {
- if (entries.some((e) => e.isIntersecting) && hasMore.value) {
- void loadMore(toServerQuery())
- }
- },
- { rootMargin: '400px' },
- )
- observer.observe(sentinel.value)
-}
-
-watch(sentinel, mountObserver)
-onBeforeUnmount(() => observer?.disconnect())
-
-// Debounce text; category / brand / gram apply immediately.
-let textTimer: ReturnType | undefined
-watch(
- () => filters.query.text,
- () => {
- clearTimeout(textTimer)
- textTimer = setTimeout(() => void reload(), 300)
- },
-)
-watch(
- () => [filters.query.cats, filters.query.brands, filters.query.limit_gram] as const,
- () => void reload(),
- { deep: true },
-)
-
-onMounted(async () => {
- await loadFacets()
- await reload()
-})
+onMounted(() => loadProducts())
diff --git a/apps/user-app/src/views/user/LoginView.vue b/apps/user-app/src/views/user/LoginView.vue
index c0a86ce..8327db1 100644
--- a/apps/user-app/src/views/user/LoginView.vue
+++ b/apps/user-app/src/views/user/LoginView.vue
@@ -22,19 +22,22 @@
-
-
-
-
+
+
ورود به {{ brand.name }}
- {{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
+ {{ body.step === 1 ? 'با شماره موبایل وارد شوید؛ کد یکبارمصرف پیامک میشود.' : 'کد ۴ رقمی پیامکشده را وارد کنید.' }}
-
+
- اگر ثبت نام نکردهاید
- ثبت نام کنید
+ حساب ندارید؟
+ ثبتنام فروشگاه
@@ -85,7 +97,6 @@ import { brand } from '../../brand'
import { computed, nextTick, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Form, FormItem, Input } from 'ant-design-vue'
-import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import { toast } from '../../composables/useToast'
import OtpInput from '../../components/input/OtpInput.vue'
@@ -117,6 +128,12 @@ const countDownTimer = () => {
}
}
+function backToPhone() {
+ body.step = 1
+ body.defaultFocus = false
+ timer.value = 0
+}
+
/**
* Step 1 — ask the server to send an SMS code.
*
@@ -215,56 +232,93 @@ function extractServerMessage(e: Error): string | null {
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
-const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
+const btnText = computed(() => (body.step === 1 ? 'دریافت کد ورود' : 'ارسال مجدد کد'))
@@ -333,23 +442,25 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
/* OtpInput renders its inputs with this class from a prop, so the rule cannot
be scoped to this component. */
.otp-input {
- width: 48px;
- height: 52px;
+ width: 52px;
+ height: 56px;
padding: 5px;
margin: 0;
- font-size: 19px;
- font-weight: 700;
+ font-size: 20px;
+ font-weight: 800;
color: @ink;
- background: @canvas;
+ background: @canvasSoft;
border: 1px solid @hairline;
- border-radius: 13px;
+ border-radius: 16px;
text-align: center;
- transition: border-color 0.18s ease;
+ transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.otp-input:focus {
outline: none;
border-color: @primary;
+ box-shadow: 0 0 0 3px alpha(@primary, 12%);
+ background: @canvas;
}
.otp-input.error {
diff --git a/apps/user-app/src/views/user/RegisterView.vue b/apps/user-app/src/views/user/RegisterView.vue
index cfd3dd4..cea09f2 100644
--- a/apps/user-app/src/views/user/RegisterView.vue
+++ b/apps/user-app/src/views/user/RegisterView.vue
@@ -18,53 +18,91 @@
-->
-
+
-
+
+
+ ثبت درخواست عضویت
+
+
- اگر حساب کاربری دارید
- وارد شوید
+ قبلاً ثبتنام کردهاید؟
+ ورود به حساب
@@ -74,8 +112,8 @@
-
- ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.
+
+ ثبتنام شما ثبت شد. پس از تأیید کارشناسان میتوانید با همین شماره وارد شوید.
@@ -86,11 +124,11 @@