Banner: system banners zone carousel mobile layout remove Home from Profile

This commit is contained in:
Fedor
2026-02-27 15:56:40 +03:00
parent f2e144e9ca
commit b5c31b43dd
4 changed files with 177 additions and 8 deletions

View File

@@ -1,5 +1,11 @@
# Доработки мини-приложения Clientright (TG/MAX и веб) # Доработки мини-приложения Clientright (TG/MAX и веб)
## Системные баннеры на экране приветствия (2026-02)
- **Баннер «Профиль не заполнен»** вынесен в отдельную зону справа от текста «Теперь ты в системе — можно продолжать» (на десктопе — колонка ~260px), чтобы не занимал полстраницы и не сдвигал контент.
- Реализовано **единое место для системных баннеров**: массив `systemBanners`, при одном баннере показывается один Alert, при нескольких — карусель (Ant Design Carousel). В будущем сюда можно добавлять другие критические уведомления.
- **Мобильная вёрстка**: баннер на всю ширину, нормальный перенос текста (без разбиения по слогам), кнопка «Заполнить профиль» переносится под текст, крестик закрытия остаётся в первой строке справа (через `order` и `flex-wrap`).
- **Профиль**: убрана дублирующая ссылка «Домой» из шапки карточки профиля — навигация остаётся через нижний бар.
## UI и навигация ## UI и навигация
- **«Мои обращения»**: дашборд с плитками по статусам (На рассмотрении, В работе, Решённые, Отклонённые, Все), заголовок переименован с «Жалобы потребителей». - **«Мои обращения»**: дашборд с плитками по статусам (На рассмотрении, В работе, Решённые, Отклонённые, Все), заголовок переименован с «Жалобы потребителей».
- Убрана внешняя рамка у дашборда; карточки с hover-эффектом (подъём, тень), единая высота плиток, прозрачный фон у иконок. - Убрана внешняя рамка у дашборда; карточки с hover-эффектом (подъём, тень), единая высота плиток, прозрачный фон у иконок.

View File

@@ -52,10 +52,10 @@
} }
.hello-hero-body { .hello-hero-body {
padding-top: 8px; padding: 8px 20px 16px;
min-height: 140px; min-height: 120px;
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: center; justify-content: center;
} }
@@ -64,6 +64,56 @@
text-align: center; text-align: center;
} }
.hello-hero-success {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.hello-hero-success-main {
flex: 1 1 auto;
font-size: 14px;
color: #374151;
}
.hello-hero-system {
flex: 0 0 260px;
max-width: 260px;
min-width: 0;
}
.hello-hero-system :where(.ant-alert) {
padding: 8px 12px;
width: 100%;
box-sizing: border-box;
}
.hello-hero-system :where(.ant-alert-content) {
min-width: 0;
flex: 1;
}
.hello-hero-system :where(.ant-alert-message) {
font-size: 13px;
font-weight: 600;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.hello-hero-system :where(.ant-alert-description) {
font-size: 12px;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.hello-hero-system-carousel .hello-hero-system-slide {
width: 100%;
}
.hello-grid { .hello-grid {
margin-top: 32px; margin-top: 32px;
} }
@@ -204,6 +254,32 @@
--tile-h: 140px; --tile-h: 140px;
} }
.hello-hero-success {
flex-direction: column;
}
.hello-hero-system {
flex: 1 1 auto;
width: 100%;
max-width: 100%;
min-width: 0;
}
/* На узких экранах баннер на всю ширину, кнопка под текстом — текст не сжимается */
.hello-hero-system :where(.ant-alert) {
flex-wrap: wrap;
}
.hello-hero-system :where(.ant-alert-action) {
flex-basis: 100%;
order: 1;
margin-top: 8px;
margin-left: 0;
}
.hello-hero-system :where(.ant-alert-close-icon) {
order: 0;
align-self: flex-start;
}
.tile-card :where(.ant-card-body) { .tile-card :where(.ant-card-body) {
padding: 16px 12px; padding: 16px 12px;
gap: 8px; gap: 8px;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card, Button, Input, Space, Spin, message, Row, Col } from 'antd'; import { Card, Button, Input, Space, Spin, message, Row, Col, Alert, Carousel } from 'antd';
import { import {
User, User,
IdCard, IdCard,
@@ -22,6 +22,7 @@ type Status = 'idle' | 'loading' | 'success' | 'error';
interface HelloAuthProps { interface HelloAuthProps {
onAvatarChange?: (url: string) => void; onAvatarChange?: (url: string) => void;
onNavigate?: (path: string) => void; onNavigate?: (path: string) => void;
onProfileNeedsAttentionChange?: (value: boolean) => void;
} }
const INIT_DATA_WAIT_MS = 5500; const INIT_DATA_WAIT_MS = 5500;
@@ -29,11 +30,19 @@ const INIT_DATA_POLL_MS = 200;
const INIT_DATA_BG_RECOVERY_MS = 15000; const INIT_DATA_BG_RECOVERY_MS = 15000;
const INIT_DATA_BG_TICK_MS = 250; const INIT_DATA_BG_TICK_MS = 250;
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) { export default function HelloAuth({ onAvatarChange, onNavigate, onProfileNeedsAttentionChange }: HelloAuthProps) {
const [status, setStatus] = useState<Status>('idle'); const [status, setStatus] = useState<Status>('idle');
const [greeting, setGreeting] = useState<string>('Привет!'); const [greeting, setGreeting] = useState<string>('Привет!');
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [avatar, setAvatar] = useState<string>(''); const [avatar, setAvatar] = useState<string>('');
const [profileNeedsAttention, setProfileNeedsAttention] = useState<boolean>(false);
const [profileBannerDismissed, setProfileBannerDismissed] = useState<boolean>(() => {
try {
return sessionStorage.getItem('profile_attention_banner_dismissed_v1') === '1';
} catch (_) {
return false;
}
});
const [phone, setPhone] = useState<string>(''); const [phone, setPhone] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [codeSent, setCodeSent] = useState<boolean>(false); const [codeSent, setCodeSent] = useState<boolean>(false);
@@ -97,6 +106,15 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
return 'need_contact'; return 'need_contact';
} }
if (res.ok && data.success) { if (res.ok && data.success) {
const needsAttentionRaw =
(data.profile_needs_attention as unknown) ??
(data.profileNeedsAttention as unknown) ??
(data.need_profile_confirm as unknown) ??
(data.needProfileConfirm as unknown);
const needsAttention = needsAttentionRaw === true || needsAttentionRaw === 1 || needsAttentionRaw === '1' || needsAttentionRaw === 'true';
setProfileNeedsAttention(needsAttention);
onProfileNeedsAttentionChange?.(needsAttention);
const token = data.session_token as string | undefined; const token = data.session_token as string | undefined;
if (token) { if (token) {
try { try {
@@ -171,6 +189,15 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
}); });
const verifyData = await verifyRes.json().catch(() => ({})); const verifyData = await verifyRes.json().catch(() => ({}));
if (verifyData?.valid === true) { if (verifyData?.valid === true) {
const needsAttentionRaw =
verifyData.profile_needs_attention ??
verifyData.profileNeedsAttention ??
verifyData.need_profile_confirm ??
verifyData.needProfileConfirm;
const needsAttention = needsAttentionRaw === true || needsAttentionRaw === 1 || needsAttentionRaw === '1' || needsAttentionRaw === 'true';
setProfileNeedsAttention(needsAttention);
onProfileNeedsAttentionChange?.(needsAttention);
setGreeting('Привет!'); setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user; const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user; const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
@@ -260,7 +287,7 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
}; };
tryAuth(); tryAuth();
}, [onAvatarChange, onNavigate]); }, [onAvatarChange, onNavigate, onProfileNeedsAttentionChange]);
if (noInitDataAfterTimeout && status === 'idle') { if (noInitDataAfterTimeout && status === 'idle') {
return ( return (
@@ -325,6 +352,43 @@ if (data.avatar_url) {
} }
}; };
const systemBanners: Array<{ key: string; node: JSX.Element }> = [];
if (status === 'success' && profileNeedsAttention && !profileBannerDismissed) {
systemBanners.push({
key: 'profile-not-complete',
node: (
<Alert
type="warning"
showIcon
closable
message="Профиль не заполнен"
description="Пожалуйста, заполните обязательные поля профиля, чтобы продолжить работу."
onClose={() => {
try {
sessionStorage.setItem('profile_attention_banner_dismissed_v1', '1');
} catch (_) {}
setProfileBannerDismissed(true);
}}
action={
<Button
size="small"
type="primary"
onClick={() => {
if (onNavigate) onNavigate('/profile');
else window.location.href = '/profile';
}}
>
Заполнить профиль
</Button>
}
/>
),
});
}
const hasSystemBanners = systemBanners.length > 0;
const tiles: Array<{ title: string; icon: typeof User; color: string; href?: string }> = [ const tiles: Array<{ title: string; icon: typeof User; color: string; href?: string }> = [
{ title: 'Мои обращения', icon: ClipboardList, color: '#6366F1', href: '/' }, { title: 'Мои обращения', icon: ClipboardList, color: '#6366F1', href: '/' },
{ title: 'Подать жалобу', icon: FileWarning, color: '#EA580C', href: '/new' }, { title: 'Подать жалобу', icon: FileWarning, color: '#EA580C', href: '/new' },
@@ -359,7 +423,31 @@ if (data.avatar_url) {
{status === 'loading' ? ( {status === 'loading' ? (
<Spin size="large" tip="Авторизация..." /> <Spin size="large" tip="Авторизация..." />
) : status === 'success' ? ( ) : status === 'success' ? (
<p>Теперь ты в системе можно продолжать</p> <div className={`hello-hero-success${hasSystemBanners ? ' hello-hero-success--with-banner' : ''}`}>
<div className="hello-hero-success-main">
<p>Теперь ты в системе можно продолжать</p>
</div>
{hasSystemBanners && (
<div className="hello-hero-system">
{systemBanners.length === 1 ? (
systemBanners[0].node
) : (
<Carousel
autoplay
dots
adaptiveHeight
className="hello-hero-system-carousel"
>
{systemBanners.map((banner) => (
<div key={banner.key} className="hello-hero-system-slide">
{banner.node}
</div>
))}
</Carousel>
)}
</div>
)}
</div>
) : status === 'error' ? ( ) : status === 'error' ? (
<p className="hello-hero-error">{error}</p> <p className="hello-hero-error">{error}</p>
) : ( ) : (

View File

@@ -207,7 +207,6 @@ export default function Profile({ onNavigate }: ProfileProps) {
<Card <Card
className="profile-card" className="profile-card"
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>} title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
extra={onNavigate ? <Button type="link" onClick={() => onNavigate('/')}>Домой</Button> : null}
> >
<Form form={form} layout="vertical" onFinish={handleSave}> <Form form={form} layout="vertical" onFinish={handleSave}>
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => ( {PROFILE_FIELDS.map(({ key, keys, label, editable }) => (