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:
co-authored by
Claude Opus 5
parent
a12fb0450e
commit
f2d83c53df
@@ -0,0 +1,229 @@
|
||||
<!--
|
||||
The "حساب" block that sits BELOW the shop content on the home screen.
|
||||
|
||||
Two genuinely different states:
|
||||
- signed out: a quiet invitation, no fabricated numbers.
|
||||
- signed in: the customer's real gram position from GET /customers/:id
|
||||
(goldDebtGrams / fiatDebtRial), labelled بدهکار or بستانکار the same way
|
||||
DashboardView does, since the ledger stores debt as a signed figure.
|
||||
-->
|
||||
<template>
|
||||
<section class="acct">
|
||||
<template v-if="customer">
|
||||
<div class="acct__head">
|
||||
<div>
|
||||
<div class="acct__eyebrow">{{ positionLabel }}</div>
|
||||
<div class="acct__figure">
|
||||
{{ grams }}
|
||||
<span class="acct__unit">گرم</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="acct__seal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
|
||||
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="acct__row">
|
||||
<span class="acct__rowLabel">بدهی ریالی</span>
|
||||
<span class="acct__rowValue">{{ price(fiat) }} <i>ریال</i></span>
|
||||
</div>
|
||||
|
||||
<div class="acct__actions">
|
||||
<button type="button" class="acct__action" @click="emit('go', 'ordersList')">
|
||||
<Icon icon="vuesax-linear:box" size="18" />
|
||||
سفارشها
|
||||
</button>
|
||||
<button type="button" class="acct__action" @click="emit('go', 'transactionsList')">
|
||||
<Icon icon="vuesax-linear:receipt-text" size="18" />
|
||||
اسناد
|
||||
</button>
|
||||
<button type="button" class="acct__action" @click="emit('go', 'profileData')">
|
||||
<Icon icon="vuesax-linear:user" size="18" />
|
||||
پروفایل
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="acct__invite">
|
||||
<div class="acct__inviteIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:security-user" size="20" />
|
||||
</div>
|
||||
<div class="acct__inviteCopy">
|
||||
<h3>حساب گرمی شما</h3>
|
||||
<p>برای دیدن موجودی، سفارشها و اسناد خود وارد شوید.</p>
|
||||
</div>
|
||||
<button type="button" class="acct__inviteCta" @click="emit('login')">ورود / ثبتنام</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { price } from '../../utils/filters'
|
||||
import type { CustomerDetail } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ customer: CustomerDetail | null }>()
|
||||
const emit = defineEmits<{ (e: 'go', route: string): void; (e: 'login'): void }>()
|
||||
|
||||
const isDebtor = computed(() => (props.customer ? parseFloat(props.customer.goldDebtGrams) > 0 : false))
|
||||
const positionLabel = computed(() => (isDebtor.value ? 'مانده بدهی گرمی' : 'مانده بستانکاری گرمی'))
|
||||
const grams = computed(() => (props.customer ? Math.abs(parseFloat(props.customer.goldDebtGrams)) : 0))
|
||||
const fiat = computed(() => (props.customer ? props.customer.fiatDebtRial.split('.')[0] : '0'))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.acct {
|
||||
border-radius: 20px;
|
||||
padding: 18px;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
box-shadow: 0 22px 44px -32px rgba(20, 22, 26, 0.9);
|
||||
}
|
||||
|
||||
.acct__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.acct__eyebrow {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.acct__figure {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.acct__unit {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
padding-inline-start: 3px;
|
||||
}
|
||||
|
||||
.acct__seal {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.acct__seal.is-debtor {
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
.acct__seal.is-creditor {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
.acct__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding-top: 13px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.acct__rowLabel {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.acct__rowValue {
|
||||
font-weight: 700;
|
||||
}
|
||||
.acct__rowValue i {
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.acct__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.acct__action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 11px 4px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 13px;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
transition: background 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.acct__action:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.acct__invite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.acct__inviteIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.acct__inviteCopy {
|
||||
flex: 1 1 150px;
|
||||
}
|
||||
.acct__inviteCopy h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.acct__inviteCopy p {
|
||||
margin: 5px 0 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.8;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.acct__inviteCta {
|
||||
flex: 1 1 100%;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
color: @ink;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.acct__inviteCta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!--
|
||||
Horizontal category rail. Categories are real, derived from the product
|
||||
catalogue by configStore (GET /catalog/products), so this renders nothing
|
||||
rather than placeholder tiles when the catalogue is empty.
|
||||
-->
|
||||
<template>
|
||||
<div class="rail no-scrollbar">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
type="button"
|
||||
class="rail__chip"
|
||||
:class="{ 'is-active': cat.id === activeId }"
|
||||
@click="emit('select', cat)"
|
||||
>
|
||||
<span class="rail__mark" aria-hidden="true">
|
||||
<img v-if="cat.icon?.image" :src="cat.icon.image" alt="" />
|
||||
</span>
|
||||
<span class="rail__title">{{ cat.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HomeCategory } from '../../stores/configStore'
|
||||
|
||||
defineProps<{ categories: HomeCategory[]; activeId?: string | null }>()
|
||||
const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.rail {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 16px 2px;
|
||||
margin: 0 -16px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.rail__chip {
|
||||
scroll-snap-align: start;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px 8px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.rail__chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.rail__chip.is-active {
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rail__mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: @hairlineSoft;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.rail__chip.is-active .rail__mark {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.rail__mark img {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
object-fit: contain;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.rail__title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<!--
|
||||
Live 18k gold rate strip. Real data: GET /pricing/latest (PricingController).
|
||||
The strip degrades to a quiet placeholder when the quote endpoint has no
|
||||
data yet, rather than rendering a fabricated number.
|
||||
-->
|
||||
<template>
|
||||
<div class="rate-strip" :class="{ 'is-empty': !quote }">
|
||||
<div class="rate-strip__label">
|
||||
<span class="rate-strip__dot" aria-hidden="true"></span>
|
||||
نرخ طلای ۱۸ عیار
|
||||
</div>
|
||||
|
||||
<div v-if="quote" class="rate-strip__value">
|
||||
<span class="rate-strip__num">{{ price(perGram) }}</span>
|
||||
<span class="rate-strip__unit">ریال / گرم</span>
|
||||
</div>
|
||||
<div v-else class="rate-strip__value rate-strip__value--muted">در انتظار اعلام نرخ</div>
|
||||
|
||||
<div v-if="quote" class="rate-strip__time">{{ jdate(quote.quotedAt) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { price, jdate } from '../../utils/filters'
|
||||
import type { GoldPriceQuote } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ quote: GoldPriceQuote | null }>()
|
||||
|
||||
// pricePerGramRial is a decimal string from the ledger — never parsed into a
|
||||
// float for display, only trimmed of any fractional tail.
|
||||
const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split('.')[0] : ''))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.rate-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -18px rgba(20, 22, 26, 0.9);
|
||||
}
|
||||
|
||||
.rate-strip__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rate-strip__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: @wineSoft;
|
||||
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0.65);
|
||||
animation: ratePulse 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.rate-strip__value {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rate-strip__value--muted {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.rate-strip__num {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.rate-strip__unit {
|
||||
font-size: 10.5px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.rate-strip__time {
|
||||
font-size: 10.5px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
padding-inline-start: 10px;
|
||||
border-inline-start: 1px solid rgba(255, 255, 255, 0.14);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes ratePulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 7px rgba(163, 60, 85, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!-- Section heading with an optional "see all" affordance. -->
|
||||
<template>
|
||||
<header class="sec">
|
||||
<div class="sec__text">
|
||||
<h2 class="sec__title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="sec__sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button v-if="actionLabel" type="button" class="sec__action" @click="emit('action')">
|
||||
{{ actionLabel }}
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="15" />
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from '../icon/Icon.vue'
|
||||
|
||||
defineProps<{ title: string; subtitle?: string; actionLabel?: string }>()
|
||||
const emit = defineEmits<{ (e: 'action'): void }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sec {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
|
||||
.sec__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
|
||||
.sec__sub {
|
||||
margin: 5px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.sec__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: @wine;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,201 @@
|
||||
<!--
|
||||
Editorial hero for the home screen. Replaces the legacy swiper banner with a
|
||||
self-contained, dependency-free slider: no swiper CSS to fight, no autoplay
|
||||
jitter, and it keeps working when a slide has no artwork.
|
||||
-->
|
||||
<template>
|
||||
<section class="showcase" v-if="slides.length">
|
||||
<div class="showcase__frame">
|
||||
<transition-group name="showcase-fade" tag="div" class="showcase__stage">
|
||||
<article v-for="(slide, i) in slides" :key="slide.key" v-show="i === index" class="showcase__slide">
|
||||
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" />
|
||||
<div class="showcase__scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="showcase__copy">
|
||||
<span class="showcase__eyebrow">{{ slide.eyebrow }}</span>
|
||||
<h2 class="showcase__title">{{ slide.title }}</h2>
|
||||
<p class="showcase__sub">{{ slide.subtitle }}</p>
|
||||
<button type="button" class="showcase__cta" @click="emit('select', slide)">
|
||||
{{ slide.cta }}
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<div class="showcase__dots" v-if="slides.length > 1">
|
||||
<button
|
||||
v-for="(slide, i) in slides"
|
||||
:key="slide.key"
|
||||
type="button"
|
||||
class="showcase__dot"
|
||||
:class="{ 'is-active': i === index }"
|
||||
:aria-label="slide.title"
|
||||
@click="go(i)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
|
||||
export interface ShowcaseSlide {
|
||||
key: string
|
||||
eyebrow: string
|
||||
title: string
|
||||
subtitle: string
|
||||
cta: string
|
||||
image?: string
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ slides: ShowcaseSlide[] }>()
|
||||
const emit = defineEmits<{ (e: 'select', slide: ShowcaseSlide): void }>()
|
||||
|
||||
const index = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function go(i: number) {
|
||||
index.value = i
|
||||
restart()
|
||||
}
|
||||
function restart() {
|
||||
if (timer) clearInterval(timer)
|
||||
if (props.slides.length > 1) {
|
||||
timer = setInterval(() => {
|
||||
index.value = (index.value + 1) % props.slides.length
|
||||
}, 6000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(restart)
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.showcase__frame {
|
||||
position: relative;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
background: @ink;
|
||||
box-shadow: 0 26px 50px -34px rgba(20, 22, 26, 0.85);
|
||||
}
|
||||
|
||||
.showcase__stage {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 320 / 208;
|
||||
}
|
||||
|
||||
.showcase__slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.showcase__art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.5;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.showcase__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(255deg, rgba(122, 27, 51, 0.55) 0%, rgba(122, 27, 51, 0) 52%),
|
||||
linear-gradient(to left, rgba(20, 22, 26, 0.2) 0%, rgba(20, 22, 26, 0.92) 72%);
|
||||
}
|
||||
|
||||
.showcase__copy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.showcase__eyebrow {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 1.6px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.showcase__title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
color: #fff;
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
|
||||
.showcase__sub {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.85;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0 0 16px;
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.showcase__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 9px 16px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: @ink;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
transition: transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.showcase__cta:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.showcase__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.showcase__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
background: @hairline;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.showcase__dot.is-active {
|
||||
width: 20px;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.showcase-fade-enter-active,
|
||||
.showcase-fade-leave-active {
|
||||
transition: opacity 0.55s ease;
|
||||
}
|
||||
.showcase-fade-enter-from,
|
||||
.showcase-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user