Первая постановка на уведомления

This commit is contained in:
mi
2026-07-26 13:20:23 +03:00
parent 8c3c454998
commit 8822ae609f
15 changed files with 698 additions and 271 deletions
+32 -4
View File
@@ -1,6 +1,34 @@
import { RouterProvider } from 'react-router';
import { router } from './routes';
import { MemoryRouter, Routes, Route, Navigate, Outlet } from 'react-router';
import { Home } from './pages/Home';
import { History } from './pages/History';
import { Profile } from './pages/Profile';
import { Chat } from './pages/Chat';
import { Calendar } from './pages/Calendar';
import { NotificationDetail } from './pages/NotificationDetail';
import { AuthPhone } from './pages/AuthPhone';
import { AuthOtp } from './pages/AuthOtp';
import { AuthLoading } from './pages/AuthLoading';
import { AuthConsent } from './pages/AuthConsent';
import { Root } from './pages/Root';
export default function App() {
return <RouterProvider router={router} />;
}
return (
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Root />}>
<Route index element={<Home />} />
<Route path="history" element={<History />} />
<Route path="profile" element={<Profile />} />
<Route path="chat/:id" element={<Chat />} />
<Route path="calendar" element={<Calendar />} />
<Route path="notification/:id" element={<NotificationDetail />} />
<Route path="auth/consent" element={<AuthConsent />} />
<Route path="auth/phone" element={<AuthPhone />} />
<Route path="auth/otp" element={<AuthOtp />} />
<Route path="auth/loading" element={<AuthLoading />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MemoryRouter>
);
}
-7
View File
@@ -1,8 +1,6 @@
import { Mic, Paperclip, Send } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { isGuest } from '../data/session';
export function ChatInput() {
const [message, setMessage] = useState('');
const [isFocused, setIsFocused] = useState(false);
@@ -12,11 +10,6 @@ export function ChatInput() {
e.preventDefault();
if (!message.trim()) return;
if (isGuest()) {
navigate('/auth/consent', { state: { returnTo: '/auth/phone' } });
return;
}
navigate('/chat/new');
setMessage('');
setIsFocused(false);
+42 -25
View File
@@ -2,10 +2,10 @@ import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router';
import {
Calendar, AlertCircle, Bell, MessageCircle, Tag, Download,
X, ChevronLeft, ChevronRight, ArrowRight, Smartphone, UserX,
X, ChevronLeft, ChevronRight, ArrowRight, Smartphone,
Paperclip, CheckCircle2, RefreshCw, CreditCard,
} from 'lucide-react';
import { getActiveMessages, dismissMessage, CompanyMessage } from '../data/companyMessages';
import { isGuest } from '../data/session';
type TypeConfig = {
label: string;
@@ -63,6 +63,42 @@ function getConfig(type: CompanyMessage['type']): TypeConfig {
badge: 'bg-[#d1c4e9] text-[#4527a0]',
actionColor: 'text-[#4527a0]',
};
case 'docs_required':
return {
label: 'Документы',
icon: <Paperclip className="w-4 h-4" />,
bg: 'bg-[#fff3e0] border-[#ffcc80]',
iconWrap: 'bg-[#ffe0b2] text-[#e65100]',
badge: 'bg-[#ffe0b2] text-[#e65100]',
actionColor: 'text-[#e65100]',
};
case 'docs_ready':
return {
label: 'Готово',
icon: <CheckCircle2 className="w-4 h-4" />,
bg: 'bg-[#e8f5e9] border-[#a5d6a7]',
iconWrap: 'bg-[#c8e6c9] text-[#2e7d32]',
badge: 'bg-[#c8e6c9] text-[#2e7d32]',
actionColor: 'text-[#2e7d32]',
};
case 'status_changed':
return {
label: 'Статус',
icon: <RefreshCw className="w-4 h-4" />,
bg: 'bg-primary/6 border-primary/20',
iconWrap: 'bg-primary/10 text-primary',
badge: 'bg-primary/10 text-primary',
actionColor: 'text-primary',
};
case 'payment_pending':
return {
label: 'Оплата',
icon: <CreditCard className="w-4 h-4" />,
bg: 'bg-[#fce4ec] border-[#f48fb1]',
iconWrap: 'bg-[#f8bbd0] text-[#880e4f]',
badge: 'bg-[#f8bbd0] text-[#880e4f]',
actionColor: 'text-[#880e4f]',
};
default:
return {
label: 'Новость',
@@ -83,27 +119,6 @@ interface BeforeInstallPromptEvent extends Event {
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
function GuestBanner() {
const navigate = useNavigate();
return (
<div className="px-4 py-3">
<button
onClick={() => navigate('/auth/consent', { state: { returnTo: '/auth/phone' } })}
className="w-full rounded-xl border border-dashed border-primary/40 bg-primary/4 px-4 py-3 flex items-start gap-3 hover:bg-primary/8 transition-colors text-left"
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary flex-shrink-0 mt-0.5">
<UserX className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground mb-0.5">Вы в гостевом режиме</p>
<p className="text-xs text-muted-foreground leading-snug">
Авторизуйтесь для получения полноценного доступа к функционалу приложения
</p>
</div>
</button>
</div>
);
}
export function Notifications() {
const navigate = useNavigate();
@@ -146,8 +161,6 @@ export function Notifications() {
}
}
if (isGuest()) return <GuestBanner />;
if (messages.length === 0) return null;
const msg = messages[index];
@@ -168,6 +181,10 @@ export function Notifications() {
msg.type === 'message' ? 'Открыть чат →' :
msg.type === 'install' ? (installing ? 'Открываем...' : (installReady ? 'Установить →' : 'Как установить →')) :
msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' :
msg.type === 'docs_required' ? 'Загрузить документы →' :
msg.type === 'docs_ready' ? 'Как получить →' :
msg.type === 'status_changed' ? 'Подробнее →' :
msg.type === 'payment_pending' ? 'Оплатить →' :
'Подробнее →';
return (
+50 -2
View File
@@ -1,6 +1,6 @@
export interface CompanyMessage {
id: string;
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo' | 'install';
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo' | 'install' | 'docs_required' | 'docs_ready' | 'status_changed' | 'payment_pending';
title: string;
description: string;
fullContent: string;
@@ -86,6 +86,54 @@ export const companyMessages: CompanyMessage[] = [
fullContent: 'Добавьте HAN на главный экран — приложение откроется мгновенно, будет работать офлайн и присылать важные напоминания о документах.',
date: 'Сегодня',
},
{
id: '10',
type: 'docs_required',
title: 'Требуется приложить документы',
description: 'Для продолжения оформления патента загрузите недостающие документы',
fullContent: 'Для завершения оформления вашего патента необходимо предоставить недостающие документы: перевод паспорта и медицинский полис ДМС. Загрузите их в чат с консультантом.',
steps: [
'Откройте чат с консультантом',
'Загрузите перевод паспорта (фото или скан)',
'Загрузите полис ДМС',
'Дождитесь подтверждения от специалиста',
],
date: 'Сегодня, 10:30',
},
{
id: '11',
type: 'docs_ready',
title: 'Ваши документы готовы',
description: 'Справка о несудимости готова к получению',
fullContent: 'Справка о несудимости готова. Вы можете получить её в нашем офисе или заказать курьерскую доставку. Документ действителен 3 месяца с даты выдачи.',
steps: [
'Подойдите в офис по адресу: Москва, ул. Тверская, 15',
'Режим работы: пн–пт 9:0018:00',
'При себе иметь паспорт',
],
date: 'Сегодня, 09:45',
},
{
id: '12',
type: 'status_changed',
title: 'Статус по услуге изменился',
description: 'Заявление на РВП принято в обработку',
fullContent: 'Ваше заявление на разрешение временного проживания (РВП) принято сотрудниками МВД и передано на рассмотрение. Срок рассмотрения — до 60 рабочих дней. Мы уведомим вас о следующем изменении статуса.',
date: 'Вчера, 16:20',
},
{
id: '13',
type: 'payment_pending',
title: 'Ожидаем оплату',
description: 'Счёт за услугу «Перевод документов» — 2 500 ₽',
fullContent: 'Для завершения оказания услуги «Перевод документов» необходимо произвести оплату в размере 2 500 ₽. После оплаты переводы будут готовы в течение 2 рабочих дней.',
steps: [
'Нажмите «Оплатить» ниже',
'Выберите удобный способ оплаты',
'Сохраните чек об оплате',
],
date: 'Вчера, 14:00',
},
{
id: '9',
type: 'promo',
@@ -114,7 +162,7 @@ export function getDismissedIds(): string[] {
export function dismissMessage(id: string) {
const current = getDismissedIds();
if (!current.includes(id)) {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...current, id]));
try { localStorage.setItem(DISMISSED_KEY, JSON.stringify([...current, id])); } catch { /* ignore */ }
}
}
+15 -3
View File
@@ -2,13 +2,25 @@ export type AuthStatus = 'authenticated' | 'guest' | null;
const KEY = 'han_auth_status';
function safeGet(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
}
function safeSet(key: string, value: string) {
try { localStorage.setItem(key, value); } catch { /* ignore */ }
}
function safeRemove(key: string) {
try { localStorage.removeItem(key); } catch { /* ignore */ }
}
export function getAuthStatus(): AuthStatus {
return (localStorage.getItem(KEY) as AuthStatus) ?? null;
return (safeGet(KEY) as AuthStatus) ?? null;
}
export function setAuthStatus(status: AuthStatus) {
if (status === null) localStorage.removeItem(KEY);
else localStorage.setItem(KEY, status);
if (status === null) safeRemove(KEY);
else safeSet(KEY, status);
}
export function isGuest() {
+19 -28
View File
@@ -2,16 +2,12 @@ import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { ArrowRight, Check, ExternalLink, ShieldCheck } from 'lucide-react';
interface ConsentLink {
label: string;
href: string;
}
interface ConsentItem {
id: string;
required: boolean;
text: string;
links: ConsentLink[];
linkLabel: string;
linkHref: string;
}
const consents: ConsentItem[] = [
@@ -19,22 +15,22 @@ const consents: ConsentItem[] = [
id: 'pdp',
required: true,
text: 'Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных',
links: [
{ label: 'Согласие на обработку ПД', href: '#pdp-consent' },
{ label: 'Политика обработки ПД', href: '#pdp-policy' },
],
linkLabel: 'Политика обработки персональных данных',
linkHref: '#pdp',
},
{
id: 'terms',
required: true,
text: 'Я прочитал и соглашаюсь с Пользовательским соглашением',
links: [{ label: 'Пользовательское соглашение', href: '#terms' }],
linkLabel: 'Пользовательское соглашение',
linkHref: '#terms',
},
{
id: 'marketing',
required: false,
text: 'Я даю своё согласие на получение рекламных и маркетинговых коммуникаций',
links: [{ label: 'Условия получения коммуникаций', href: '#marketing' }],
linkLabel: 'Условия получения коммуникаций',
linkHref: '#marketing',
},
];
@@ -117,22 +113,17 @@ export function AuthConsent() {
<span className="text-destructive ml-1">*</span>
)}
</p>
{/* Ссылки на документы */}
<div className="flex flex-wrap gap-x-3 gap-y-1">
{consent.links.map(link => (
<a
key={link.href}
href={link.href}
onClick={e => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-primary underline underline-offset-2 hover:opacity-75 transition-opacity"
target="_blank"
rel="noreferrer"
>
<ExternalLink className="w-3 h-3" />
{link.label}
</a>
))}
</div>
{/* Ссылка на документ */}
<a
href={consent.linkHref}
onClick={e => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-primary underline underline-offset-2 hover:opacity-75 transition-opacity"
target="_blank"
rel="noreferrer"
>
<ExternalLink className="w-3 h-3" />
{consent.linkLabel}
</a>
</div>
</div>
</div>
+17
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { ArrowRight } from 'lucide-react';
import { setAuthStatus } from '../data/session';
export function AuthPhone() {
const navigate = useNavigate();
@@ -88,6 +89,22 @@ export function AuthPhone() {
</form>
</div>
{/* Низ страницы */}
<div className="space-y-4 text-center">
<button
type="button"
onClick={() => { setAuthStatus('guest'); navigate('/'); }}
className="w-full text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
>
Войти как гость
</button>
<p className="text-xs text-muted-foreground leading-relaxed">
Нажимая «Получить код», вы соглашаетесь с{' '}
<span className="text-foreground/70 underline underline-offset-2">условиями использования</span>
{' '}и{' '}
<span className="text-foreground/70 underline underline-offset-2">политикой конфиденциальности</span>
</p>
</div>
</div>
</div>
);
+9 -1
View File
@@ -1,4 +1,4 @@
import { Clock, ArrowLeft, Bell, AlertCircle, Calendar, MessageCircle, Tag, Smartphone } from 'lucide-react';
import { Clock, ArrowLeft, Bell, AlertCircle, Calendar, MessageCircle, Tag, Smartphone, Paperclip, CheckCircle2, RefreshCw, CreditCard } from 'lucide-react';
import { useNavigate } from 'react-router';
import { getActiveMessages, CompanyMessage } from '../data/companyMessages';
@@ -8,6 +8,10 @@ function getIcon(type: CompanyMessage['type']) {
if (type === 'message') return <MessageCircle className="w-4 h-4" />;
if (type === 'promo') return <Tag className="w-4 h-4" />;
if (type === 'install') return <Smartphone className="w-4 h-4" />;
if (type === 'docs_required') return <Paperclip className="w-4 h-4" />;
if (type === 'docs_ready') return <CheckCircle2 className="w-4 h-4" />;
if (type === 'status_changed') return <RefreshCw className="w-4 h-4" />;
if (type === 'payment_pending') return <CreditCard className="w-4 h-4" />;
return <Bell className="w-4 h-4" />;
}
@@ -17,6 +21,10 @@ function typeColors(type: CompanyMessage['type']) {
if (type === 'message') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
if (type === 'promo') return { icon: 'bg-[#fff3cd] text-[#e65100]', dot: 'bg-[#fb8c00]' };
if (type === 'install') return { icon: 'bg-[#d1c4e9] text-[#4527a0]', dot: 'bg-[#7c4dff]' };
if (type === 'docs_required') return { icon: 'bg-[#ffe0b2] text-[#e65100]', dot: 'bg-[#fb8c00]' };
if (type === 'docs_ready') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
if (type === 'status_changed') return { icon: 'bg-primary/10 text-primary', dot: 'bg-primary' };
if (type === 'payment_pending') return { icon: 'bg-[#f8bbd0] text-[#880e4f]', dot: 'bg-[#e91e63]' };
return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' };
}
+10 -25
View File
@@ -1,5 +1,5 @@
import { useNavigate, useParams } from 'react-router';
import { ArrowLeft, Calendar, AlertCircle, Bell, CheckCircle, EyeOff, Clock } from 'lucide-react';
import { ArrowLeft, Calendar, AlertCircle, Bell, CheckCircle, Clock } from 'lucide-react';
import { companyMessages, dismissMessage } from '../data/companyMessages';
function getIcon(type: 'urgent' | 'reminder' | 'info', className = 'w-5 h-5') {
@@ -52,11 +52,6 @@ export function NotificationDetail() {
navigate('/');
};
const handleHide = () => {
dismissMessage(notification.id);
navigate('/');
};
const handleLater = () => navigate('/');
return (
@@ -123,31 +118,21 @@ export function NotificationDetail() {
</div>
{/* Кнопки */}
<div className="border-t border-border/40 p-4 space-y-2 bg-background">
<div className="border-t border-border/40 p-4 flex gap-2 bg-background">
<button
onClick={handleComplete}
className="w-full bg-primary text-primary-foreground rounded-lg px-4 py-3 font-medium flex items-center justify-center gap-2 hover:bg-primary/90 transition-colors"
className="flex-1 bg-primary text-primary-foreground rounded-lg px-4 py-3 font-medium flex items-center justify-center gap-2 hover:bg-primary/90 transition-colors"
>
<CheckCircle className="w-4 h-4" />
Готово
</button>
<div className="flex gap-2">
<button
onClick={handleLater}
className="flex-1 bg-muted text-foreground rounded-lg px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 hover:bg-muted/80 transition-colors"
>
<Clock className="w-4 h-4" />
Сделаю позже
</button>
<button
onClick={handleHide}
className="flex-1 bg-muted text-muted-foreground rounded-lg px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 hover:bg-muted/80 transition-colors"
>
<EyeOff className="w-4 h-4" />
Скрыть
</button>
</div>
<button
onClick={handleLater}
className="flex-1 bg-muted text-foreground rounded-lg px-4 py-3 font-medium flex items-center justify-center gap-2 hover:bg-muted/80 transition-colors"
>
<Clock className="w-4 h-4" />
Сделаю позже
</button>
</div>
</div>
);
+2 -1
View File
@@ -1,4 +1,4 @@
import { createBrowserRouter } from 'react-router';
import { createBrowserRouter, Navigate } from 'react-router';
import { Root } from './pages/Root';
import { Home } from './pages/Home';
import { History } from './pages/History';
@@ -28,4 +28,5 @@ export const router = createBrowserRouter([
{ path: 'auth/loading', Component: AuthLoading },
],
},
{ path: '*', element: <Navigate to="/" replace /> },
]);