Files
tehran_taj/apps/user-app/src/views/user/TransactionsView.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

343 lines
8.2 KiB
Vue

<!--
Ported from legacy henza_pwa/src/views/user/src/components/transactions.vue
("اسناد" — financial documents), restyled into the redesigned design
language (see components/home/* and HomeView).
Data source: legacy listed `Cash` rows from POST /get-my-findoc. The new
system's equivalent is LedgerEntry, already exposed by
GET /customers/:id/ledger-entries, so the list is real data.
Legacy's colour/icon logic is preserved in meaning: `hasFactor(item)` was
`item.type != 9` (type 9 = سفارش, the debt-creating order entry) → a green
"in" treatment for everything else, red "out" for orders. That maps 1:1 onto
our CREDIT/DEBIT direction, since an ORDER_STOCK entry is exactly the DEBIT
case. The palette is now the design system's own rather than hardcoded hexes.
DROPPED, unchanged from the previous version: the "دریافت فایل" per-document
download — legacy's getFile() hit the old Laravel host; there is no
document-file endpoint in the new API.
-->
<template>
<div class="tx">
<PageBar title="اسناد" back-to="userPage::hasSubRoute" />
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<container class="tx__body">
<template v-if="entries.length">
<SectionHead title="گردش حساب" :subtitle="`${entries.length} سند`" />
<div class="tx__list">
<article v-for="item in entries" :key="item.id" class="tx__row" @click="openEntry(item)">
<span class="tx__mark" :class="isCredit(item) ? 'is-in' : 'is-out'" aria-hidden="true">
<Icon :icon="isCredit(item) ? 'vuesax-linear:arrow-down' : 'vuesax-linear:arrow-up'" size="16" />
</span>
<div class="tx__main">
<div class="tx__head">
<h3 class="tx__kind">{{ SOURCE_LABELS[item.sourceType] ?? item.sourceType }}</h3>
<span class="tx__amount" :class="isCredit(item) ? 'is-in' : 'is-out'">
{{ formatAmount(item) }}
</span>
</div>
<p v-if="item.note" class="tx__note">{{ item.note }}</p>
<div class="tx__foot">
<span class="tx__date">{{ jdate(item.createdAt) }}</span>
<span class="tx__state" :class="{ 'is-pending': !item.confirmed }">
{{ item.confirmed ? 'تایید شده' : 'در انتظار تایید' }}
</span>
</div>
</div>
</article>
</div>
</template>
<section v-else-if="!loading" class="tx__empty">
<Icon icon="vuesax-linear:receipt-text" size="26" />
<p>سندی برای حساب شما ثبت نشده است.</p>
</section>
</container>
</Spinner>
<Drawer
v-model:visible="showPopUp"
placement="bottom"
v-if="activeEntry"
:closable="false"
height="420px"
class="rtl"
:bodyStyle="{ padding: 0 }"
>
<div class="sheet">
<h3 class="sheet__title">جزئیات سند</h3>
<dl class="sheet__specs">
<div class="sheet__spec">
<dt>نوع سند</dt>
<dd>{{ SOURCE_LABELS[activeEntry.sourceType] ?? activeEntry.sourceType }}</dd>
</div>
<div class="sheet__spec">
<dt>مبلغ</dt>
<dd :class="isCredit(activeEntry) ? 'is-in' : 'is-out'">{{ formatAmount(activeEntry) }}</dd>
</div>
<div class="sheet__spec">
<dt>وضعیت</dt>
<dd>{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</dd>
</div>
<div class="sheet__spec">
<dt>تاریخ</dt>
<dd>{{ jdate(activeEntry.createdAt) }}</dd>
</div>
<div class="sheet__spec">
<dt>توضیحات</dt>
<dd>{{ activeEntry.note || '—' }}</dd>
</div>
</dl>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type LedgerEntrySummary } from '../../api/client'
const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش',
ORDER_PRE_ORDER: 'سفارش ساخت',
REPAYMENT_MELTED_GOLD: 'طلای آب‌شده',
REPAYMENT_TRANSFER: 'حواله',
REPAYMENT_CHEQUE: 'چک',
REPAYMENT_CASH: 'پولی',
WALLET_BONUS: 'پاداش',
ADJUSTMENT: 'اصلاحیه',
}
const session = useSessionStore()
const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([])
const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null)
// legacy hasFactor(item) === item.type != 9 (9 = سفارش/order). Our DEBIT
// entries are exactly that case.
const isCredit = (item: LedgerEntrySummary) => item.direction === 'CREDIT'
const formatAmount = (item: LedgerEntrySummary) =>
item.currency === 'GOLD_GRAM' ? `${item.amount} گرم` : `${price(item.amount)} ریال`
const openEntry = (item: LedgerEntrySummary) => {
activeEntry.value = item
showPopUp.value = true
}
onMounted(async () => {
if (!session.isLoggedIn) return
loading.value = true
try {
entries.value = await api.getMyLedgerEntries()
} finally {
loading.value = false
}
})
</script>
<style scoped lang="less">
.tx {
background: @page;
min-height: 100vh;
}
.tx__body {
padding-top: 76px;
padding-bottom: 90px;
}
.tx__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.tx__row {
display: flex;
gap: 11px;
padding: 12px;
cursor: pointer;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
transition: transform 0.18s ease;
}
.tx__row:active {
transform: scale(0.99);
}
.tx__mark {
flex: 0 0 36px;
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
}
.tx__mark.is-in {
color: @green;
background: alpha(@green, 10%);
}
.tx__mark.is-out {
color: @wine;
background: @wineWash;
}
.tx__main {
flex: 1;
min-width: 0;
}
.tx__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.tx__kind {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tx__amount {
font-size: 12.5px;
font-weight: 800;
white-space: nowrap;
}
.tx__amount.is-in {
color: @green;
}
.tx__amount.is-out {
color: @wine;
}
.tx__note {
margin: 6px 0 0;
font-size: 11.5px;
line-height: 1.7;
color: @inkMuted;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tx__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 8px;
}
.tx__date {
font-size: 11px;
color: @inkFaint;
}
.tx__state {
font-size: 10.5px;
font-weight: 700;
color: @inkMuted;
}
.tx__state.is-pending {
color: @notify2;
}
.tx__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: @inkFaint;
}
.tx__empty p {
margin: 0;
font-size: 12.5px;
}
.sheet {
padding: 18px 16px 22px;
}
.sheet__title {
margin: 0 0 14px;
font-size: 13.5px;
font-weight: 800;
color: @ink;
}
.sheet__specs {
margin: 0;
border: 1px solid @hairline;
border-radius: 16px;
overflow: hidden;
}
.sheet__spec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 14px;
border-bottom: 1px solid @hairlineSoft;
}
.sheet__spec:last-child {
border-bottom: 0;
}
.sheet__spec dt {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.sheet__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
text-align: left;
}
.sheet__spec dd.is-in {
color: @green;
}
.sheet__spec dd.is-out {
color: @wine;
}
</style>