Files
hedena_gold/apps/user-app/src/views/expert/CustomerChoiceView.vue
T
s.esmaeili f441ca8289 feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet
Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.

Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
  dashboard and available without login. It offers a light/dark/system
  theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
  script in index.html applies it before first paint so there is no
  white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.

Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
  values come from a generated virtual:palette.css (themeConfig/
  appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
  photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
  surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
  .van-theme-dark.
- Light mode looks the same as before.

Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
  loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
  exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
  setting.

Product detail
- Opening a product is now a container-transform morph from the tapped
  card (ProductMorph). It replaces the bottom Drawer and uses composited
  transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
  opens blank and jumps.

Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
  until applied, so the result list no longer re-renders while
  dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
  max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).

Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
  filters never cleared the selected brands.
2026-10-04 16:18:56 +03:30

347 lines
9.2 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
"لیست مشتریان" — the sales expert's own referred customers.
Ported from legacy henza_pwa/src/views/user/src/components/customerChoice.vue
(route name customerChoice, title "انتخاب مشتری"), restyled into this app's
design language. The LOGIC is the reference's and is identical in
user-app-v2; only the styling differs.
Kept from the reference:
- the header title "لیست مشتریان" (customerChoice.vue:8)
- the search box, re-querying on each search (:11, :184)
- per card: name, mobile, phone, address and the active/inactive state
- inactive customers are LISTED, not hidden — the reference's `active()`
scope is commented out at ApiMobileController.php:187, so every referred
customer appears whatever their state (D4: replicate, don't "fix")
Deliberately NOT ported — two things, both recorded in D21:
1. `changeUser` / act-as. The reference minted an api_token for each listed
customer (ApiMobileController.php:197-201) so the agent could act AS them.
That is a session-hijack primitive; every session here derives from the
caller's own token, so tapping a card opens their details instead.
2. The activate/deactivate toggle and the edit button, which posted to
`/customer/toggle-active` and the customer form. Those are admin-panel
operations here, and adding a write path to the app during a delivery is
not worth the risk. The state is shown, read-only.
Scope is the server's: `referredByAgentId` = this expert, from the token.
-->
<template>
<div class="cch">
<PageBar title="لیست مشتریان" back-to="userPage::hasSubRoute">
<template #below>
<div class="cch__barExtra">
<SearchInput @search="readSearch" place-holder="جستجو" />
</div>
</template>
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cch__body">
<div v-if="rows.length" class="cch__list">
<article
v-for="item in rows"
:key="item.id"
class="cch__card"
@click="openDetail(item)"
>
<div class="cch__head">
<h3 class="cch__name">{{ item.name }}</h3>
<span class="cch__state" :class="item.active === 1 ? 'is-on' : 'is-off'">
{{ item.active === 1 ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="cch__facts">
<div class="cch__fact">
<Icon icon="vuesax-linear:mobile" size="15" />
<span dir="ltr">{{ item.mobile || '—' }}</span>
</div>
<div class="cch__fact">
<Icon icon="vuesax-linear:call" size="15" />
<span dir="ltr">{{ item.phone || '—' }}</span>
</div>
<div v-if="item.store_name" class="cch__fact">
<Icon icon="vuesax-linear:shop" size="15" />
<span>{{ item.store_name }}</span>
</div>
</div>
</article>
</div>
<section v-else-if="!loading && !failed" class="cch__empty">
<Icon icon="vuesax-linear:profile-2user" size="26" />
<p>{{ search ? 'مشتری با این مشخصات پیدا نشد.' : 'هنوز مشتری‌ای به شما معرفی نشده است.' }}</p>
</section>
<section v-if="failed" class="cch__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت اطلاعات با خطا روبه‌رو شد.</p>
<button type="button" class="cch__retry" @click="load">تلاش دوباره</button>
</section>
</container>
</PullRefresh>
</Spinner>
<Drawer
v-model:open="detailOpen"
placement="bottom"
height="auto"
:closable="false"
:body-style="{ padding: '0' }"
>
<div v-if="active" class="cch__detail">
<h2 class="cch__detailTitle">{{ active.name }}</h2>
<dl class="cch__rows">
<div class="cch__row">
<dt>موبایل</dt>
<dd dir="ltr">{{ active.mobile || '—' }}</dd>
</div>
<div class="cch__row">
<dt>تلفن</dt>
<dd dir="ltr">{{ active.phone || '—' }}</dd>
</div>
<div class="cch__row">
<dt>نام مغازه</dt>
<dd>{{ active.store_name || '—' }}</dd>
</div>
<div class="cch__row">
<dt>نام پاساژ</dt>
<dd>{{ active.pasazh_name || '—' }}</dd>
</div>
<div class="cch__row">
<dt>سقف اعتبار</dt>
<dd dir="ltr">{{ grams(active.gram_level) }} گرم</dd>
</div>
<div class="cch__row">
<dt>بدهی گرمی</dt>
<dd dir="ltr">{{ grams(active.remain) }} گرم</dd>
</div>
<div class="cch__row">
<dt>وضعیت</dt>
<dd>{{ active.active === 1 ? 'فعال' : 'غیرفعال' }}</dd>
</div>
</dl>
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import { api, type ExpertCustomer } from '../../api/client'
const rows = ref<ExpertCustomer[]>([])
const loading = ref(false)
const refreshing = ref(false)
const failed = ref(false)
const search = ref<string | null>(null)
const detailOpen = ref(false)
const active = ref<ExpertCustomer | null>(null)
function grams(value: string | number | null | undefined): string {
const n = Number(value ?? 0)
return Number.isFinite(n) ? n.toFixed(3) : '0.000'
}
/**
* The reference returned the whole list in one call (no paging on /my-child,
* ApiMobileController.php:195 ends in `->get()`), so this does the same.
*/
async function load() {
loading.value = true
failed.value = false
try {
rows.value = await api.getExpertCustomers(search.value)
} catch {
failed.value = true
rows.value = []
} finally {
loading.value = false
}
}
/** The reference re-queried the server on each search (customerChoice.vue:184). */
async function readSearch(value: string | undefined) {
search.value = value?.trim() ? value.trim() : null
await load()
}
async function onRefresh() {
await load()
refreshing.value = false
}
function openDetail(item: ExpertCustomer) {
active.value = item
detailOpen.value = true
}
onMounted(load)
</script>
<style scoped lang="less">
.cch__barExtra {
padding: 0 16px 12px;
}
.cch__body {
padding-top: 132px;
padding-bottom: 90px;
}
.cch__list {
display: flex;
flex-direction: column;
padding-top: 16px;
gap: 11px;
}
.cch__card {
padding: 16px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
cursor: pointer;
transition: transform 0.2s ease;
}
.cch__card:active {
transform: scale(0.99);
}
.cch__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.cch__name {
margin: 0;
font-size: 15.5px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cch__state {
flex: 0 0 auto;
padding: 4px 12px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
}
.cch__state.is-on {
color: @green;
background: alpha(@green, 14%);
}
.cch__state.is-off {
color: @red;
background: alpha(@red, 12%);
}
.cch__facts {
display: flex;
flex-direction: column;
gap: 7px;
}
.cch__fact {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
color: @inkMuted;
}
.cch__empty {
margin-top: 8px;
padding: 34px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkMuted;
font-size: 13px;
}
.cch__retry {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cch__detail {
padding: 20px 16px 24px;
}
.cch__detailTitle {
margin: 0 0 16px;
font-size: 17px;
font-weight: 700;
}
.cch__rows {
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.cch__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 11px 14px;
background: @canvasSoft;
border-radius: 10px;
}
.cch__row dt {
font-size: 13px;
color: @inkMuted;
}
.cch__row dd {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.cch__close {
margin-top: 18px;
width: 100%;
padding: 13px;
border: none;
border-radius: 14px;
background: @primary;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>