feat: move brand name to one config and redesign the accounting card
اپ: نام برند در همهی صفحهها و پیامها یکسان شده و از یک تنظیم مرکزی خوانده میشود. اپ: کارت حسابداری در صفحهی مشتری بازطراحی شد؛ ماندهها و وضعیت لینک واضحتر نمایش داده میشوند. پنل: کارت حسابداری در پروندهی مشتری بازطراحی شد؛ ماندهها، آخرین ارسال و فرم لینک دستی واضحتر شدهاند. پنل: ستون «کد» در جدولها بهطور پیشفرض پنهان است و از انتخاب ستونها قابل نمایش است. پنل: تغییر نمایش ستونها در جدولها بلافاصله اعمال میشود. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 5.5
parent
5d2a1efb0c
commit
532ceee189
@@ -344,8 +344,8 @@ onMounted(async () => {
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.cart__done {
|
||||
@@ -400,8 +400,8 @@ onMounted(async () => {
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.cart__bar {
|
||||
@@ -460,8 +460,8 @@ onMounted(async () => {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ onMounted(async () => {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="home">
|
||||
<header class="home__bar">
|
||||
<div class="home__barInner">
|
||||
<img src="/img/montzar-logo.png" alt="منتزر" class="home__logo" />
|
||||
<img src="/img/brand-logo.png" :alt="brand.name" class="home__logo" />
|
||||
<RateStrip :quote="goldPrice" compact class="home__rate" />
|
||||
</div>
|
||||
</header>
|
||||
@@ -39,7 +39,6 @@
|
||||
:slides="slides"
|
||||
class="home__hero home__reveal"
|
||||
:style="revealDelay(0)"
|
||||
@select="onSlide"
|
||||
/>
|
||||
|
||||
<section v-if="memory.cats.length" class="home__section home__reveal" :style="revealDelay(1)">
|
||||
@@ -80,6 +79,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { brand } from '../brand'
|
||||
import { computed, nextTick, onActivated, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
@@ -149,35 +149,15 @@ const newestProducts = computed(() => products.value)
|
||||
const reelProducts = computed(() => products.value.slice(0, REEL_LIMIT))
|
||||
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[]>(() => {
|
||||
// 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)
|
||||
return match?.images?.[0]?.url
|
||||
}
|
||||
// The hero shows ONLY what the admin uploaded in the slider section
|
||||
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
|
||||
// from categories and filled with product photos, so a newly added product
|
||||
// could land in the hero. Slides are display-only: tapping one does nothing.
|
||||
const slides = computed<HeroSlide[]>(() =>
|
||||
(adminSlides.value ?? [])
|
||||
.filter((s) => s.image)
|
||||
.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.
|
||||
@@ -190,24 +170,6 @@ function onCategory(cat: HomeCategory) {
|
||||
router.push({ name: 'search' })
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
router.push({ name: 'search' })
|
||||
}
|
||||
|
||||
function goNewest() {
|
||||
router.push({ name: 'newestProduct' })
|
||||
}
|
||||
|
||||
@@ -422,8 +422,8 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
color: @onInk;
|
||||
background: @wine;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -392,8 +392,8 @@ onMounted(load)
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
color: @onInk;
|
||||
background: @wine;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -271,14 +271,14 @@ onMounted(() => loadProducts())
|
||||
}
|
||||
|
||||
.search__chip.is-on {
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
}
|
||||
|
||||
.search__chip--filter.is-on {
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
background: @wineDeep;
|
||||
border-color: @wineDeep;
|
||||
}
|
||||
|
||||
.search__chip:active {
|
||||
|
||||
@@ -289,7 +289,7 @@ const THEMES: { value: ThemeChoice; label: string; icon: string }[] = [
|
||||
}
|
||||
.ap__theme.is-on {
|
||||
color: @ink;
|
||||
box-shadow: inset 0 0 0 1.5px @ink;
|
||||
box-shadow: inset 0 0 0 1.5px @wine;
|
||||
}
|
||||
|
||||
.ap__preview {
|
||||
@@ -306,8 +306,8 @@ const THEMES: { value: ThemeChoice; label: string; icon: string }[] = [
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.ap__row {
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
v-model:show="data.notify"
|
||||
art="logout"
|
||||
title="آیا می خواهید از برنامه خارج شوید؟"
|
||||
description="برای استفاده دوباره از امکانات منتزر، باید دوباره وارد حساب کاربری خود شوید."
|
||||
:description="`برای استفاده دوباره از امکانات ${brand.name}، باید دوباره وارد حساب کاربری خود شوید.`"
|
||||
confirm-text="بله"
|
||||
cancel-text="بی خیال"
|
||||
@confirm="logOut"
|
||||
@@ -232,7 +232,7 @@
|
||||
v-model:show="data.loginNotify"
|
||||
art="login"
|
||||
title="ابتدا وارد حساب کاربری شوید"
|
||||
description="برای دسترسی به تمامی امکانات نرم افزار منتزر، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید."
|
||||
:description="`برای دسترسی به تمامی امکانات نرم افزار ${brand.name}، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.`"
|
||||
confirm-text="ورود/ثبت نام"
|
||||
cancel-text="بیخیال"
|
||||
@confirm="router.push({ name: 'login' })"
|
||||
@@ -241,6 +241,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { brand } from '../../brand'
|
||||
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
@@ -453,9 +454,9 @@ onMounted(async () => {
|
||||
margin-bottom: 28px;
|
||||
padding: 20px 18px 16px;
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5E1427 130%);
|
||||
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5A0027 130%);
|
||||
color: #fff;
|
||||
box-shadow: 0 22px 40px -26px rgba(20, 22, 26, 0.32);
|
||||
box-shadow: 0 22px 40px -26px rgba(124, 0, 53, 0.32);
|
||||
}
|
||||
|
||||
.dash__heroTop {
|
||||
|
||||
@@ -125,7 +125,7 @@ watch(() => favorites.ids.join(','), load)
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="auth__mark" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:security-user" size="28" />
|
||||
</div>
|
||||
<h2 class="auth__title">ورود به منتزر</h2>
|
||||
<h2 class="auth__title">ورود به {{ brand.name }}</h2>
|
||||
<p class="auth__sub">
|
||||
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
|
||||
</p>
|
||||
@@ -81,6 +81,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
@@ -147,7 +148,7 @@ const sendPhone = async () => {
|
||||
// this branch the person is told "خطا در ارسال کد"، which is the
|
||||
// very confusion the 404 was added to remove.
|
||||
const text =
|
||||
e instanceof Error && /\((?:429|404)\)/.test(e.message) ? extractServerMessage(e) : null
|
||||
e instanceof Error && /\((?:429|404|403)\)/.test(e.message) ? extractServerMessage(e) : null
|
||||
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
body.loading = false
|
||||
@@ -294,8 +295,8 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -347,7 +348,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
|
||||
.otp-input:focus {
|
||||
outline: none;
|
||||
border-color: @ink;
|
||||
border-color: @wine;
|
||||
}
|
||||
|
||||
.otp-input.error {
|
||||
|
||||
@@ -298,9 +298,9 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
.ord__chip.is-on {
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
}
|
||||
|
||||
.ord__chip:active {
|
||||
@@ -430,15 +430,15 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
.detail__step.is-done .detail__stepDot {
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
}
|
||||
|
||||
.detail__step.is-active .detail__stepDot {
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
background: @wineDeep;
|
||||
border-color: @wineDeep;
|
||||
}
|
||||
|
||||
.detail__stepTitle {
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
drops the leading 0 and shows spinners), and Persian/Arabic digits are
|
||||
converted to Latin as the user types, so the validators accept them.
|
||||
|
||||
STUBBED, unchanged from before: legacy's POST /major/register has no
|
||||
counterpart in apps/api yet. Submitting shows the "awaiting review" state
|
||||
without persisting anything — the copy says the request is under review,
|
||||
which is what legacy said too, so it does not claim an account exists.
|
||||
Submits to POST customer/auth/register (legacy POST /major/register). The new
|
||||
customer is created inactive and lands in the admin's «مشتریان در انتظار»
|
||||
list; they can sign in once an admin approves them.
|
||||
-->
|
||||
<template>
|
||||
<div class="reg">
|
||||
@@ -192,6 +191,8 @@ import { useRouter } from 'vue-router'
|
||||
import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import { api } from '../../api/client'
|
||||
import { toast } from '../../composables/useToast'
|
||||
|
||||
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
|
||||
const router = useRouter()
|
||||
@@ -235,14 +236,35 @@ async function onFinish() {
|
||||
}
|
||||
try {
|
||||
loading.value = true
|
||||
// Stubbed — no create-customer endpoint exists in apps/api yet.
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
await api.register({
|
||||
fname: String(formModel.fname ?? ''),
|
||||
lname: String(formModel.lname ?? ''),
|
||||
mobile: String(formModel.mobile ?? ''),
|
||||
store_name: String(formModel.store_name ?? ''),
|
||||
phone: String(formModel.phone ?? ''),
|
||||
address: String(formModel.address ?? ''),
|
||||
city: String(formModel.city ?? ''),
|
||||
})
|
||||
done.value = true
|
||||
window.scrollTo({ top: 0 })
|
||||
} catch (e: unknown) {
|
||||
toast.error(serverMessage(e) ?? 'خطا در ثبت اطلاعات. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The API's Persian `message` (duplicate number, bad mobile, throttle), if any. */
|
||||
function serverMessage(e: unknown): string | null {
|
||||
const match = e instanceof Error ? e.message.match(/\{.*\}/) : null
|
||||
if (!match) return null
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]) as { message?: string | string[] }
|
||||
return Array.isArray(parsed.message) ? parsed.message[0] ?? null : parsed.message ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -364,16 +386,19 @@ async function onFinish() {
|
||||
}
|
||||
}
|
||||
|
||||
// Same field language as the rest of the app (SearchInput, login): @canvas
|
||||
// fill, 1px @hairline edge, and on focus only the edge turns @wineSoft —
|
||||
// no tinted fill, no glow ring.
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid @hairline;
|
||||
background: @canvasSoft;
|
||||
border: 1px solid @hairline;
|
||||
background: @canvas;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: @ink;
|
||||
box-shadow: none;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: border-color 0.18s ease;
|
||||
}
|
||||
|
||||
:deep(.ant-input) {
|
||||
@@ -386,9 +411,10 @@ async function onFinish() {
|
||||
padding: 0 14px;
|
||||
|
||||
.ant-input {
|
||||
min-height: 46px;
|
||||
min-height: 48px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -403,7 +429,7 @@ async function onFinish() {
|
||||
:deep(.ant-input-prefix) {
|
||||
margin-inline-end: 10px;
|
||||
color: @inkFaint;
|
||||
transition: color 0.2s ease;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
:deep(.ant-input::placeholder) {
|
||||
@@ -418,29 +444,21 @@ async function onFinish() {
|
||||
:deep(.ant-input:focus),
|
||||
:deep(.ant-input-focused),
|
||||
:deep(.ant-input-affix-wrapper-focused) {
|
||||
border-color: @wine;
|
||||
border-color: @wineSoft;
|
||||
background: @canvas;
|
||||
box-shadow: 0 0 0 4px alpha(@wine, 10%);
|
||||
|
||||
.ant-input-prefix {
|
||||
color: @wine;
|
||||
}
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper-focused .ant-input-prefix) {
|
||||
color: @wine;
|
||||
color: @wineSoft;
|
||||
}
|
||||
|
||||
// Errors
|
||||
// Errors: red edge only — like focus, no ring.
|
||||
:deep(.ant-form-item-has-error .ant-input),
|
||||
:deep(.ant-form-item-has-error .ant-input-affix-wrapper) {
|
||||
border-color: @red !important;
|
||||
background: alpha(@red, 3%);
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-has-error .ant-input:focus),
|
||||
:deep(.ant-form-item-has-error .ant-input-affix-wrapper-focused) {
|
||||
box-shadow: 0 0 0 4px alpha(@red, 10%) !important;
|
||||
background: @canvas;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-has-error .ant-input-affix-wrapper .ant-input) {
|
||||
@@ -481,14 +499,15 @@ async function onFinish() {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: @wineWash;
|
||||
color: @wine;
|
||||
color: @inkSoft;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1.8;
|
||||
|
||||
:deep(svg) {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: @wine;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,9 +545,9 @@ async function onFinish() {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
box-shadow: 0 10px 22px -12px alpha(@ink, 70%);
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
box-shadow: 0 10px 22px -12px alpha(@wine, 70%);
|
||||
transition: transform 0.15s ease, opacity 0.2s ease;
|
||||
|
||||
&:active {
|
||||
|
||||
Reference in New Issue
Block a user