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:
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user