Stop the profile header from flickering on scroll.

Use a sticky avatar pin instead of changing hero height while the white sheet moves.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 21:46:55 +03:30
co-authored by Cursor
parent 20a8e2eec2
commit b3b80d8f9d
@@ -99,13 +99,6 @@ const unreadLabel = computed(() =>
notificationStore.unreadCount > 9 ? '9+' : String(notificationStore.unreadCount),
)
const samplePrice = computed(() => sampleProduct.value.basePrice ?? SAMPLE_PRODUCT.basePrice ?? 0)
const collapse = ref(0)
const COLLAPSE_DISTANCE = 160
function onProfileScroll(event: Event) {
const top = (event.target as HTMLElement).scrollTop
collapse.value = Math.min(1, Math.max(0, top / COLLAPSE_DISTANCE))
}
onMounted(async () => {
loading.value = true
@@ -134,59 +127,58 @@ onMounted(async () => {
<template>
<div class="page profile-page">
<header class="hero" :class="{ 'is-collapsed': collapse > 0.85 }" :style="{ '--c': String(collapse) }">
<div class="hero__top">
<button
type="button"
class="icon-btn"
aria-label="تنظیمات"
@click="router.push('/profile/display')"
>
<IconSettings :size="20" color="#ffffff" />
</button>
<h1>پروفایل من</h1>
<button
type="button"
class="icon-btn"
aria-label="اعلان‌ها"
@click="router.push('/profile/notifications')"
>
<IconBell :size="20" color="#ffffff" />
<span
v-if="notificationStore.hasUnread"
class="icon-btn__badge"
:aria-label="`${notificationStore.unreadCount} اعلان خوانده‌نشده`"
>
{{ unreadLabel }}
<div class="page__scroll page__scroll--with-nav profile-scroll">
<header class="hero">
<div class="hero__pin">
<div class="hero__top">
<button
type="button"
class="icon-btn"
aria-label="تنظیمات"
@click="router.push('/profile/display')"
>
<IconSettings :size="20" color="#ffffff" />
</button>
<h1>پروفایل من</h1>
<button
type="button"
class="icon-btn"
aria-label="اعلان‌ها"
@click="router.push('/profile/notifications')"
>
<IconBell :size="20" color="#ffffff" />
<span
v-if="notificationStore.hasUnread"
class="icon-btn__badge"
:aria-label="`${notificationStore.unreadCount} اعلان خوانده‌نشده`"
>
{{ unreadLabel }}
</span>
</button>
</div>
<UserAvatar
class="hero__avatar"
:src="auth.user?.avatarUrl"
:initials="displayName.charAt(0)"
:size="84"
/>
</div>
<div class="hero__meta">
<h2>{{ displayName }}</h2>
<p>کد واسطه: {{ code }}</p>
<span class="level-pill">
<svg class="level-pill__crown" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M3.5 17.5h17l-1.2-8.2a1 1 0 0 0-1.5-.7L14 11l-2.5-5.2a1 1 0 0 0-1.8 0L7.2 11 4.2 8.6a1 1 0 0 0-1.5.7L3.5 17.5Zm1.7 1.5h13.6v1.2H5.2V19Z"
/>
</svg>
سطح: {{ levelLabel }}
</span>
</button>
</div>
</div>
</header>
<UserAvatar
class="hero__avatar"
:src="auth.user?.avatarUrl"
:initials="displayName.charAt(0)"
:size="84"
/>
<div class="hero__meta">
<h2>{{ displayName }}</h2>
<p>کد واسطه: {{ code }}</p>
<span class="level-pill">
<svg class="level-pill__crown" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M3.5 17.5h17l-1.2-8.2a1 1 0 0 0-1.5-.7L14 11l-2.5-5.2a1 1 0 0 0-1.8 0L7.2 11 4.2 8.6a1 1 0 0 0-1.5.7L3.5 17.5Zm1.7 1.5h13.6v1.2H5.2V19Z"
/>
</svg>
سطح: {{ levelLabel }}
</span>
</div>
</header>
<div
class="page__scroll page__scroll--with-nav profile-scroll"
@scroll.passive="onProfileScroll"
>
<div class="profile-sheet">
<PageSkeleton v-if="loading" variant="profile" />
<template v-else>
<section class="panel">
@@ -332,6 +324,7 @@ onMounted(async () => {
</div>
</section>
</template>
</div>
</div>
</div>
</template>
@@ -342,30 +335,39 @@ onMounted(async () => {
}
.hero {
--c: 0;
flex-shrink: 0;
color: #fff;
padding: 0 0 28px;
text-align: center;
background: linear-gradient(
145deg,
var(--color-primary) 0%,
color-mix(in srgb, var(--color-primary) 78%, #0a1f18) 100%
);
}
.hero__pin {
position: sticky;
top: 0;
z-index: 3;
padding: 12px var(--page-padding) 10px;
background: linear-gradient(
145deg,
var(--color-primary) 0%,
color-mix(in srgb, var(--color-primary) 78%, #0a1f18) 100%
);
color: #fff;
padding: 12px var(--page-padding) calc(44px - var(--c) * 34px);
text-align: center;
}
.hero__top {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: calc(16px * (1 - var(--c)));
margin-bottom: 12px;
}
.hero__top h1 {
margin: 0;
font-size: 1rem;
font-weight: 700;
opacity: calc(1 - var(--c));
}
.icon-btn {
@@ -397,23 +399,13 @@ onMounted(async () => {
}
.hero__avatar {
margin: 0 auto calc(12px * (1 - var(--c)));
margin: 0 auto;
border: 3px solid rgba(255, 255, 255, 0.35);
color: #fff;
transform: scale(calc(1 - var(--c) * 0.18));
transform-origin: center top;
}
.hero__meta {
overflow: hidden;
max-height: calc((1 - var(--c)) * 130px);
opacity: calc(1 - var(--c));
transform: translateY(calc(var(--c) * -12px));
pointer-events: none;
}
.hero.is-collapsed .hero__meta {
visibility: hidden;
padding: 10px var(--page-padding) 0;
}
.hero h2 {
@@ -447,11 +439,16 @@ onMounted(async () => {
}
.profile-scroll {
position: relative;
z-index: 1;
flex: 1;
min-height: 0;
margin-top: -24px;
background: transparent;
padding: 0;
}
.profile-sheet {
position: relative;
z-index: 1;
margin-top: -20px;
padding: 20px var(--page-padding) 24px;
display: flex;
flex-direction: column;
@@ -461,7 +458,7 @@ onMounted(async () => {
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
}
.profile-scroll > * {
.profile-sheet > * {
flex-shrink: 0;
}