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:
s.esmaeili
2026-10-16 00:04:13 +03:30
co-authored by Cursor
parent be2c2ddf21
commit 3add0683b3
69 changed files with 1577 additions and 576 deletions
+75 -7
View File
@@ -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>
+58 -92
View File
@@ -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 {