user-app: redesigned home screen and product detail

Design language introduced here and reused across both screens:
  - LESS colour tokens (@ink/@inkSoft/@inkMuted/@inkFaint, @wine/@wineSoft,
    @canvas/@canvasSoft, @hairline/@hairlineSoft) added to
    themeConfig/colors.json and injected globally via vite's modifyVars, so
    scoped <style lang="less"> blocks resolve them with no import
  - BEM-ish scoped class names, hairline-bordered cards at 14-18px radii,
    ink surfaces with a wine accent, dashed-border empty states
  - components/home/: SectionHead, ShowcaseCard, ShowcaseSlider, CategoryRail,
    RateStrip, AccountStrip

Data honesty is deliberate throughout: no rial price is printed for a piece,
because a finished item's price is the live gold rate x weight plus wage,
computed at order time and never stored on the product. Only genuinely
present fields are shown (weight, making-charge percent, stock, category,
brand). Categories and the hero are derived from the real catalogue, so an
empty catalogue renders an empty state rather than placeholder tiles.

Also fixes a real bug in the product detail view: it ran, at module scope,
history.pushState + window.onpopstate = () => history.go(1). Because the
drawer mounts its slot eagerly, that fired for every product card on the
home screen and permanently hijacked the back button for the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-14 23:05:29 +03:30
co-authored by Claude Opus 5
parent a12fb0450e
commit f2d83c53df
41 changed files with 2041 additions and 241 deletions
@@ -0,0 +1,150 @@
<!--
Product card for the redesigned home screen.
Shows only fields that genuinely exist on ProductSummary today: title, the
cheapest variant's rash weight, its making-charge (اجرت) percent, and the
catalogue image. No price is printed on the card, because a finished piece's
rial price is a function of the live gold rate plus wage and is computed at
order time, not stored on the product — printing one here would be invented.
-->
<template>
<article class="card" :class="{ 'card--wide': wide }" @click="emit('open', value)">
<div class="card__art">
<img v-lazy="image" :alt="title" />
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="17" />
</button>
<span v-if="badge" class="card__badge">{{ badge }}</span>
</div>
<div class="card__body">
<h3 class="card__title">{{ title }}</h3>
<div class="card__meta">
<span class="card__weight">{{ min_gram }} گرم</span>
<span v-if="wage != null" class="card__wage">اجرت {{ wageLabel }}٪</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ value: ProductSummary; wide?: boolean; badge?: string }>()
const emit = defineEmits<{ (e: 'open', value: ProductSummary): void }>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage } = useProduct(props.value)
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
</script>
<style scoped lang="less">
.card {
cursor: pointer;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.card:active {
transform: scale(0.985);
}
.card__art {
position: relative;
aspect-ratio: 1 / 1;
background: @hairlineSoft;
}
.card--wide .card__art {
aspect-ratio: 4 / 3;
}
.card__art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card__fav {
position: absolute;
top: 9px;
left: 9px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
cursor: pointer;
border-radius: 50%;
color: @inkMuted;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
.card__fav.is-on {
color: @wine;
}
.card__fav:active {
transform: scale(0.9);
}
.card__badge {
position: absolute;
top: 9px;
right: 9px;
padding: 4px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #fff;
background: @wine;
}
.card__body {
padding: 11px 12px 13px;
}
.card__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card__meta {
display: flex;
align-items: center;
gap: 7px;
padding-top: 7px;
}
.card__weight {
font-size: 11.5px;
font-weight: 700;
color: @inkSoft;
}
.card__wage {
font-size: 10.5px;
color: @inkFaint;
padding-inline-start: 7px;
border-inline-start: 1px solid @hairline;
}
</style>