feat(apps): sales-expert screens in both apps + v2 admin home slider (D21, APP-02)

Both expert screens are built in user-app and user-app-v2, with byte-identical
template and script and a per-app style block only — verified by diffing
everything above <style>:

  ChildOrdersView  "سفارش مشتریان"  — legacy ChildOrders.vue. Keeps the
    reference's title, search, infinite paging, and per-card item count, total
    weight, customer name and the fixed «تحویل مسئول فروش» chip (the endpoint
    only returns that status, ApiMobileController:766).
  CustomerChoiceView "لیست مشتریان" — legacy customerChoice.vue. Keeps its
    title, per-search re-query and the reference's choice to LIST inactive
    customers (its active() scope is commented out at :187).

Reachable from inside the app, not just by URL: routes carry the reference's own
paths and names, the dashboard grows the two entries only for an expert (v1 as
tiles, v2 as its own rows), and a router guard sends an ordinary customer back
to their dashboard. None of that is relied on for safety — the server scopes
both endpoints on the AdminUser id in the token and answers 403 otherwise.

Not ported, both recorded in D21: the act-as api_token minting (:197-201) and
the child-order edit drawer, which recomputed an order's money from client-sent
values. The card opens a read-only summary instead.

APP-02 fixed: user-app-v2 gained getHomeSlider and now prefers admin-configured
slides, keeping its category-derived hero as the fallback and its own design.

Session stores stay byte-identical across the apps.
build 5/5, jest 45/45, expert login 36/36, apps smoke 43/43, typecheck clean.

[ref: henza_pwa/src/views/user/src/components/ChildOrders.vue, customerChoice.vue]
[ref: henza_pwa/src/router/index.ts:171-188]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 01:12:53 +03:30
co-authored by Claude Opus 5
parent 69f4ba2b29
commit f71fd5bfe1
99 changed files with 1991 additions and 99 deletions
+43 -5
View File
@@ -107,7 +107,7 @@ import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore'
import { api } from '../api/client'
import type { GoldPriceQuote, ProductSummary } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
// Module scope, so it survives this view being unmounted and remounted as the
// user moves between tabs, and resets only on a full app reload.
@@ -121,6 +121,12 @@ const loading = ref(true)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
// knows whether to wait or fall back to the category-derived hero.
// Kept in step with user-app, which has had this since the admin screen was
// wired up; without it, slides configured in the admin panel never showed here.
const adminSlides = ref<AdminSlide[] | null>(null)
const productDrawer = ref(false)
const activeProductId = ref<string | null>(null)
@@ -156,14 +162,27 @@ const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
// The hero is built from REAL categories, not a curated banner payload (none
// exists). Each slide deep-links into search filtered by that category, so the
// artwork is decorative but the destination is genuine.
const slides = computed<HeroSlide[]>(() =>
memory.cats.slice(0, 5).map((cat) => ({
const slides = computed<HeroSlide[]>(() => {
// Prefer what the admin actually configured; the category-derived hero below
// stays as the fallback for when no slide has been uploaded.
if (adminSlides.value?.length) {
return adminSlides.value
.filter((s) => s.image)
.map((s, i) => ({
key: `${i}-${s.image}`,
title: '',
image: s.image,
link: s.link || undefined,
}))
}
return memory.cats.slice(0, 5).map((cat) => ({
key: cat.id,
title: cat.title,
image: productImageForCategory(cat.id),
categoryId: cat.id,
})),
)
}))
})
function productImageForCategory(categoryId: string): string | undefined {
const match = products.value.find((p) => p.category?.id === categoryId)
@@ -181,6 +200,17 @@ function onCategory(cat: HomeCategory) {
}
function onSlide(slide: HeroSlide) {
// Admin-uploaded slides (manage/Const.vue) carry their own destination
// instead of a categoryId — an absolute URL opens in a new tab, anything
// else is treated as an in-app path. Same rule as user-app.
if (slide.link) {
if (/^https?:\/\//i.test(slide.link)) {
window.open(slide.link, '_blank', 'noopener')
} else {
router.push(slide.link)
}
return
}
if (slide.categoryId) {
filters.query.cats = [slide.categoryId]
}
@@ -204,6 +234,14 @@ async function loadData() {
} catch {
goldPrice.value = null
}
try {
adminSlides.value = await api.getHomeSlider()
} catch {
// No admin slides configured, or the endpoint failed — the category-
// derived hero covers this silently, same as before this existed.
adminSlides.value = null
}
}
async function onRefresh() {