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.
347 lines
9.2 KiB
Vue
347 lines
9.2 KiB
Vue
<!--
|
||
"لیست مشتریان" — 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>
|