feat: product design surveys, cart expiry/buy-later, and catalog polish

Add like/dislike surveys with majority alerts for manufacturing decisions, clearer accounting mapping copy, and related cart/catalog admin-app improvements.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-11-12 17:55:52 +03:30
co-authored by Cursor
parent 032fa800d9
commit 7dd74ff4f7
74 changed files with 6351 additions and 893 deletions
+117
View File
@@ -135,6 +135,8 @@ export interface ProductSummary {
category: CategorySummary | null;
brand: BrandSummary | null;
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
/** Labels from the admin «برچسب‌ها» list. */
tags?: string[];
}
/** Query for the paged storefront list; every field is optional. */
@@ -145,6 +147,10 @@ export interface ProductQuery {
categoryId?: string;
brandId?: string;
collectionId?: string;
/** One label. */
tag?: string;
/** Only products carrying at least one label. */
tagged?: boolean;
minGram?: number;
maxGram?: number;
sort?: 'newest' | 'oldest' | 'title';
@@ -167,6 +173,8 @@ export interface CatalogFacets {
categories: CategorySummary[];
brands: BrandSummary[];
collections: CollectionSummary[];
/** Labels in use on the storefront. */
tags?: string[];
/** Decimal strings, like every other weight this API returns. */
gramRange: { min: string; max: string };
}
@@ -394,6 +402,29 @@ export interface PreOrderInput {
description?: string;
}
/** One product-design survey card (like/dislike before manufacturing). */
export interface ProductSurveyRow {
id: string;
title: string | null;
imageUrl: string;
suggestedWeightGrams: number;
status: string;
statusLabel: string;
likes: number;
dislikes: number;
myVote: 'LIKE' | 'DISLIKE' | null;
customerCount?: number | null;
majorityThreshold?: number | null;
reachedMajority?: boolean;
createdAt: string;
}
export interface ProductSurveyInput {
title?: string;
file_id: string;
suggestedWeightGrams: number;
}
/** One row of "سفارش مشتریان". */
export interface ExpertChildOrder {
id: string;
@@ -516,6 +547,13 @@ export const api = {
*/
getProduct: (id: string) => request<ProductSummary>(`/catalog/products/${encodeURIComponent(id)}`),
/** Counts a detail-screen opening for the admin «آمار» screen. Never throws. */
recordProductView: (id: string) => {
void fetch(`${BASE}/catalog/products/${encodeURIComponent(id)}/view`, { method: 'POST', keepalive: true }).catch(
() => undefined,
);
},
/**
* Specific products by id — the cart, favourites, order items.
*
@@ -661,6 +699,26 @@ export const api = {
createPreOrder: (body: PreOrderInput) =>
request<PreOrderRow>('/customer/me/pre-orders', { method: 'POST', body: JSON.stringify(body) }),
getMyPreOrders: () => request<{ rows: PreOrderRow[] }>('/customer/me/pre-orders'),
listOpenProductSurveys: () =>
request<{
rows: ProductSurveyRow[];
majorityHits?: ProductSurveyRow[];
customerCount?: number;
majorityThreshold?: number;
}>('/customer/product-surveys'),
listMyProductSurveys: () =>
request<{ rows: ProductSurveyRow[] }>('/customer/me/product-surveys'),
createProductSurvey: (body: ProductSurveyInput) =>
request<ProductSurveyRow>('/customer/me/product-surveys', {
method: 'POST',
body: JSON.stringify(body),
}),
voteProductSurvey: (id: string, value: 'LIKE' | 'DISLIKE') =>
request<{ surveyId: string; myVote: 'LIKE' | 'DISLIKE'; likes: number; dislikes: number }>(
`/customer/product-surveys/${encodeURIComponent(id)}/vote`,
{ method: 'PUT', body: JSON.stringify({ value }) },
),
adjustExpertChildOrder: (orderId: string, items: { id: string; rash_weight: number }[], finalSubmit: boolean) =>
request<{ id: string; status: string }>(`/expert/child-orders/${encodeURIComponent(orderId)}`, {
method: 'PUT',
@@ -739,4 +797,63 @@ export const api = {
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
`/customer/me/report/${encodeURIComponent(id)}`,
),
// ── cart sync / daily expiry ─────────────────────────────────────────
getCartPolicy: () =>
request<{
enabled: boolean;
clearHour: number;
clearMinute: number;
sameDayDeferFromHour: number;
sameDayDeferFromMinute: number;
reminderEnabled: boolean;
reminderHoursBefore: number;
todayClearAt: string;
nextClearAt: string;
nextReminderAt: string;
}>('/cart/policy'),
getCart: () =>
request<{
items: Array<{
pid: string;
vid: string;
qty: number;
note?: string | null;
}>;
updatedAt: string | null;
clearedAt: string | null;
}>('/cart'),
putCart: (items: Array<{ pid: string; vid: string; qty: number; note?: string | null }>) =>
request<{
items: unknown[];
updatedAt: string;
clearedAt: string | null;
}>('/cart', {
method: 'PUT',
body: JSON.stringify({ items }),
}),
getBuyLater: () =>
request<{
items: Array<{
pid: string;
vid: string;
qty: number;
note?: string | null;
}>;
updatedAt: string | null;
}>('/cart/buy-later'),
putBuyLater: (
items: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
) =>
request<{
items: unknown[];
updatedAt: string;
}>('/cart/buy-later', {
method: 'PUT',
body: JSON.stringify({ items }),
}),
};
@@ -29,10 +29,12 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSessionStore } from '../../stores/session'
import { useCartStore } from '../../stores/cartStore'
import { useBuyLaterStore } from '../../stores/buyLaterStore'
import { toast } from '../../composables/useToast'
const session = useSessionStore()
const cart = useCartStore()
const buyLater = useBuyLaterStore()
const router = useRouter()
const leaving = ref(false)
@@ -45,6 +47,7 @@ async function leave() {
leaving.value = true
try {
cart.clear()
buyLater.clear()
session.setActingAs(null)
await router.push({ name: 'home' })
toast.info('به حساب خودتان بازگشتید')
@@ -14,6 +14,7 @@
<article class="panel" data-product-origin @click="emit('open', value)">
<div class="panel__art">
<img v-lazy="image" :alt="title" />
<ProductTags :tags="value.tags" class="panel__tags" />
</div>
<h3 class="panel__title">{{ title }}</h3>
@@ -30,6 +31,7 @@
</template>
<script setup lang="ts">
import ProductTags from '../product/ProductTags.vue'
import { useProduct } from '../../composables/useProduct'
import type { ProductSummary } from '../../api/client'
@@ -59,12 +61,20 @@ function format(v: number) {
}
.panel__art {
position: relative;
height: 148px;
display: flex;
align-items: center;
justify-content: center;
}
.panel__tags {
position: absolute;
top: 0;
right: 0;
left: 0;
}
.panel__art img {
max-width: 78%;
max-height: 100%;
@@ -59,7 +59,7 @@
type="button"
class="ci__qtyBtn ci__qtyBtn--danger"
aria-label="حذف"
@click="cart.remove(value)"
@click="removeLine"
>
<Icon icon="vuesax-linear:trash" size="16" />
</button>
@@ -68,12 +68,33 @@
</button>
</div>
<button type="button" class="ci__note" @click="emit('note', index)">
<button type="button" class="ci__note" @click="emit('note', listIndex)">
<Icon icon="vuesax-linear:note-text" size="15" />
{{ !value.note || value.note === '' ? 'توضیحات' : 'ویرایش توضیحات' }}
</button>
</div>
<div class="ci__actions">
<button
v-if="mode === 'cart'"
type="button"
class="ci__move ci__move--quiet"
@click="emit('defer')"
>
<Icon icon="vuesax-linear:clock-1" size="14" />
خرید بعدی
</button>
<button
v-else
type="button"
class="ci__move ci__move--primary"
@click="emit('toCart')"
>
<Icon icon="vuesax-linear:bag-2" size="15" />
افزودن به سبد
</button>
</div>
<p v-if="value.note" class="ci__noteText">{{ value.note }}</p>
</div>
</article>
@@ -84,28 +105,54 @@ import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { useBuyLaterStore } from '../../stores/buyLaterStore'
import { price } from '../../utils/filters'
const props = defineProps<{ value: CartItem }>()
const emit = defineEmits<{ (e: 'note', value: number): void }>()
const props = withDefaults(
defineProps<{ value: CartItem; mode?: 'cart' | 'buyLater' }>(),
{ mode: 'cart' },
)
const emit = defineEmits<{
(e: 'note', value: number): void
(e: 'defer'): void
(e: 'toCart'): void
}>()
const cart = useCartStore()
const index = cart.find_index(props.value)
const buyLater = useBuyLaterStore()
const listIndex = computed(() =>
props.mode === 'buyLater' ? buyLater.find_index(props.value) : cart.find_index(props.value),
)
// Stock ceiling, same rule as the product page: uncapped for a pre-order
// variant, otherwise limited to what is on hand. The store clamps as well.
const maxQty = computed(() => cart.maxQty(props.value))
// Active cart: stock ceiling. Buy-later: uncapped (desired qty for later).
const maxQty = computed(() => (props.mode === 'cart' ? cart.maxQty(props.value) : null))
const canIncrement = computed(() => maxQty.value === null || props.value.qty < maxQty.value)
const increment = () => {
if (!canIncrement.value) return
cart.updateFeild(props.value.qty + 1, index, props.value, 'qty')
if (props.mode === 'buyLater') {
buyLater.updateQty(props.value, props.value.qty + 1)
return
}
cart.updateFeild(props.value.qty + 1, listIndex.value, props.value, 'qty')
}
const decrement = () => {
cart.updateFeild(props.value.qty - 1, index, props.value, 'qty')
if (props.mode === 'buyLater') {
buyLater.updateQty(props.value, props.value.qty - 1)
return
}
cart.updateFeild(props.value.qty - 1, listIndex.value, props.value, 'qty')
}
const removeLine = () => {
if (props.mode === 'buyLater') buyLater.remove(props.value)
else cart.remove(props.value)
}
const { image, title, selectedVariant, wage } = useProduct(props.value.product, cart.variant(props.value))
const variant =
props.mode === 'buyLater'
? props.value.product.variants.find((v) => v.id === props.value.vid)
: cart.variant(props.value)
const { image, title, selectedVariant, wage } = useProduct(props.value.product, variant)
/** The variant's fixed rial making charge, per unit. */
const rialWage = computed(() => Number(selectedVariant.value?.rial_pay ?? 0) || 0)
@@ -269,6 +316,47 @@ html.theme-dark .ci__art img {
white-space: nowrap;
}
.ci__actions {
display: flex;
justify-content: stretch;
padding-top: 10px;
}
.ci__move {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 36px;
padding: 0 12px;
border-radius: 11px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 700;
transition:
transform 0.15s ease,
background 0.15s ease,
color 0.15s ease;
}
.ci__move:active {
transform: scale(0.98);
}
.ci__move--quiet {
border: 1px solid @hairline;
color: @inkSoft;
background: @canvas;
}
.ci__move--primary {
border: 0;
color: #fff;
background: @wine;
}
.ci__noteText {
margin: 8px 0 0;
padding: 8px 10px;
@@ -54,6 +54,8 @@
<span v-if="!is_exist" class="pc__flag">ناموجود</span>
<span v-else-if="isPreOrder && qty === 0" class="pc__flag pc__flag--soft">پیش‌سفارش</span>
<ProductTags :tags="value.tags" class="pc__tags" />
</div>
<h3 class="pc__title">{{ title }}</h3>
@@ -70,6 +72,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
import ProductTags from './ProductTags.vue'
import { useProduct } from '../../composables/useProduct'
import { openProduct } from '../../composables/useProductMorph'
import { useFavoritesStore } from '../../stores/favoritesStore'
@@ -190,6 +193,13 @@ html.theme-dark .pc__art img {
background: @wineSoft;
}
.pc__tags {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
.pc__title {
margin: 12px 0 0;
font-size: 12px;
@@ -51,7 +51,12 @@
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import SingleProduct from './SingleProduct.vue'
import { productMorph, type ProductMorphRequest } from '../../composables/useProductMorph'
import {
discardMorphHistory,
productMorph,
releaseMorphHistory,
type ProductMorphRequest,
} from '../../composables/useProductMorph'
const route = useRoute()
@@ -442,7 +447,7 @@ async function closeAnimations(): Promise<Animation[]> {
return anims
}
function teardown() {
function teardown(opts: { history?: 'release' | 'discard' | 'none' } = { history: 'release' }) {
stopRunning()
unhideAll()
ghostSrc.value = null
@@ -452,6 +457,11 @@ function teardown() {
current.value = null
lockScroll(false)
if (productMorph.value) productMorph.value = null
// Phone/browser back already popped history; the in-overlay back button still
// needs to drop the extra same-URL entry we pushed on open. Route changes
// discard the marker without an extra history.back().
if (opts.history === 'release') releaseMorphHistory()
else if (opts.history === 'discard') discardMorphHistory()
}
// ── Wiring ────────────────────────────────────────────────────
@@ -467,7 +477,7 @@ watch(
() => {
if (!current.value) return
++gen
teardown()
teardown({ history: 'discard' })
},
)
@@ -0,0 +1,50 @@
<!--
The admin-picked labels (برچسب‌ها) of a product, as small pills overlaid on a
card's artwork. The parent positions the root; at most two are shown so the
pills never cover the piece, with a "+N" for the rest.
-->
<template>
<div v-if="shown.length" class="tags">
<span v-for="t in shown" :key="t" class="tags__pill">{{ t }}</span>
<span v-if="extra > 0" class="tags__pill tags__pill--more">+{{ extra.toLocaleString('fa-IR') }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const MAX_SHOWN = 2
const props = defineProps<{ tags?: string[] | null }>()
const shown = computed(() => (props.tags ?? []).slice(0, MAX_SHOWN))
const extra = computed(() => Math.max(0, (props.tags?.length ?? 0) - MAX_SHOWN))
</script>
<style scoped lang="less">
.tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
pointer-events: none;
}
.tags__pill {
max-width: 96px;
padding: 3px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
line-height: 1.5;
color: #fff;
background: @wine;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tags__pill--more {
color: @wine;
background: @wineWash;
}
</style>
@@ -334,7 +334,7 @@ import { useFavoritesStore } from '../../stores/favoritesStore'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
import { api, type ProductSummary } from '../../api/client'
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
@@ -530,6 +530,7 @@ const goCategory = () => {
}
onBeforeMount(async () => {
api.recordProductView(props.id)
await productDetail.load()
})
@@ -12,6 +12,10 @@
// to thread an element through. A caller that does hold the element can pass
// it; with neither (keyboard activation), the overlay falls back to a plain
// rise-and-fade instead of a morph.
//
// History: opening pushes a same-URL history entry so the phone/browser back
// button closes the overlay first (like a modal), instead of leaving the list
// page underneath. Closing via the in-overlay back control pops that entry.
import { shallowRef } from 'vue'
import type { ProductSummary } from '../api/client'
@@ -22,6 +26,12 @@ export interface ProductMorphRequest {
export const productMorph = shallowRef<ProductMorphRequest | null>(null)
/** Marker on `history.state` while the product overlay owns the top entry. */
export const PRODUCT_MORPH_HISTORY_KEY = '__productMorph'
/** True when the current close was triggered by popstate (phone/browser back). */
let closingFromPopstate = false
let lastPointerTarget: Element | null = null
if (typeof document !== 'undefined') {
document.addEventListener(
@@ -31,11 +41,74 @@ if (typeof document !== 'undefined') {
},
{ capture: true, passive: true },
)
window.addEventListener('popstate', () => {
if (!productMorph.value) return
closingFromPopstate = true
productMorph.value = null
})
}
type RouterHistoryState = Record<string, unknown> & {
position?: number
current?: string
back?: string | null
forward?: string | null
}
function pushMorphHistory() {
if (typeof window === 'undefined') return
const st = (window.history.state ?? {}) as RouterHistoryState
if (st[PRODUCT_MORPH_HISTORY_KEY]) return
const current = typeof st.current === 'string' ? st.current : window.location.pathname
const position = typeof st.position === 'number' ? st.position + 1 : 1
window.history.pushState(
{
...st,
[PRODUCT_MORPH_HISTORY_KEY]: true,
position,
back: current,
current,
forward: null,
replaced: false,
},
'',
window.location.href,
)
}
/**
* After the overlay finishes closing via the UI (not phone back), drop the
* extra history entry so the next back leaves the underlying page.
*/
export function releaseMorphHistory() {
if (typeof window === 'undefined') return
if (closingFromPopstate) {
closingFromPopstate = false
return
}
const st = (window.history.state ?? {}) as RouterHistoryState
if (!st[PRODUCT_MORPH_HISTORY_KEY]) return
window.history.back()
}
/** Strip the morph marker without navigating — used when the route itself changes. */
export function discardMorphHistory() {
if (typeof window === 'undefined') return
closingFromPopstate = false
const st = (window.history.state ?? {}) as RouterHistoryState
if (!st[PRODUCT_MORPH_HISTORY_KEY]) return
const next = { ...st }
delete next[PRODUCT_MORPH_HISTORY_KEY]
window.history.replaceState(next, '', window.location.href)
}
export function openProduct(product: ProductSummary, origin?: HTMLElement | null) {
const fromTap = lastPointerTarget?.closest<HTMLElement>('[data-product-origin]') ?? null
const alreadyOpen = Boolean(productMorph.value)
productMorph.value = { product, origin: origin ?? fromTap }
// Switching product while open keeps the same history entry.
if (!alreadyOpen) pushMorphHistory()
}
export function closeProduct() {
+2
View File
@@ -44,6 +44,8 @@ export const router = createRouter({
// Reference paths and names kept (henza_pwa router/index.ts:113-128).
{ path: '/makeOrder', name: 'makeOrderList', component: () => import('../views/user/MakeOrderView.vue'), meta: { requiresAuth: true } },
{ path: '/makeOrder_status', name: 'makeOrderStatus', component: () => import('../views/user/MakeOrderStatusView.vue'), meta: { requiresAuth: true } },
{ path: '/surveys', name: 'surveyFeed', component: () => import('../views/user/SurveyFeedView.vue'), meta: { requiresAuth: true } },
{ path: '/surveys/mine', name: 'surveyMine', component: () => import('../views/user/SurveyMineView.vue'), meta: { requiresAuth: true } },
// Theme + reduced motion. Device preferences, so no requiresAuth.
{ path: '/settings/appearance', name: 'appearanceSettings', component: () => import('../views/user/AppearanceView.vue') },
// D21 — the sales expert's two screens. Reference paths and names kept
@@ -0,0 +1,214 @@
/**
* «سبد خرید بعدی» — deferred purchase list.
*
* Scenarios covered:
* - Move from cart → here: removes from active cart; stock is untouched
* (cart never reserved stock; this list also never reserves).
* - Move here → cart: uses cart.addToCart stock guards; on failure the line
* stays here so an out-of-stock deferral is not lost.
* - Qty/note editable here without stock ceiling (desired qty for later).
* - Survives daily cart expiry and successful order (active cart only clears).
* - Guest: localStorage; logged-in: debounced PUT /cart/buy-later.
* - Acting-as / logout: list is cleared in memory like the active cart.
*/
import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client'
import { useSessionStore } from './session'
import { useCartStore, type CartItem } from './cartStore'
const STORAGE_KEY = 'appBuyLater'
const SYNC_DEBOUNCE_MS = 600
type Stored = { items: CartItem[] }
function loadStored(): CartItem[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed
return Array.isArray(parsed?.items) ? parsed.items : []
} catch {
return []
}
}
function persist(items: CartItem[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ items } satisfies Stored))
}
function toServerItems(items: CartItem[]) {
return items.map((i) => ({
pid: i.pid,
vid: i.vid,
qty: i.qty,
note: i.note ?? null,
}))
}
export const useBuyLaterStore = defineStore('buyLater', {
state: () => ({
items: loadStored() as CartItem[],
_syncTimer: null as ReturnType<typeof setTimeout> | null,
_hydrating: false,
}),
actions: {
async init() {
await this.syncFromServer()
if (this.items.length === 0) return
try {
const ids = [...new Set(this.items.map((i) => i.pid))]
const products = await api.getProductsByIds(ids)
const byId = new Map(products.map((p) => [p.id, p]))
const refreshed: CartItem[] = []
for (const item of this.items) {
const product = byId.get(item.pid)
const variant = product?.variants.find((v) => v.id === item.vid)
// Keep OOS / missing-from-storefront lines with a stale product
// snapshot so the person can still see what they deferred.
if (product && variant) refreshed.push({ ...item, product })
else refreshed.push(item)
}
this.items = refreshed
this.writeLocal()
} catch (e) {
console.log(e)
}
},
async syncFromServer() {
const session = useSessionStore()
if (!session.isLoggedIn) return
try {
this._hydrating = true
const remote = await api.getBuyLater()
const remoteItems = Array.isArray(remote.items) ? remote.items : []
if (remoteItems.length > 0) {
await this.hydrateFromServer(remoteItems)
} else if (this.items.length > 0) {
await api.putBuyLater(toServerItems(this.items))
}
} catch (e) {
console.log(e)
} finally {
this._hydrating = false
}
},
async hydrateFromServer(
lines: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
) {
const ids = [...new Set(lines.map((l) => l.pid))]
let byId = new Map<string, ProductSummary>()
try {
const products = await api.getProductsByIds(ids)
byId = new Map(products.map((p) => [p.id, p]))
} catch {
/* keep whatever local product blobs we can */
}
const next: CartItem[] = []
for (const line of lines) {
const product = byId.get(line.pid)
const local = this.items.find((i) => i.pid === line.pid && i.vid === line.vid)
const blob = product ?? local?.product
if (!blob) continue
next.push({
pid: line.pid,
vid: line.vid,
qty: Math.max(1, Math.floor(line.qty) || 1),
note: line.note ?? null,
product: blob,
})
}
this.items = next
this.writeLocal()
},
writeLocal() {
persist(this.items)
if (!this._hydrating) this.scheduleServerSync()
},
scheduleServerSync() {
const session = useSessionStore()
if (!session.isLoggedIn) return
if (this._syncTimer) clearTimeout(this._syncTimer)
this._syncTimer = setTimeout(() => {
this._syncTimer = null
void api.putBuyLater(toServerItems(this.items)).catch((e) => console.log(e))
}, SYNC_DEBOUNCE_MS)
},
find_index(item: Pick<CartItem, 'pid' | 'vid'>) {
return this.items.findIndex((i) => i.pid === item.pid && i.vid === item.vid)
},
/** Upsert a line without any stock check. */
upsert(item: CartItem) {
const qty = Math.max(1, Math.floor(item.qty) || 1)
const i = this.find_index(item)
if (i === -1) {
this.items.push({ ...item, qty })
} else {
this.items[i] = {
...this.items[i],
product: item.product,
qty,
note: item.note ?? this.items[i].note,
}
}
this.writeLocal()
},
remove(item: Pick<CartItem, 'pid' | 'vid'>) {
const i = this.find_index(item)
if (i === -1) return
this.items.splice(i, 1)
this.writeLocal()
},
updateQty(item: Pick<CartItem, 'pid' | 'vid'>, qty: number) {
const i = this.find_index(item)
if (i === -1) return
this.items[i].qty = Math.max(1, Math.floor(qty) || 1)
this.writeLocal()
},
updateNote(item: Pick<CartItem, 'pid' | 'vid'>, note: string | null | undefined) {
const i = this.find_index(item)
if (i === -1) return
this.items[i].note = note ?? null
this.writeLocal()
},
/**
* Cart → buy later. Stock unchanged. Removes from active cart.
*/
moveFromCart(item: CartItem) {
const cart = useCartStore()
const live = cart.items[cart.find_index(item)]
const snapshot = live ? { ...live } : { ...item }
this.upsert(snapshot)
cart.remove(snapshot)
},
/**
* Buy later → cart. Stock guards apply only now.
* @returns false when cart refused the add (e.g. out of stock).
*/
moveToCart(item: CartItem): boolean {
const cart = useCartStore()
const live = this.items[this.find_index(item)]
const snapshot = live ? { ...live } : { ...item }
const ok = cart.addToCart(snapshot)
if (ok) this.remove(snapshot)
return ok
},
clear() {
this.items = []
persist(this.items)
if (!this._hydrating) this.scheduleServerSync()
},
},
})
+161 -26
View File
@@ -1,8 +1,13 @@
// Ported from legacy henza_pwa/src/store/cartStore.ts, adapted to our string
// ids. Persisted the same way (a single localStorage key), just via Pinia's own
// state instead of @vueuse's useStorage wrapping a hand-rolled class.
//
// When logged in, lines are also mirrored to PUT /cart so the API can SMS a
// reminder and clear the basket at the configured Tehran time.
import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client'
import { clearDeadlineForTouchedAt } from '../utils/cartExpiry'
import { useSessionStore } from './session'
export interface CartItem {
pid: string
@@ -12,41 +17,77 @@ export interface CartItem {
note?: string | null
}
const STORAGE_KEY = 'appCart'
type StoredCart = {
items: CartItem[]
/** Last local mutation — used for guest daily-expiry. */
touchedAt: string | null
}
function loadFromStorage(): CartItem[] {
const STORAGE_KEY = 'appCart'
const SYNC_DEBOUNCE_MS = 600
function loadStored(): StoredCart {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
if (!raw) return { items: [], touchedAt: null }
const parsed = JSON.parse(raw)
// Legacy shape was a bare array.
if (Array.isArray(parsed)) {
return { items: parsed, touchedAt: null }
}
return {
items: Array.isArray(parsed?.items) ? parsed.items : [],
touchedAt: typeof parsed?.touchedAt === 'string' ? parsed.touchedAt : null,
}
} catch {
return []
return { items: [], touchedAt: null }
}
}
function persist(items: CartItem[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
function persist(items: CartItem[], touchedAt: string | null) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, touchedAt }))
}
function toServerItems(items: CartItem[]) {
return items.map((i) => ({
pid: i.pid,
vid: i.vid,
qty: i.qty,
note: i.note ?? null,
}))
}
export const useCartStore = defineStore('cart', {
state: () => ({
items: loadFromStorage() as CartItem[],
}),
state: () => {
const stored = loadStored()
return {
items: stored.items as CartItem[],
touchedAt: stored.touchedAt as string | null,
policy: null as null | {
enabled: boolean
clearHour: number
clearMinute: number
sameDayDeferFromHour: number
sameDayDeferFromMinute: number
todayClearAt: string
nextClearAt: string
},
_syncTimer: null as ReturnType<typeof setTimeout> | null,
_hydrating: false,
}
},
actions: {
/**
* Re-syncs cart entries against fresh product data (e.g. price/weight
* changes since the item was added), dropping entries whose
* product/variant no longer exists — same intent as legacy's init().
*
* Asks for exactly the products in the cart. It used to load the entire
* catalogue to look up two or three of them, which with ~16k products meant
* downloading all of them every time the app opened.
*
* A failed request leaves the cart alone: it is the person's own data, held
* in localStorage, and dropping items because the network blinked would be
* worse than showing a slightly stale price.
* Also loads expiry policy + server cart (when logged in) so a clear that
* happened while the app was closed is reflected locally.
*/
async init(force = false) {
void force // kept for callers; every call now goes to the server anyway
void force
await this.applyExpiryAndSync()
if (this.items.length === 0) return
try {
const ids = [...new Set(this.items.map((i) => i.pid))]
@@ -56,19 +97,107 @@ export const useCartStore = defineStore('cart', {
for (const item of this.items) {
const product = byId.get(item.pid)
const variant = product?.variants.find((v) => v.id === item.vid)
// A product the server no longer lists (deactivated, or its photo was
// removed) comes back missing, which is the same signal the catalogue
// lookup gave before: drop the line.
if (product && variant) {
refreshed.push({ ...item, product })
}
}
this.items = refreshed
persist(this.items)
this.writeLocal(false)
} catch (e) {
console.log(e)
}
},
async applyExpiryAndSync() {
try {
const policy = await api.getCartPolicy()
this.policy = {
enabled: policy.enabled,
clearHour: policy.clearHour,
clearMinute: policy.clearMinute,
sameDayDeferFromHour: policy.sameDayDeferFromHour ?? 8,
sameDayDeferFromMinute: policy.sameDayDeferFromMinute ?? 0,
todayClearAt: policy.todayClearAt,
nextClearAt: policy.nextClearAt,
}
if (policy.enabled && this.touchedAt) {
const touched = new Date(this.touchedAt)
if (!Number.isNaN(touched.getTime())) {
const deadline = clearDeadlineForTouchedAt(touched, {
clearHour: this.policy.clearHour,
clearMinute: this.policy.clearMinute,
sameDayDeferFromHour: this.policy.sameDayDeferFromHour,
sameDayDeferFromMinute: this.policy.sameDayDeferFromMinute,
})
if (Date.now() >= deadline.getTime()) this.clear(false)
}
}
} catch (e) {
console.log(e)
}
const session = useSessionStore()
if (!session.isLoggedIn) return
try {
this._hydrating = true
const remote = await api.getCart()
const remoteItems = Array.isArray(remote.items) ? remote.items : []
if (remoteItems.length > 0) {
await this.hydrateFromServer(remoteItems)
} else if (remote.clearedAt) {
this.clear(false)
} else if (this.items.length > 0) {
// First sync: push local guest/offline lines up.
await api.putCart(toServerItems(this.items))
}
} catch (e) {
console.log(e)
} finally {
this._hydrating = false
}
},
async hydrateFromServer(
lines: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
) {
const ids = [...new Set(lines.map((l) => l.pid))]
const products = await api.getProductsByIds(ids)
const byId = new Map(products.map((p) => [p.id, p]))
const next: CartItem[] = []
for (const line of lines) {
const product = byId.get(line.pid)
const variant = product?.variants.find((v) => v.id === line.vid)
if (!product || !variant) continue
next.push({
pid: line.pid,
vid: line.vid,
qty: line.qty,
note: line.note ?? null,
product,
})
}
this.items = next
this.touchedAt = new Date().toISOString()
this.writeLocal(false)
},
writeLocal(touch: boolean) {
if (touch) this.touchedAt = new Date().toISOString()
persist(this.items, this.touchedAt)
if (!this._hydrating) this.scheduleServerSync()
},
scheduleServerSync() {
const session = useSessionStore()
if (!session.isLoggedIn) return
if (this._syncTimer) clearTimeout(this._syncTimer)
this._syncTimer = setTimeout(() => {
this._syncTimer = null
void api.putCart(toServerItems(this.items)).catch((e) => console.log(e))
}, SYNC_DEBOUNCE_MS)
},
addToCart(data: CartItem) {
// Refuse a variant that cannot be ordered — out of stock and not flagged
// pre-order. The same guard the admin cart applies (stores/cart.ts), kept
@@ -83,7 +212,7 @@ export const useCartStore = defineStore('cart', {
this.items[index].product = data.product
this.items[index].qty = this.capQty(data.qty, target)
}
persist(this.items)
this.writeLocal(true)
return true
},
@@ -110,7 +239,7 @@ export const useCartStore = defineStore('cart', {
remove(item: CartItem) {
const index = this.find_index(item)
if (index > -1) this.items.splice(index, 1)
persist(this.items)
this.writeLocal(true)
},
// Legacy's exact name (typo'd "Feild"), kept for consistency with the rest
// of this faithfully-ported store's method names.
@@ -125,7 +254,7 @@ export const useCartStore = defineStore('cart', {
value = v ? this.capQty(Number(value), v) : Math.max(1, Number(value) || 1)
}
;(this.items[i] as any)[field] = value
persist(this.items)
this.writeLocal(true)
return true
},
find_index(item: Pick<CartItem, 'pid' | 'vid'>) {
@@ -157,9 +286,15 @@ export const useCartStore = defineStore('cart', {
return sum + (v?.rial_pay ? Number(v.rial_pay) * item.qty : 0)
}, 0)
},
clear() {
/**
* @param syncServer when false, only wipe local (used while applying a
* server-side clear so we do not bounce an empty PUT that races).
*/
clear(syncServer = true) {
this.items = []
persist(this.items)
this.touchedAt = new Date().toISOString()
persist(this.items, this.touchedAt)
if (syncServer && !this._hydrating) this.scheduleServerSync()
},
},
})
+92
View File
@@ -0,0 +1,92 @@
/**
* Mirrors apps/api cart-schedule clearDeadlineForTouchedAt for guest/local expiry.
* Keep in step with the server rule (same-day defer → tomorrow's clear).
*/
type Parts = { year: number; month: number; day: number; hour: number; minute: number; second: number }
function tehranParts(date: Date): Parts {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
const bag: Record<string, string> = {}
for (const p of fmt.formatToParts(date)) {
if (p.type !== 'literal') bag[p.type] = p.value
}
return {
year: Number(bag.year),
month: Number(bag.month),
day: Number(bag.day),
hour: Number(bag.hour),
minute: Number(bag.minute),
second: Number(bag.second),
}
}
function tehranWallToUtc(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second = 0,
): Date {
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
const shown = tehranParts(utcGuess)
const wantedMs = Date.UTC(year, month - 1, day, hour, minute, second)
const shownMs = Date.UTC(
shown.year,
shown.month - 1,
shown.day,
shown.hour,
shown.minute,
shown.second,
)
return new Date(utcGuess.getTime() + (wantedMs - shownMs))
}
function addTehranDays(year: number, month: number, day: number, delta: number) {
const noon = tehranWallToUtc(year, month, day, 12, 0, 0)
const shifted = new Date(noon.getTime() + delta * 24 * 60 * 60 * 1000)
const p = tehranParts(shifted)
return { year: p.year, month: p.month, day: p.day }
}
export type CartExpiryPolicyFields = {
clearHour: number
clearMinute: number
sameDayDeferFromHour: number
sameDayDeferFromMinute: number
}
/** Deadline when a cart last touched at `touchedAt` should be emptied. */
export function clearDeadlineForTouchedAt(
touchedAt: Date,
cfg: CartExpiryPolicyFields,
): Date {
const p = tehranParts(touchedAt)
const dayClear = tehranWallToUtc(p.year, p.month, p.day, cfg.clearHour, cfg.clearMinute, 0)
const deferFrom = tehranWallToUtc(
p.year,
p.month,
p.day,
cfg.sameDayDeferFromHour,
cfg.sameDayDeferFromMinute,
0,
)
const tomorrowClear = () => {
const tmr = addTehranDays(p.year, p.month, p.day, 1)
return tehranWallToUtc(tmr.year, tmr.month, tmr.day, cfg.clearHour, cfg.clearMinute, 0)
}
if (touchedAt.getTime() >= dayClear.getTime()) return tomorrowClear()
if (touchedAt.getTime() >= deferFrom.getTime()) return tomorrowClear()
return dayClear
}
+236 -31
View File
@@ -27,37 +27,122 @@
-->
<template>
<div class="cart">
<PageBar title="سبد خرید" back-to="home" />
<PageBar title="سبد خرید" back-to="home">
<template v-if="cartData.step === 1" #below>
<div class="cart__tabs" role="tablist" aria-label="نوع سبد">
<button
type="button"
role="tab"
class="cart__tab"
:class="{ 'is-on': activeTab === 'cart' }"
:aria-selected="activeTab === 'cart'"
@click="activeTab = 'cart'"
>
<span class="cart__tabLabel">سبد خرید</span>
<span v-if="cart.items.length" class="cart__tabCount">{{ cart.items.length }}</span>
</button>
<button
type="button"
role="tab"
class="cart__tab"
:class="{ 'is-on': activeTab === 'later' }"
:aria-selected="activeTab === 'later'"
@click="activeTab = 'later'"
>
<span class="cart__tabLabel">خرید بعدی</span>
<span v-if="buyLater.items.length" class="cart__tabCount">{{ buyLater.items.length }}</span>
</button>
</div>
</template>
</PageBar>
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<container class="cart__body" :class="{ 'cart__body--tabs': cartData.step === 1 }">
<!-- Step 1: active cart OR buy-later, one at a time -->
<AppPullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template v-if="cart.items.length">
<div class="cart__items" :class="{ 'is-revealed': revealed }">
<CartItem
v-for="(item, i) in cart.items"
:key="item.pid + item.vid"
:value="item"
class="cart__row"
:style="revealDelay(i)"
@note="
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = $event;
formModel.note = item.note ?? undefined;
"
/>
</div>
<!-- Active cart tab -->
<template v-if="activeTab === 'cart'">
<template v-if="cart.items.length">
<div class="cart__items" :class="{ 'is-revealed': revealed }">
<CartItem
v-for="(item, i) in cart.items"
:key="'c-' + item.pid + item.vid"
:value="item"
mode="cart"
class="cart__row"
:style="revealDelay(i)"
@defer="moveToBuyLater(item)"
@note="
cartData.noteTarget = 'cart';
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = $event;
formModel.note = item.note ?? undefined;
"
/>
</div>
</template>
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:bag-2" size="26" />
<p>سبد خرید خالی است.</p>
<p v-if="buyLater.items.length" class="cart__emptyHint">
{{ buyLater.items.length }} مورد در «خرید بعدی» دارید.
</p>
<button
v-if="buyLater.items.length"
type="button"
class="cart__emptyCta cart__emptyCta--ghost"
@click="activeTab = 'later'"
>
رفتن به خرید بعدی
</button>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
</template>
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:bag-2" size="26" />
<p>سبد خرید شما خالی است.</p>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
<!-- Buy-later tab -->
<template v-else>
<p v-if="buyLater.items.length" class="cart__laterHint">
از موجودی کم نمی‌شود؛ هر وقت آماده بودید به سبد برگردانید.
</p>
<template v-if="buyLater.items.length">
<div class="cart__items is-revealed">
<CartItem
v-for="item in buyLater.items"
:key="'l-' + item.pid + item.vid"
:value="item"
mode="buyLater"
class="cart__row is-static"
@to-cart="moveBuyLaterToCart(item)"
@note="
cartData.noteTarget = 'buyLater';
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = buyLater.find_index(item);
formModel.note = item.note ?? undefined;
"
/>
</div>
</template>
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:clock-1" size="26" />
<p>خرید بعدی خالی است.</p>
<p class="cart__emptyHint">از سبد خرید، «خرید بعدی» را بزنید تا اینجا نگه داشته شود.</p>
<button
v-if="cart.items.length"
type="button"
class="cart__emptyCta cart__emptyCta--ghost"
@click="activeTab = 'cart'"
>
بازگشت به سبد
</button>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
</template>
</AppPullRefresh>
<!-- Step 2: submitted -->
@@ -76,8 +161,8 @@
</container>
</Spinner>
<!-- Sticky summary + submit -->
<div v-if="cart.items.length > 0 && cartData.step === 1" class="cart__bar">
<!-- Sticky summary + submit — only on the active cart tab -->
<div v-if="activeTab === 'cart' && cart.items.length > 0 && cartData.step === 1" class="cart__bar">
<div class="cart__barInner">
<div class="cart__totals">
<div class="cart__total">
@@ -145,6 +230,8 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
type CartTab = 'cart' | 'later'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
@@ -153,9 +240,11 @@ import Spinner from '../components/Spinner.vue'
import IslandSheet from '../components/ui/IslandSheet.vue'
import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
import { useBuyLaterStore } from '../stores/buyLaterStore'
import { useSessionStore } from '../stores/session'
import { api } from '../api/client'
import { price } from '../utils/filters'
import { toast } from '../composables/useToast'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS, useQueryCache } from '../stores/queryCache'
@@ -168,10 +257,12 @@ let hasRevealed = false
const router = useRouter()
const cart = useCartStore()
const buyLater = useBuyLaterStore()
const session = useSessionStore()
const loginNotify = ref(false)
const refreshing = ref(false)
const activeTab = ref<CartTab>('cart')
// First-paint entrance for the basket rows. Flipped once, after the cart has
// loaded; pull-to-refresh does not replay it.
@@ -193,17 +284,39 @@ const cartData = reactive({
notify: false,
orderID: '',
tempRowIndex: -1,
noteTarget: 'cart' as 'cart' | 'buyLater',
tempRow: { product: undefined } as Partial<CartItemType>,
})
const formModel = reactive<{ note: string | undefined }>({ note: undefined })
const setNote = () => {
cart.updateFeild(formModel.note, cartData.tempRowIndex, cartData.tempRow as CartItemType, 'note')
const row = cartData.tempRow as CartItemType
if (cartData.noteTarget === 'buyLater') {
buyLater.updateNote(row, formModel.note)
} else {
cart.updateFeild(formModel.note, cartData.tempRowIndex, row, 'note')
}
cartData.showPopUp = false
formModel.note = undefined
cartData.tempRow = { product: undefined }
}
function moveToBuyLater(item: CartItemType) {
buyLater.moveFromCart(item)
activeTab.value = 'later'
toast.success('به خرید بعدی منتقل شد')
}
function moveBuyLaterToCart(item: CartItemType) {
const ok = buyLater.moveToCart(item)
if (ok) {
activeTab.value = 'cart'
toast.success('به سبد خرید اضافه شد')
} else {
toast.error('موجودی کافی نیست؛ در خرید بعدی ماند')
}
}
// legacy routed to `home` because this port had no orders list yet; it exists
// now, so the success step links to the real one.
const goOrders = async () => {
@@ -251,14 +364,14 @@ const submitOrder = async () => {
async function onRefresh() {
try {
await cart.init(true)
await Promise.all([cart.init(true), buyLater.init()])
} finally {
refreshing.value = false
}
}
onMounted(async () => {
await cart.init()
await Promise.all([cart.init(), buyLater.init()])
// Wait for the rows the load just populated to be in the DOM, then let one
// frame paint them in their "before" state — otherwise the browser coalesces
// both states into one and there is nothing to animate.
@@ -282,6 +395,75 @@ onMounted(async () => {
padding-bottom: 150px;
}
.cart__body--tabs {
padding-top: 118px;
}
.cart__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin: 0 16px 10px;
padding: 4px;
border-radius: 14px;
background: @hairlineSoft;
border: 1px solid @hairline;
}
.cart__tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
padding: 0 10px;
border: 0;
border-radius: 11px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: @inkSoft;
background: transparent;
transition:
background 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
}
.cart__tab.is-on {
color: @ink;
background: @canvas;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.cart__tabLabel {
line-height: 1;
}
.cart__tabCount {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
display: inline-grid;
place-items: center;
font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: @inkSoft;
background: #e5e7eb; // gray-200
}
html.theme-dark .cart__tabCount {
background: @hairlineSoft;
}
.cart__tab.is-on .cart__tabCount {
color: #fff;
background: @wine;
}
// One continuous list rather than a stack of boxes: the rows are borderless
// and a hairline divider separates each from the next. No gap — the rows carry
// their own vertical padding, so the divider sits centred between them.
@@ -295,6 +477,18 @@ onMounted(async () => {
border-top: 1px solid @hairline;
}
.cart__laterHint {
margin: 4px 2px 2px;
font-size: 11.5px;
line-height: 1.65;
color: @inkFaint;
}
.cart__row.is-static {
opacity: 1;
transform: none;
}
// First-paint entrance, the same choreography HomeView uses: each row rises a
// little, scales up from just under full size and fades in, one beat after the
// one above it.
@@ -344,6 +538,11 @@ onMounted(async () => {
font-size: 12.5px;
}
.cart__emptyHint {
font-size: 11.5px !important;
color: @inkSoft;
}
.cart__emptyCta {
margin-top: 4px;
padding: 9px 18px;
@@ -357,6 +556,12 @@ onMounted(async () => {
background: @wine;
}
.cart__emptyCta--ghost {
color: @ink;
background: @canvas;
border: 1px solid @hairline;
}
.cart__done {
margin-top: 20px;
padding: 34px 22px;
+91 -1
View File
@@ -41,6 +41,36 @@
<CategoryGrid :categories="memory.cats" :active-id="null" @select="onCategory" />
</section>
<!-- Every product an admin has labelled (برچسب‌ها), filterable by label. -->
<section v-if="taggedProducts.length" class="home__section home__reveal" :style="revealDelay(2)">
<h2 class="home__heading">ویژه‌ها</h2>
<div v-if="tagChips.length > 1" class="home__chips no-scrollbar">
<button
type="button"
class="home__chip"
:class="{ 'is-on': activeTag === null }"
@click="activeTag = null"
>
همه
</button>
<button
v-for="t in tagChips"
:key="t"
type="button"
class="home__chip"
:class="{ 'is-on': activeTag === t }"
@click="activeTag = t"
>
{{ t }}
</button>
</div>
<div class="home__rail no-scrollbar">
<ProductCard v-for="item in visibleTagged" :key="item.id" :value="item" class="home__railCard" />
</div>
</section>
<section v-if="newestProducts.length" class="home__section home__reveal" :style="revealDelay(2)">
<h2 class="home__heading">محصولات جدید</h2>
@@ -86,6 +116,7 @@ import type { HeroSlide } from '../components/home/HeroCarousel.vue'
import CategoryGrid from '../components/home/CategoryGrid.vue'
import ProductPanel from '../components/home/ProductPanel.vue'
import ProductCarousel from '../components/home/ProductCarousel.vue'
import ProductCard from '../components/product/ProductCard.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore'
@@ -140,6 +171,15 @@ function revealDelay(i: number) {
const REEL_LIMIT = 6
const LIST_LIMIT = 4
// «ویژه‌ها»: every labelled product, up to the API's page ceiling.
const TAGGED_LIMIT = 100
const taggedProducts = ref<ProductSummary[]>([])
const activeTag = ref<string | null>(null)
const tagChips = computed(() => [...new Set(taggedProducts.value.flatMap((p) => p.tags ?? []))])
const visibleTagged = computed(() =>
activeTag.value ? taggedProducts.value.filter((p) => p.tags?.includes(activeTag.value!)) : taggedProducts.value,
)
const newestProducts = computed(() => products.value)
const reelProducts = computed(() => products.value.slice(0, REEL_LIMIT))
const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
@@ -172,7 +212,7 @@ function goNewest() {
// The three reads are independent, so they run side by side. Each one is
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
const [catalogResult, catsResult, priceResult, slidesResult, taggedResult] = await Promise.allSettled([
// STUB-03: rails need a small newest page. Pull-to-refresh must drop the
// cached catalogue first (same rule as before paging) or the screen keeps
// showing the previous slice while gold/slides refresh.
@@ -188,8 +228,15 @@ async function loadData(force = false) {
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
api.listProductsPaged({ page: 1, limit: TAGGED_LIMIT, tagged: true, sort: 'newest' }),
])
if (taggedResult.status === 'fulfilled') {
taggedProducts.value = taggedResult.value.rows
if (activeTag.value && !tagChips.value.includes(activeTag.value)) activeTag.value = null
} else {
console.log(taggedResult.reason)
}
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
if (catsResult.status === 'rejected') console.log(catsResult.reason)
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
@@ -350,6 +397,49 @@ onActivated(async () => {
margin-top: 14px;
}
.home__chips {
display: flex;
gap: 8px;
margin: -4px -16px 12px;
padding: 0 16px;
overflow-x: auto;
}
.home__chip {
flex: 0 0 auto;
padding: 6px 14px;
border: 1px solid @hairline;
border-radius: 999px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
color: @inkSoft;
background: @canvas;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.home__chip.is-on {
color: #fff;
border-color: @wine;
background: @wine;
}
// Bleeds past the container gutter like the hero, so cards run off the edge
// and the rail reads as scrollable.
.home__rail {
display: flex;
gap: 12px;
margin-inline: -16px;
padding: 0 16px 4px;
overflow-x: auto;
scroll-snap-type: x proximity;
}
.home__railCard {
flex: 0 0 156px;
scroll-snap-align: start;
}
.home__empty {
margin-top: 28px;
padding: 34px 20px;
@@ -190,11 +190,13 @@ import SearchInput from '../../components/search/SearchInput.vue'
import { api, type ExpertCustomer } from '../../api/client'
import { useSessionStore } from '../../stores/session'
import { useCartStore } from '../../stores/cartStore'
import { useBuyLaterStore } from '../../stores/buyLaterStore'
import { toast } from '../../composables/useToast'
const router = useRouter()
const session = useSessionStore()
const cart = useCartStore()
const buyLater = useBuyLaterStore()
const entering = ref(false)
const rows = ref<ExpertCustomer[]>([])
@@ -261,6 +263,7 @@ async function enterAccount(item: ExpertCustomer) {
entering.value = true
try {
cart.clear()
buyLater.clear()
session.setActingAs({ id: item.id, name: item.name ?? item.fullName ?? item.mobile ?? '' })
detailOpen.value = false
// Same destination as the reference, which returned to the dashboard.
+190 -18
View File
@@ -122,6 +122,44 @@
</button>
</section>
<!-- Admin-managed banner (ثابت‌ها → دکمه سفارشی): row height, banner look. -->
<button
v-if="showCustomButton"
type="button"
class="dash__banner dash__reveal"
:style="revealDelay(1)"
@click="openCustomButton"
>
<span class="dash__bannerIcon" aria-hidden="true">
<Icon icon="vuesax-linear:link-2" size="18" />
</span>
<span class="dash__bannerText">
<b>{{ customButton!.title.trim() }}</b>
</span>
<span class="dash__bannerChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button
v-if="session.isLoggedIn && surveyMajorityHits.length"
type="button"
class="dash__banner dash__banner--survey dash__reveal"
:style="revealDelay(1)"
@click="goToDetails(8)"
>
<span class="dash__bannerIcon" aria-hidden="true">
<Icon icon="vuesax-linear:like-1" size="18" />
</span>
<span class="dash__bannerText">
<b>{{ surveyMajorityHits.length }} طرح به حد اکثریت رسید</b>
<small>{{ surveyMajorityHits[0]?.title || 'بدون عنوان' }}{{ surveyMajorityHits.length > 1 ? ' و …' : '' }}</small>
</span>
<span class="dash__bannerChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!-- Settings-style rows: every destination the hero no longer carries
as its own quick-action row. -->
<section class="dash__rows dash__reveal" :style="revealDelay(1)">
@@ -149,23 +187,6 @@
</span>
</button>
<button
v-if="showCustomButton"
type="button"
class="dash__row"
@click="openCustomButton"
>
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:link-2" size="18" />
</span>
<span class="dash__rowText">
<b>{{ customButton!.title.trim() }}</b>
</span>
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(2)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:user" size="18" />
@@ -207,6 +228,18 @@
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(8)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:like-1" size="18" />
</span>
<span class="dash__rowText">
<b>نظرسنجی طرح</b>
</span>
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(6)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:chart-2" size="18" />
@@ -299,6 +332,13 @@
<button type="button" class="dash__choiceBtn is-quiet" @click="chooseMakeOrder('makeOrderStatus')">مشاهده سفارش</button>
</div>
</Drawer>
<Drawer v-model:visible="surveyChoice" placement="bottom" height="auto" :closable="false" :body-style="{ padding: '0' }">
<div class="dash__choice">
<button type="button" class="dash__choiceBtn" @click="chooseSurvey('surveyFeed')">رأی به طرح‌ها</button>
<button type="button" class="dash__choiceBtn is-quiet" @click="chooseSurvey('surveyMine')">ثبت / طرح‌های من</button>
</div>
</Drawer>
</div>
</template>
@@ -314,7 +354,7 @@ import RateStrip from '../../components/home/RateStrip.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomButton, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { api, type CustomButton, type CustomerDetail, type GoldPriceQuote, type ProductSurveyRow } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
@@ -325,6 +365,7 @@ const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const customButton = ref<CustomButton | null>(reads.peek<CustomButton>(KEYS.customButton) ?? null)
const surveyMajorityHits = ref<ProductSurveyRow[]>([])
const refreshing = ref(false)
const profileError = ref(false)
@@ -366,6 +407,7 @@ function goToDetails(id: number) {
if (id === 5) router.push({ name: 'customerChoice' })
if (id === 6) router.push({ name: 'report' })
if (id === 7) makeOrderChoice.value = true
if (id === 8) surveyChoice.value = true
}
// «سفارش ساخت»: the reference opened a two-button dialog — place a request, or
@@ -376,6 +418,20 @@ function chooseMakeOrder(name: 'makeOrderList' | 'makeOrderStatus') {
router.push({ name })
}
const surveyChoice = ref(false)
function chooseSurvey(name: 'surveyFeed' | 'surveyMine') {
surveyChoice.value = false
const path = name === 'surveyFeed' ? '/surveys' : '/surveys/mine'
// After the drawer closes, push on the next tick so ant-design's focus
// restore does not cancel the navigation.
nextTick(() => {
router.push(path).catch(() => {
// Route missing until a full reload picks up the new router entries.
window.location.assign(path)
})
})
}
async function logOut() {
data.notify = false
try {
@@ -425,6 +481,17 @@ async function loadDashboard() {
}
goldPrice.value = price.status === 'fulfilled' ? price.value : null
if (btn.status === 'fulfilled') customButton.value = btn.value
if (session.isLoggedIn) {
try {
const surveys = await api.listOpenProductSurveys()
surveyMajorityHits.value = surveys.majorityHits ?? surveys.rows.filter((r) => r.reachedMajority)
} catch {
surveyMajorityHits.value = []
}
} else {
surveyMajorityHits.value = []
}
}
async function onRefresh() {
@@ -828,6 +895,111 @@ onMounted(async () => {
color: @inkFaint;
}
// ── Custom banner ─────────────────────────────────────────────────────
// Same box as .dash__row (padding + 36px icon) so its height matches the rows.
.dash__banner {
position: relative;
isolation: isolate;
overflow: hidden;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid transparent;
border-radius: 16px;
cursor: pointer;
color: #fff;
background: linear-gradient(115deg, @wine 0%, @wineDeep 100%);
box-shadow: 0 10px 24px -16px alpha(@wine, 85%);
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
transition: transform 0.18s ease;
}
.dash__banner::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
// Pair of fine diagonals at different angles — reads as a woven line field,
// not the old single-hatch stripes.
background-image:
repeating-linear-gradient(
118deg,
transparent 0,
transparent 11px,
rgba(255, 255, 255, 0.14) 11px,
rgba(255, 255, 255, 0.14) 12px
),
repeating-linear-gradient(
62deg,
transparent 0,
transparent 17px,
rgba(255, 255, 255, 0.07) 17px,
rgba(255, 255, 255, 0.07) 18px
);
// Physical left → fade out toward the right.
-webkit-mask-image: linear-gradient(to right, #000 0%, #000 30%, transparent 90%);
mask-image: linear-gradient(to right, #000 0%, #000 30%, transparent 90%);
}
.dash__banner:active {
transform: scale(0.985);
}
.dash__bannerIcon {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 11px;
display: grid;
place-items: center;
color: #fff;
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.dash__bannerText {
flex: 1;
min-width: 0;
}
.dash__bannerText b {
display: block;
font-size: 13px;
font-weight: 700;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash__bannerText small {
display: block;
margin-top: 2px;
font-size: 11.5px;
color: rgba(255, 255, 255, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash__banner--survey {
background: linear-gradient(115deg, @green 0%, #1f7a4d 100%);
box-shadow: 0 10px 24px -16px alpha(@green, 70%);
}
.dash__bannerChevron {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 9px;
display: grid;
place-items: center;
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
// ── Rate row ───────────────────────────────────────────────────────────
.dash__rate {
margin-top: 12px;
@@ -27,6 +27,16 @@ onMounted(async () => {
try {
const result = await api.ssoCallback(params.get('code') as string, params.get('state') as string)
session.login(result.token, result.customer.id, result.customer.mobile)
try {
const { useCartStore } = await import('../../stores/cartStore')
const { useBuyLaterStore } = await import('../../stores/buyLaterStore')
await Promise.all([
useCartStore().applyExpiryAndSync(),
useBuyLaterStore().syncFromServer(),
])
} catch {
/* non-fatal */
}
try {
const who = await api.getExpertIdentity()
session.setSalesExpert(who.isSalesExpert)
@@ -189,6 +189,17 @@ const sendToken = async (otpCode: string) => {
window.location.assign(verified.iamBootstrapUrl)
return
}
// Mirror local cart to the server (and pick up a server-side clear).
try {
const { useCartStore } = await import('../../stores/cartStore')
const { useBuyLaterStore } = await import('../../stores/buyLaterStore')
await Promise.all([
useCartStore().applyExpiryAndSync(),
useBuyLaterStore().syncFromServer(),
])
} catch {
/* non-fatal */
}
// D21 — ask the server which kind of principal this token is, so the
// expert-only entries appear for a sales expert. Failure is non-fatal:
// the entries simply stay hidden, and the server refuses those routes
@@ -0,0 +1,226 @@
<template>
<div class="svf">
<PageBar title="نظرسنجی طرح" back-to="userPage::hasSubRoute">
<template #trailing>
<button type="button" class="svf__mine" @click="router.push({ name: 'surveyMine' })">طرح‌های من</button>
</template>
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="svf__body">
<article v-for="item in rows" :key="item.id" class="svf__card">
<img class="svf__photo" :src="item.imageUrl" alt="" />
<div class="svf__meta">
<b v-if="item.title">{{ item.title }}</b>
<span>وزن پیشنهادی: {{ item.suggestedWeightGrams }} گرم</span>
<span v-if="item.reachedMajority" class="svf__majority">به حد اکثریت رسیده</span>
<span v-else-if="item.majorityThreshold" class="svf__prog">
▲ {{ item.likes }} از {{ item.majorityThreshold }} لایک لازم
</span>
</div>
<div class="svf__votes">
<button
type="button"
class="svf__vote"
:class="{ 'is-on': item.myVote === 'LIKE' }"
:disabled="votingId === item.id"
@click="vote(item, 'LIKE')"
>
پسندیدم · {{ item.likes }}
</button>
<button
type="button"
class="svf__vote is-down"
:class="{ 'is-on': item.myVote === 'DISLIKE' }"
:disabled="votingId === item.id"
@click="vote(item, 'DISLIKE')"
>
نپسندیدم · {{ item.dislikes }}
</button>
</div>
</article>
<section v-if="!rows.length && !loading && !failed" class="svf__empty">
<Icon icon="vuesax-linear:like-1" size="26" />
<p>الان طرح فعالی برای رأی‌گیری نیست.</p>
<button type="button" class="svf__cta" @click="router.push({ name: 'surveyMine' })">ثبت طرح خودم</button>
</section>
<section v-if="failed" class="svf__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت اطلاعات با خطا روبه‌رو شد.</p>
<button type="button" class="svf__cta" @click="load">تلاش دوباره</button>
</section>
</container>
</AppPullRefresh>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import { toast } from '../../composables/useToast'
import { api, type ProductSurveyRow } from '../../api/client'
const router = useRouter()
const rows = ref<ProductSurveyRow[]>([])
const loading = ref(false)
const refreshing = ref(false)
const failed = ref(false)
const votingId = ref<string | null>(null)
async function load() {
loading.value = true
failed.value = false
try {
rows.value = (await api.listOpenProductSurveys()).rows
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function onRefresh() {
try {
rows.value = (await api.listOpenProductSurveys()).rows
failed.value = false
} catch {
failed.value = true
} finally {
refreshing.value = false
}
}
async function vote(item: ProductSurveyRow, value: 'LIKE' | 'DISLIKE') {
votingId.value = item.id
try {
const r = await api.voteProductSurvey(item.id, value)
item.myVote = r.myVote
item.likes = r.likes
item.dislikes = r.dislikes
} catch {
toast.error('ثبت رأی ممکن نشد')
} finally {
votingId.value = null
}
}
onMounted(load)
</script>
<style scoped lang="less">
.svf__mine {
border: none;
background: transparent;
color: @wine;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.svf__body {
padding-top: 84px;
padding-bottom: 110px;
display: flex;
flex-direction: column;
gap: 14px;
}
.svf__card {
border: 1px solid @hairline;
border-radius: 16px;
overflow: hidden;
}
.svf__photo {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
background: @hairline;
}
.svf__meta {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px 4px;
font-size: 13px;
color: @inkSoft;
}
.svf__meta b {
color: @ink;
font-size: 14px;
}
.svf__majority {
color: @green;
font-weight: 700;
}
.svf__prog {
font-size: 12px;
color: @inkMuted;
}
.svf__votes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 10px 12px 14px;
}
.svf__vote {
padding: 11px 8px;
border: 1px solid @hairline;
border-radius: 999px;
background: transparent;
color: @ink;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.svf__vote.is-on {
border-color: @wine;
background: @wineWash;
color: @wine;
}
.svf__vote.is-down.is-on {
border-color: @red;
color: @red;
background: transparent;
}
.svf__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 16px;
color: @inkMuted;
text-align: center;
}
.svf__cta {
margin-top: 6px;
padding: 10px 18px;
border: none;
border-radius: 999px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
</style>
@@ -0,0 +1,211 @@
<template>
<div class="svm">
<PageBar title="طرح‌های من" back-to="surveyFeed" />
<Spinner :spinning="submitting" tip="در حال ثبت">
<container class="svm__body">
<form class="svm__form" novalidate @submit.prevent="submit">
<p class="svm__lead">طرح جدید برای نظرسنجی</p>
<SingleImageUploader
upload-text="بارگذاری تصویر طرح"
purpose="product-survey"
@done="(id: string) => (form.file_id = id)"
/>
<label class="svm__field">
<span>وزن پیشنهادی (گرم)</span>
<input v-model.number="form.suggestedWeightGrams" type="number" inputmode="decimal" min="0.01" step="0.1" />
</label>
<label class="svm__field">
<span>عنوان (اختیاری)</span>
<input v-model.trim="form.title" type="text" maxlength="200" />
</label>
<p v-if="error" class="svm__error">{{ error }}</p>
<button type="submit" class="svm__submit" :disabled="submitting">ارسال برای بررسی</button>
</form>
<section class="svm__list">
<h2 class="svm__listTitle">ارسال‌های قبلی</h2>
<article v-for="item in rows" :key="item.id" class="svm__card">
<img class="svm__photo" :src="item.imageUrl" alt="" />
<div class="svm__meta">
<b>{{ item.title || 'بدون عنوان' }}</b>
<span>{{ item.suggestedWeightGrams }} گرم</span>
<span>{{ item.statusLabel }}</span>
<span v-if="item.status === 'OPEN' || item.status === 'CLOSED' || item.status === 'APPROVED' || item.status === 'REJECTED'">
▲ {{ item.likes }} · ▼ {{ item.dislikes }}
</span>
</div>
</article>
<p v-if="!rows.length && !loading" class="svm__empty">هنوز طرحی نفرستاده‌اید.</p>
<p v-if="failed" class="svm__empty">بارگذاری لیست ناموفق بود.</p>
</section>
</container>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { toast } from '../../composables/useToast'
import { api, type ProductSurveyRow } from '../../api/client'
const form = reactive({
file_id: null as string | null,
suggestedWeightGrams: null as number | null,
title: '',
})
const submitting = ref(false)
const error = ref<string | null>(null)
const rows = ref<ProductSurveyRow[]>([])
const loading = ref(false)
const failed = ref(false)
watch(form, () => (error.value = null))
async function load() {
loading.value = true
failed.value = false
try {
rows.value = (await api.listMyProductSurveys()).rows
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function submit() {
if (!form.file_id) {
error.value = 'لطفا تصویر طرح را بارگذاری کنید'
return
}
if (!form.suggestedWeightGrams || form.suggestedWeightGrams <= 0) {
error.value = 'وزن پیشنهادی را وارد کنید'
return
}
submitting.value = true
try {
await api.createProductSurvey({
file_id: form.file_id,
suggestedWeightGrams: form.suggestedWeightGrams,
title: form.title || undefined,
})
toast.success('طرح ثبت شد؛ منتظر تأیید ادمین بمانید')
form.file_id = null
form.suggestedWeightGrams = null
form.title = ''
await load()
} catch {
error.value = 'ثبت طرح ممکن نشد'
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped lang="less">
.svm__body {
padding-top: 84px;
padding-bottom: 40px;
}
.svm__lead {
margin: 0 2px 12px;
font-size: 13px;
font-weight: 700;
color: @inkMuted;
}
.svm__field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
border-bottom: 1px solid @hairline;
font-size: 12.5px;
color: @inkSoft;
}
.svm__field input {
width: 100%;
padding: 11px 12px;
border: 1px solid @hairline;
border-radius: 12px;
background: transparent;
color: @ink;
font-family: inherit;
font-size: 14px;
}
.svm__error {
margin: 12px 2px 0;
font-size: 12.5px;
color: @red;
}
.svm__submit {
margin-top: 16px;
width: 100%;
padding: 14px;
border: none;
border-radius: 999px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.svm__list {
margin-top: 28px;
}
.svm__listTitle {
margin: 0 2px 12px;
font-size: 14px;
font-weight: 700;
color: @ink;
}
.svm__card {
display: grid;
grid-template-columns: 72px 1fr;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid @hairline;
}
.svm__photo {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 12px;
background: @hairline;
}
.svm__meta {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12.5px;
color: @inkSoft;
}
.svm__meta b {
color: @ink;
font-size: 13.5px;
}
.svm__empty {
padding: 18px 2px;
color: @inkMuted;
font-size: 13px;
}
</style>