Первая постановка на уведомления
This commit is contained in:
Binary file not shown.
+32
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:00–18: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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 /> },
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user