Реализован интерфейс согласий
This commit is contained in:
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
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('');
|
||||
@@ -9,12 +10,16 @@ export function ChatInput() {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (message.trim()) {
|
||||
// Переход в чат при отправке сообщения
|
||||
navigate('/chat/new');
|
||||
setMessage('');
|
||||
setIsFocused(false);
|
||||
if (!message.trim()) return;
|
||||
|
||||
if (isGuest()) {
|
||||
navigate('/auth/consent', { state: { returnTo: '/auth/phone' } });
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat/new');
|
||||
setMessage('');
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -35,18 +40,11 @@ export function ChatInput() {
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => {
|
||||
if (!message.trim()) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => { if (!message.trim()) setIsFocused(false); }}
|
||||
placeholder="Напишите ваш вопрос..."
|
||||
rows={isFocused ? 3 : 1}
|
||||
className="flex-1 bg-transparent resize-none outline-none py-2 px-2 max-h-32 min-h-[40px] text-base transition-all"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}}
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Calendar, AlertCircle, Bell, MessageCircle, Tag,
|
||||
X, ChevronLeft, ChevronRight, ArrowRight,
|
||||
Calendar, AlertCircle, Bell, MessageCircle, Tag, Download,
|
||||
X, ChevronLeft, ChevronRight, ArrowRight, Smartphone, UserX,
|
||||
} from 'lucide-react';
|
||||
import { getActiveMessages, dismissMessage, CompanyMessage } from '../data/companyMessages';
|
||||
|
||||
// ─── Тип-конфиг ──────────────────────────────────────────────────────────────
|
||||
import { isGuest } from '../data/session';
|
||||
|
||||
type TypeConfig = {
|
||||
label: string;
|
||||
@@ -55,6 +54,15 @@ function getConfig(type: CompanyMessage['type']): TypeConfig {
|
||||
badge: 'bg-[#fff3cd] text-[#e65100]',
|
||||
actionColor: 'text-[#e65100]',
|
||||
};
|
||||
case 'install':
|
||||
return {
|
||||
label: 'Приложение',
|
||||
icon: <Smartphone className="w-4 h-4" />,
|
||||
bg: 'bg-[#ede7f6] border-[#b39ddb]',
|
||||
iconWrap: 'bg-[#d1c4e9] text-[#4527a0]',
|
||||
badge: 'bg-[#d1c4e9] text-[#4527a0]',
|
||||
actionColor: 'text-[#4527a0]',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Новость',
|
||||
@@ -67,12 +75,53 @@ function getConfig(type: CompanyMessage['type']): TypeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Основной компонент ───────────────────────────────────────────────────────
|
||||
// Глобальное хранение события установки PWA
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
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();
|
||||
const [messages, setMessages] = useState(() => getActiveMessages());
|
||||
const [index, setIndex] = useState(0);
|
||||
const [installReady, setInstallReady] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
// Перехватываем beforeinstallprompt
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
setInstallReady(true);
|
||||
};
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
return () => window.removeEventListener('beforeinstallprompt', handler);
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((id: string) => {
|
||||
dismissMessage(id);
|
||||
@@ -81,6 +130,24 @@ export function Notifications() {
|
||||
setIndex(i => Math.min(i, Math.max(next.length - 1, 0)));
|
||||
}, []);
|
||||
|
||||
async function handleInstall(msgId: string) {
|
||||
if (!deferredPrompt) {
|
||||
// На десктопе или если prompt недоступен — просто закрываем
|
||||
handleDismiss(msgId);
|
||||
return;
|
||||
}
|
||||
setInstalling(true);
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
deferredPrompt = null;
|
||||
setInstalling(false);
|
||||
if (outcome === 'accepted') {
|
||||
handleDismiss(msgId);
|
||||
}
|
||||
}
|
||||
|
||||
if (isGuest()) return <GuestBanner />;
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
const msg = messages[index];
|
||||
@@ -90,9 +157,8 @@ export function Notifications() {
|
||||
function handleAction() {
|
||||
if (msg.type === 'message' && msg.chatId) {
|
||||
navigate(`/chat/${msg.chatId}`);
|
||||
} else if (msg.type === 'promo') {
|
||||
// внешний переход — в реальном приложении будет ссылка
|
||||
navigate(`/notification/${msg.id}`);
|
||||
} else if (msg.type === 'install') {
|
||||
handleInstall(msg.id);
|
||||
} else {
|
||||
navigate(`/notification/${msg.id}`);
|
||||
}
|
||||
@@ -100,13 +166,14 @@ export function Notifications() {
|
||||
|
||||
const actionLabel =
|
||||
msg.type === 'message' ? 'Открыть чат →' :
|
||||
msg.type === 'install' ? (installing ? 'Открываем...' : (installReady ? 'Установить →' : 'Как установить →')) :
|
||||
msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' :
|
||||
'Подробнее →';
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className={`rounded-xl border ${cfg.bg} overflow-hidden`}>
|
||||
{/* Верхняя строка: метка + навигация + закрыть */}
|
||||
{/* Верхняя строка */}
|
||||
<div className="flex items-center justify-between px-3 pt-2.5 pb-2">
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full ${cfg.badge}`}>
|
||||
{cfg.label}
|
||||
@@ -151,7 +218,6 @@ export function Notifications() {
|
||||
<p className="font-medium text-sm text-foreground leading-snug mb-0.5">{msg.title}</p>
|
||||
<p className="text-xs text-muted-foreground leading-snug">{msg.description}</p>
|
||||
|
||||
{/* Промо-цена */}
|
||||
{msg.type === 'promo' && msg.promo && (
|
||||
<div className="flex items-baseline gap-1.5 mt-1.5">
|
||||
<span className="text-base font-bold text-[#e65100]">{msg.promo.price}</span>
|
||||
@@ -163,20 +229,21 @@ export function Notifications() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Футер с действием */}
|
||||
{/* Футер */}
|
||||
<div className="border-t border-black/6 px-3 py-2 flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className={`text-xs font-semibold flex items-center gap-1 ${cfg.actionColor} hover:opacity-75 transition-opacity`}
|
||||
disabled={installing}
|
||||
className={`text-xs font-semibold flex items-center gap-1 transition-opacity disabled:opacity-50 ${cfg.actionColor} hover:opacity-75`}
|
||||
>
|
||||
{msg.type === 'install' && <Download className="w-3 h-3" />}
|
||||
{msg.type === 'message' && <MessageCircle className="w-3 h-3" />}
|
||||
{msg.type === 'promo' && <ArrowRight className="w-3 h-3" />}
|
||||
{actionLabel}
|
||||
</button>
|
||||
|
||||
{/* Для промо — дополнительно показываем дату */}
|
||||
{msg.type === 'promo' && (
|
||||
<span className="text-[11px] text-muted-foreground">{msg.date}</span>
|
||||
{msg.type === 'install' && !installReady && (
|
||||
<span className="text-[11px] text-muted-foreground">iOS: через Safari → «На экран»</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface CompanyMessage {
|
||||
id: string;
|
||||
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo';
|
||||
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo' | 'install';
|
||||
title: string;
|
||||
description: string;
|
||||
fullContent: string;
|
||||
@@ -80,6 +80,14 @@ export const companyMessages: CompanyMessage[] = [
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
type: 'install',
|
||||
title: 'Установите приложение',
|
||||
description: 'Быстрый доступ с экрана телефона без браузера',
|
||||
fullContent: 'Добавьте HAN на главный экран — приложение откроется мгновенно, будет работать офлайн и присылать важные напоминания о документах.',
|
||||
date: 'Сегодня',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
type: 'promo',
|
||||
title: 'Полное оформление ВНЖ под ключ',
|
||||
description: 'Юрист сам подаст документы — вам только расписаться',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export type AuthStatus = 'authenticated' | 'guest' | null;
|
||||
|
||||
const KEY = 'han_auth_status';
|
||||
|
||||
export function getAuthStatus(): AuthStatus {
|
||||
return (localStorage.getItem(KEY) as AuthStatus) ?? null;
|
||||
}
|
||||
|
||||
export function setAuthStatus(status: AuthStatus) {
|
||||
if (status === null) localStorage.removeItem(KEY);
|
||||
else localStorage.setItem(KEY, status);
|
||||
}
|
||||
|
||||
export function isGuest() {
|
||||
return getAuthStatus() === 'guest';
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
return getAuthStatus() === 'authenticated';
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
const consents: ConsentItem[] = [
|
||||
{
|
||||
id: 'pdp',
|
||||
required: true,
|
||||
text: 'Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных',
|
||||
links: [
|
||||
{ label: 'Согласие на обработку ПД', href: '#pdp-consent' },
|
||||
{ label: 'Политика обработки ПД', href: '#pdp-policy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'terms',
|
||||
required: true,
|
||||
text: 'Я прочитал и соглашаюсь с Пользовательским соглашением',
|
||||
links: [{ label: 'Пользовательское соглашение', href: '#terms' }],
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
required: false,
|
||||
text: 'Я даю своё согласие на получение рекламных и маркетинговых коммуникаций',
|
||||
links: [{ label: 'Условия получения коммуникаций', href: '#marketing' }],
|
||||
},
|
||||
];
|
||||
|
||||
function Checkbox({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
checked
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-border bg-background hover:border-primary/50'
|
||||
}`}
|
||||
aria-checked={checked}
|
||||
role="checkbox"
|
||||
>
|
||||
{checked && <Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthConsent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const returnTo = (location.state as { returnTo?: string })?.returnTo ?? '/auth/phone';
|
||||
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({
|
||||
pdp: false,
|
||||
terms: false,
|
||||
marketing: false,
|
||||
});
|
||||
|
||||
const toggle = (id: string) => setChecked(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
|
||||
const requiredDone = consents.filter(c => c.required).every(c => checked[c.id]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (requiredDone) navigate(returnTo);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col h-full px-6 pt-14 pb-8">
|
||||
|
||||
{/* Иконка + заголовок */}
|
||||
<div className="mb-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center mb-5">
|
||||
<ShieldCheck className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-foreground mb-2">
|
||||
Перед началом работы
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Для использования приложения ознакомьтесь со следующими документами и предоставьте необходимые согласия
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Форма согласий */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
|
||||
<div className="space-y-4 flex-1">
|
||||
{consents.map(consent => (
|
||||
<div
|
||||
key={consent.id}
|
||||
className={`rounded-xl border p-4 transition-colors cursor-pointer ${
|
||||
checked[consent.id]
|
||||
? 'border-primary/30 bg-primary/4'
|
||||
: 'border-border bg-card'
|
||||
}`}
|
||||
onClick={() => toggle(consent.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<Checkbox checked={checked[consent.id]} onChange={() => toggle(consent.id)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground leading-relaxed mb-2">
|
||||
{consent.text}
|
||||
{consent.required && (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="text-xs text-muted-foreground px-1">
|
||||
<span className="text-destructive">*</span> — обязательные согласия
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Кнопка */}
|
||||
<div className="pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!requiredDone}
|
||||
className="w-full h-14 bg-primary text-primary-foreground rounded-xl font-medium flex items-center justify-center gap-2.5 transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:bg-primary/90 active:scale-[0.98]"
|
||||
>
|
||||
Продолжить
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { setAuthStatus } from '../data/session';
|
||||
|
||||
export function AuthLoading() {
|
||||
const navigate = useNavigate();
|
||||
@@ -8,7 +9,7 @@ export function AuthLoading() {
|
||||
|
||||
useEffect(() => {
|
||||
// Имитируем авторизацию — через 2.8 секунды переходим на главную
|
||||
const t = setTimeout(() => navigate('/'), 2800);
|
||||
const t = setTimeout(() => { setAuthStatus('authenticated'); navigate('/'); }, 2800);
|
||||
return () => clearTimeout(t);
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
@@ -88,15 +88,6 @@ export function AuthPhone() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Низ страницы */}
|
||||
<div className="text-center">
|
||||
<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 } from 'lucide-react';
|
||||
import { Clock, ArrowLeft, Bell, AlertCircle, Calendar, MessageCircle, Tag, Smartphone } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getActiveMessages, CompanyMessage } from '../data/companyMessages';
|
||||
|
||||
@@ -7,6 +7,7 @@ function getIcon(type: CompanyMessage['type']) {
|
||||
if (type === 'reminder') return <Calendar className="w-4 h-4" />;
|
||||
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" />;
|
||||
return <Bell className="w-4 h-4" />;
|
||||
}
|
||||
|
||||
@@ -15,6 +16,7 @@ function typeColors(type: CompanyMessage['type']) {
|
||||
if (type === 'reminder') return { icon: 'bg-primary/10 text-primary', dot: 'bg-primary' };
|
||||
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]' };
|
||||
return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -21,6 +22,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'chat/:id', Component: Chat },
|
||||
{ path: 'calendar', Component: Calendar },
|
||||
{ path: 'notification/:id', Component: NotificationDetail },
|
||||
{ path: 'auth/consent', Component: AuthConsent },
|
||||
{ path: 'auth/phone', Component: AuthPhone },
|
||||
{ path: 'auth/otp', Component: AuthOtp },
|
||||
{ path: 'auth/loading', Component: AuthLoading },
|
||||
|
||||
Reference in New Issue
Block a user