Polish Hedena brand, home layout, and user-app chrome.
Switch the customer app to IRANSans, restyle home featured/newest and cart note sheet, refresh brand icons across apps, and add the PWA install prompt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -94,21 +94,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-item note -->
|
||||
<!-- Per-item note — floating island, same language as the tab bar -->
|
||||
<Drawer
|
||||
v-model:visible="cartData.showPopUp"
|
||||
placement="bottom"
|
||||
v-if="cartData.tempRow?.product"
|
||||
:closable="false"
|
||||
height="300px"
|
||||
height="auto"
|
||||
wrap-class-name="cart-note-island"
|
||||
class="rtl"
|
||||
:bodyStyle="{ padding: 0 }"
|
||||
:body-style="{ padding: 0 }"
|
||||
:drawer-style="{ background: 'transparent', boxShadow: 'none' }"
|
||||
>
|
||||
<div class="sheet">
|
||||
<div class="sheet__handle" aria-hidden="true" />
|
||||
<h3 class="sheet__title">توضیح برای {{ cartData.tempRow.product.title }}</h3>
|
||||
<Form :model="formModel" name="noteForm" layout="vertical" autocomplete="off" @finish="setNote">
|
||||
<FormItem has-feedback name="note">
|
||||
<Textarea v-model:value="formModel.note" :rows="4" placeholder="مثلاً سایز یا جزئیات سفارش" />
|
||||
<FormItem has-feedback name="note" class="sheet__field">
|
||||
<Textarea
|
||||
v-model:value="formModel.note"
|
||||
:rows="3"
|
||||
placeholder="مثلاً سایز یا جزئیات سفارش"
|
||||
class="sheet__input"
|
||||
/>
|
||||
</FormItem>
|
||||
<button type="submit" class="sheet__cta">ثبت توضیحات</button>
|
||||
</Form>
|
||||
@@ -414,14 +422,31 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.sheet {
|
||||
padding: 18px 16px 22px;
|
||||
padding: 10px 16px 14px;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 10px 28px -12px rgba(20, 22, 26, 0.18);
|
||||
}
|
||||
|
||||
.sheet__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 999px;
|
||||
background: @hairline;
|
||||
}
|
||||
|
||||
.sheet__title {
|
||||
margin: 0 0 14px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sheet__field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sheet__cta {
|
||||
@@ -435,5 +460,48 @@ onMounted(async () => {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @primary;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.sheet__cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Drawer teleports to body — island chrome must live outside scoped CSS. -->
|
||||
<style lang="less">
|
||||
.cart-note-island.ant-drawer-bottom .ant-drawer-content-wrapper {
|
||||
left: 14px !important;
|
||||
right: 14px !important;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
width: auto !important;
|
||||
border-radius: 22px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cart-note-island .ant-drawer-content {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cart-note-island .ant-drawer-body {
|
||||
padding: 0 !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cart-note-island .sheet__input.ant-input {
|
||||
border-radius: 14px;
|
||||
border-color: @hairline;
|
||||
background: @hairlineSoft;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cart-note-island .sheet__input.ant-input:focus,
|
||||
.cart-note-island .sheet__input.ant-input:hover {
|
||||
border-color: alpha(@primary, 45%);
|
||||
background: @canvas;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
2. the live 18k rate strip (real: GET /pricing/latest)
|
||||
3. an editorial showcase hero built from real categories
|
||||
4. the category rail (real: derived from GET /catalog/products)
|
||||
5. "برگزیده" — masonry of product cards
|
||||
6. "تازهها" — a horizontal rail
|
||||
5. "برگزیده" — staggered 2-col masonry (wireframe: unequal card heights, flush bottoms)
|
||||
6. "تازهها" — full-width horizontal ProductRow list
|
||||
7. the account block (real: GET /customers/:id when signed in)
|
||||
|
||||
Data honesty, carried over from the previous version deliberately:
|
||||
@@ -68,22 +68,19 @@
|
||||
/>
|
||||
<div class="home__masonry">
|
||||
<div class="home__masonryCol">
|
||||
<ShowcaseCard
|
||||
v-for="(cell, i) in masonryCols.start"
|
||||
<ProductCard
|
||||
v-for="cell in masonryCols.left"
|
||||
:key="cell.item.id"
|
||||
:value="cell.item"
|
||||
:badge="i === 0 ? 'برگزیده' : undefined"
|
||||
:size="cell.size"
|
||||
@open="openProduct"
|
||||
/>
|
||||
</div>
|
||||
<div class="home__masonryCol">
|
||||
<ShowcaseCard
|
||||
v-for="cell in masonryCols.end"
|
||||
<ProductCard
|
||||
v-for="cell in masonryCols.right"
|
||||
:key="cell.item.id"
|
||||
:value="cell.item"
|
||||
:size="cell.size"
|
||||
@open="openProduct"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,14 +93,8 @@
|
||||
action-label="مشاهده همه"
|
||||
@action="goNewest"
|
||||
/>
|
||||
<div class="home__reel no-scrollbar">
|
||||
<ShowcaseCard
|
||||
v-for="item in newestProducts"
|
||||
:key="item.id"
|
||||
:value="item"
|
||||
class="home__reelItem"
|
||||
@open="openProduct"
|
||||
/>
|
||||
<div class="home__rows">
|
||||
<ProductRow v-for="item in newestProducts" :key="item.id" :value="item" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -112,20 +103,6 @@
|
||||
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
|
||||
</section>
|
||||
|
||||
<div class="home__trust reveal" :style="revealDelay(5)">
|
||||
<div class="home__trustItem">
|
||||
<Icon icon="vuesax-linear:shield-tick" size="18" />
|
||||
<span>خرید امن</span>
|
||||
</div>
|
||||
<div class="home__trustItem">
|
||||
<Icon icon="vuesax-linear:verify" size="18" />
|
||||
<span>اصالت کالا</span>
|
||||
</div>
|
||||
<div class="home__trustItem">
|
||||
<Icon icon="vuesax-linear:box-tick" size="18" />
|
||||
<span>ارسال بیمهشده</span>
|
||||
</div>
|
||||
</div>
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</div>
|
||||
@@ -137,12 +114,12 @@ import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../components/icon/Icon.vue'
|
||||
import { openProduct as openProductMorph } from '../composables/useProductMorph'
|
||||
import RateStrip from '../components/home/RateStrip.vue'
|
||||
import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
|
||||
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
|
||||
import CategoryRail from '../components/home/CategoryRail.vue'
|
||||
import ShowcaseCard from '../components/home/ShowcaseCard.vue'
|
||||
import ProductCard from '../components/product/ProductCard.vue'
|
||||
import ProductRow from '../components/product/ProductRow.vue'
|
||||
import SectionHead from '../components/home/SectionHead.vue'
|
||||
import { useConfigStore } from '../stores/configStore'
|
||||
import type { HomeCategory } from '../stores/configStore'
|
||||
@@ -177,35 +154,53 @@ const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeS
|
||||
const { revealed, revealDelay, reveal } = useReveal('home')
|
||||
|
||||
/**
|
||||
* Featured masonry — two real columns with deliberately *different* height
|
||||
* rhythms (not a mirrored tall/short alternation), so the block reads
|
||||
* asymmetric. RTL puts `start` on the right.
|
||||
* Featured masonry — matches the staggered wireframe: two equal columns,
|
||||
* unequal card heights (seams don't line up), both columns end flush.
|
||||
*
|
||||
* Height budget (art+body): short 1.15, mid 1.30, tall 1.40, hero 1.55.
|
||||
* Left short+hero+tall+mid = 5.40
|
||||
* Right hero+short+mid+tall = 5.40 → same baseline, no empty well.
|
||||
* Container is LTR so left/right stay stable; card text stays RTL.
|
||||
*/
|
||||
const featuredProducts = computed(() => products.value.slice(0, 9))
|
||||
const newestProducts = computed(() => products.value.slice(9, 17))
|
||||
/**
|
||||
* Even count only — an odd leftover sits alone under one column and leaves
|
||||
* a white well beside it (the orphan the owner asked to drop).
|
||||
*/
|
||||
const featuredProducts = computed(() => {
|
||||
const items = products.value.slice(0, 8)
|
||||
return items.length % 2 === 1 ? items.slice(0, -1) : items
|
||||
})
|
||||
/** Last 10 in catalogue order, newest first in the row list. */
|
||||
const newestProducts = computed(() => products.value.slice(-10).reverse())
|
||||
|
||||
type MasonrySize = 'hero' | 'tall' | 'mid' | 'short' | 'squat'
|
||||
type MasonryCol = 'left' | 'right'
|
||||
|
||||
/** Start column (visual right in RTL): opens tall, then irregular. */
|
||||
const START_SIZES: MasonrySize[] = ['hero', 'short', 'tall', 'mid', 'squat']
|
||||
/** End column: different sequence + often one fewer/taller cards. */
|
||||
const END_SIZES: MasonrySize[] = ['mid', 'hero', 'short', 'tall']
|
||||
const TILES: ReadonlyArray<{ col: MasonryCol; size: MasonrySize }> = [
|
||||
{ col: 'left', size: 'short' },
|
||||
{ col: 'right', size: 'hero' },
|
||||
{ col: 'left', size: 'hero' },
|
||||
{ col: 'right', size: 'short' },
|
||||
{ col: 'left', size: 'tall' },
|
||||
{ col: 'right', size: 'mid' },
|
||||
{ col: 'left', size: 'mid' },
|
||||
{ col: 'right', size: 'tall' },
|
||||
]
|
||||
|
||||
const masonryCols = computed(() => {
|
||||
const items = featuredProducts.value
|
||||
const start: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
const end: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
const left: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
const right: { item: ProductSummary; size: MasonrySize }[] = []
|
||||
|
||||
items.forEach((item, i) => {
|
||||
// Uneven split: 5 on start / 4 on end when we have 9 — keeps columns unequal.
|
||||
if (i % 2 === 0 && start.length < START_SIZES.length) {
|
||||
start.push({ item, size: START_SIZES[start.length] })
|
||||
} else if (end.length < END_SIZES.length) {
|
||||
end.push({ item, size: END_SIZES[end.length] })
|
||||
} else {
|
||||
start.push({ item, size: START_SIZES[start.length % START_SIZES.length] })
|
||||
const tile = TILES[i] ?? {
|
||||
col: (i % 2 === 0 ? 'left' : 'right') as MasonryCol,
|
||||
size: 'mid' as MasonrySize,
|
||||
}
|
||||
if (tile.col === 'left') left.push({ item, size: tile.size })
|
||||
else right.push({ item, size: tile.size })
|
||||
})
|
||||
return { start, end }
|
||||
return { left, right }
|
||||
})
|
||||
|
||||
// The hero shows ONLY what the admin uploaded in the slider section
|
||||
@@ -218,12 +213,6 @@ const slides = computed<ShowcaseSlide[]>(() =>
|
||||
.map((s, i) => ({ key: `${i}-${s.image}`, image: s.image })),
|
||||
)
|
||||
|
||||
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
|
||||
// App.vue) — the card is found from the tap, so the emit chain stays as is.
|
||||
function openProduct(product: ProductSummary) {
|
||||
openProductMorph(product)
|
||||
}
|
||||
|
||||
function onCategory(cat: HomeCategory) {
|
||||
filters.query.cats = [cat.id]
|
||||
router.push({ name: 'search' })
|
||||
@@ -319,7 +308,7 @@ onActivated(async () => {
|
||||
.home__barInner {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 11px 16px;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -327,7 +316,8 @@ onActivated(async () => {
|
||||
}
|
||||
|
||||
.home__logo {
|
||||
height: 21px;
|
||||
height: 52px;
|
||||
width: 52px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -361,7 +351,7 @@ onActivated(async () => {
|
||||
}
|
||||
|
||||
.home__body {
|
||||
padding-top: 62px;
|
||||
padding-top: 72px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
@@ -381,9 +371,11 @@ onActivated(async () => {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
/* Asymmetric masonry: two independent columns, unequal size rhythms. */
|
||||
/* LTR columns so left/right placement is stable under a page-level RTL. */
|
||||
.home__masonry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
direction: ltr;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
}
|
||||
@@ -394,20 +386,13 @@ onActivated(async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.home__reel {
|
||||
.home__rows {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
overflow-x: auto;
|
||||
margin: 0 -16px;
|
||||
padding: 0 16px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.home__reelItem {
|
||||
flex: 0 0 154px;
|
||||
scroll-snap-align: start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home__empty {
|
||||
@@ -426,23 +411,4 @@ onActivated(async () => {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.home__trust {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 9px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.home__trustItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 14px 6px;
|
||||
border-radius: 14px;
|
||||
background: @hairlineSoft;
|
||||
color: @inkMuted;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -96,12 +96,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SectionHead
|
||||
v-if="filteredProducts.length"
|
||||
title="نتایج"
|
||||
:subtitle="`${filteredProducts.length} محصول`"
|
||||
/>
|
||||
|
||||
<div v-if="filteredProducts.length" class="search__grid">
|
||||
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
|
||||
</div>
|
||||
@@ -145,7 +139,6 @@ import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
import SearchInput from '../../components/search/SearchInput.vue'
|
||||
import Filters from '../../components/search/Filters.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import ProductCard from '../../components/product/ProductCard.vue'
|
||||
import { useFiltersStore } from '../../stores/filtersStore'
|
||||
import { useCatalogStore } from '../../stores/catalogStore'
|
||||
@@ -294,20 +287,24 @@ onMounted(() => loadProducts())
|
||||
.search__chip {
|
||||
scroll-snap-align: start;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
transition: all 0.2s ease;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.search__chip.is-on {
|
||||
|
||||
Reference in New Issue
Block a user