Добавлены изменения для PWA

This commit is contained in:
mi
2026-07-21 18:49:21 +03:00
parent 627371b221
commit cc0163eb94
22 changed files with 592 additions and 208 deletions
@@ -13,7 +13,7 @@ ENV EXPO_PUBLIC_API_BASE_URL=$EXPO_PUBLIC_API_BASE_URL \
EXPO_PUBLIC_KEYCLOAK_REALM=$EXPO_PUBLIC_KEYCLOAK_REALM \ EXPO_PUBLIC_KEYCLOAK_REALM=$EXPO_PUBLIC_KEYCLOAK_REALM \
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \ EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \
EXPO_PUBLIC_APP_ENV=$EXPO_PUBLIC_APP_ENV EXPO_PUBLIC_APP_ENV=$EXPO_PUBLIC_APP_ENV
RUN npm run build RUN npm run build:pwa
# One-shot Compose init container copies the immutable export to nginx's volume. # One-shot Compose init container copies the immutable export to nginx's volume.
FROM alpine:3.22 AS static FROM alpine:3.22 AS static
+22 -2
View File
@@ -10,11 +10,31 @@ npm install
npm run web npm run web
``` ```
Проверки: `npm test`, `npm run typecheck`, `npm run build`, `npm run test:e2e`. Проверки: `npm test`, `npm run typecheck`, `npm run build:pwa`, `npm run test:e2e`.
## PWA
Иконки лежат в `public/`:
| Файл | Размер |
|---|---|
| `favicon.png` | 64×64 |
| `icon-192.png` | 360×360 |
| `icon-512.png` | 1024×1024 |
| `apple-touch-icon.png` | 360×360 |
Manifest: `public/manifest.json`. Корневой HTML: `app/+html.tsx`.
```bash
npm run build:pwa # export + service-worker.js (Workbox)
npm run serve # локальная проверка dist/
```
Chrome DevTools → Application → Manifest / Service Workers. API и `/auth/` не кэшируются.
## Production ## Production
`npm run build` создаёт `dist/`. Каталог монтируется в корневой nginx системы; отдельный frontend nginx не используется. Для SPA nginx должен применять `try_files $uri /index.html`, не кэшировать `index.html` и бессрочно кэшировать hashed assets. `npm run build:pwa` создаёт `dist/` с manifest, иконками и `service-worker.js`. Каталог монтируется в корневой nginx; отдельный frontend nginx не используется. Для SPA nginx должен применять `try_files $uri /index.html`, не кэшировать `index.html`, `manifest.json`, `service-worker.js` и бессрочно кэшировать hashed assets.
Dockerfile собирает статический OCI-артефакт `/dist` без runtime-сервера: Dockerfile собирает статический OCI-артефакт `/dist` без runtime-сервера:
@@ -9,7 +9,18 @@ const config: ExpoConfig = {
userInterfaceStyle: "light", userInterfaceStyle: "light",
experiments: { typedRoutes: true }, experiments: { typedRoutes: true },
plugins: ["expo-router", "expo-secure-store"], plugins: ["expo-router", "expo-secure-store"],
web: { bundler: "metro", output: "static" }, web: {
bundler: "metro",
output: "static",
shortName: "HAN Chat",
name: "HAN Chat",
description: "Помощник по документам и жизни в России",
display: "standalone",
orientation: "portrait",
backgroundColor: "#ffffff",
themeColor: "#030213",
favicon: "./public/favicon.png",
},
}; };
export default config; export default config;
@@ -0,0 +1,30 @@
import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react";
const serviceWorkerBootstrap = `
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js').catch(() => {});
});
}
`;
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#030213" />
<meta name="description" content="Помощник по документам и жизни в России" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
<link rel="apple-touch-icon" sizes="360x360" href="/apple-touch-icon.png" />
<script dangerouslySetInnerHTML={{ __html: serviceWorkerBootstrap }} />
<ScrollViewStyleReset />
</head>
<body>{children}</body>
</html>
);
}
@@ -11,6 +11,8 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"build": "expo export --platform web", "build": "expo export --platform web",
"build:pwa": "expo export --platform web && workbox generateSW workbox-config.js",
"icons:pwa": "powershell -ExecutionPolicy Bypass -File ./scripts/generate-pwa-icons.ps1",
"serve": "npx serve dist" "serve": "npx serve dist"
}, },
"dependencies": { "dependencies": {
@@ -44,6 +46,7 @@
"@vitejs/plugin-react": "6.0.3", "@vitejs/plugin-react": "6.0.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.1.4", "vite": "8.1.4",
"vitest": "4.1.10" "vitest": "4.1.10",
"workbox-cli": "7.4.0"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,31 @@
{
"short_name": "HAN Chat",
"name": "HAN Chat",
"description": "Помощник по документам и жизни в России",
"icons": [
{
"src": "/favicon.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "360x360",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "1024x1024",
"purpose": "any maskable"
}
],
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#030213",
"background_color": "#ffffff",
"lang": "ru"
}
@@ -0,0 +1,28 @@
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Drawing
function New-HanIcon([int]$size, [string]$path) {
$bmp = New-Object System.Drawing.Bitmap $size, $size
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
$g.Clear([System.Drawing.Color]::FromArgb(255, 3, 2, 19))
$fontSize = [math]::Max(8, [math]::Floor($size * 0.22))
$font = New-Object System.Drawing.Font("Segoe UI", $fontSize, [System.Drawing.FontStyle]::Bold)
$brush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::White)
$format = New-Object System.Drawing.StringFormat
$format.Alignment = "Center"
$format.LineAlignment = "Center"
$rect = New-Object System.Drawing.RectangleF 0, 0, $size, $size
$g.DrawString("HAN", $font, $brush, $rect, $format)
$g.Dispose()
$bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
}
$publicDir = Join-Path $PSScriptRoot ".." "public"
New-Item -ItemType Directory -Force -Path $publicDir | Out-Null
New-HanIcon 512 (Join-Path $publicDir "icon-512.png")
New-HanIcon 192 (Join-Path $publicDir "icon-192.png")
New-HanIcon 180 (Join-Path $publicDir "apple-touch-icon.png")
New-HanIcon 32 (Join-Path $publicDir "favicon.png")
@@ -0,0 +1,9 @@
/** @type {import('workbox-build').GenerateSWOptions} */
module.exports = {
globDirectory: "dist",
globPatterns: ["**/*.{js,css,html,ico,png,json,woff2,svg,webp}"],
globIgnores: ["**/node_modules/**"],
swDest: "dist/service-worker.js",
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api/, /^\/auth/],
};
@@ -9,6 +9,11 @@ location = /index.html {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
include /etc/nginx/generated/security-headers.conf; include /etc/nginx/generated/security-headers.conf;
} }
location = /manifest.json {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache";
include /etc/nginx/generated/security-headers.conf;
}
location = /service-worker.js { location = /service-worker.js {
root /usr/share/nginx/html; root /usr/share/nginx/html;
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
+7 -1
View File
@@ -17,13 +17,19 @@ rm han-chat-backend.tar.gz
cd /opt/han-chat/backend cd /opt/han-chat/backend
rm C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz rm C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz
tar -C HAN_chat_specification/codebase/backend -czf han-chat-backend.tar.gz . #команда складывает архив в ту папку, из которой запускается команда
cd C:\Users\MI\Documents\Assistent\
tar -C C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase\backend -czf han-chat-backend.tar.gz .
scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz root@135.106.164.58:/tmp/han-chat-backend.tar.gz scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz root@135.106.164.58:/tmp/han-chat-backend.tar.gz
tar -xzf /tmp/han-chat-backend.tar.gz tar -xzf /tmp/han-chat-backend.tar.gz
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} + find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh
docker compose --env-file .env build frontend-static keycloak
docker compose --env-file .env up -d \
--no-deps \
--force-recreate frontend-static keycloak
# Разворачиваем инфраструктуру в Селектел ч1 # Разворачиваем инфраструктуру в Селектел ч1
Binary file not shown.
+6 -6
View File
@@ -1,13 +1,11 @@
export function HanLogo() { export function HanLogo() {
return ( return (
<div className="flex items-center gap-3 px-4 py-3"> <div className="flex items-center gap-3 px-4 py-3">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-primary text-primary-foreground flex-shrink-0"> <div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-primary text-primary-foreground flex-shrink-0">
<svg <svg viewBox="0 0 100 100" className="w-7 h-7" fill="currentColor">
viewBox="0 0 100 100" <path
className="w-7 h-7" d="M20 25 L20 75 M20 50 L50 50 M50 25 L50 75 M70 25 L90 25 L90 50 L70 50 L70 75 L90 75"
fill="currentColor"
>
<path d="M20 25 L20 75 M20 50 L50 50 M50 25 L50 75 M70 25 L90 25 L90 50 L70 50 L70 75 L90 75"
stroke="currentColor" stroke="currentColor"
strokeWidth="6" strokeWidth="6"
fill="none" fill="none"
@@ -16,12 +14,14 @@ export function HanLogo() {
/> />
</svg> </svg>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h1 className="text-base font-medium mb-0.5">Привет! Я HAN</h1> <h1 className="text-base font-medium mb-0.5">Привет! Я HAN</h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Помощник по документам и жизни в России Помощник по документам и жизни в России
</p> </p>
</div> </div>
</div> </div>
); );
} }
+14 -5
View File
@@ -1,17 +1,26 @@
import { User, History } from 'lucide-react'; import { User, Bell } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { getActiveMessages } from '../data/companyMessages';
export function Header() { export function Header() {
const count = getActiveMessages().length;
return ( return (
<header className="flex items-center justify-between px-4 py-3 bg-background border-b border-border"> <header className="flex items-center justify-between px-4 py-3 bg-background border-b border-border">
<Link to="/history" className="flex items-center gap-2 text-foreground hover:opacity-70 transition-opacity"> {/* Колокольчик с бейджем */}
<History className="w-5 h-5" /> <Link to="/history" className="relative flex items-center justify-center w-10 h-10 rounded-full hover:bg-muted transition-colors">
<span className="text-sm">История</span> <Bell className="w-5 h-5" />
{count > 0 && (
<span className="absolute top-0.5 right-0.5 min-w-[16px] h-4 px-1 flex items-center justify-center bg-destructive text-white text-[10px] font-bold rounded-full leading-none">
{count}
</span>
)}
</Link> </Link>
{/* Профиль */}
<Link to="/profile" className="relative flex items-center justify-center w-10 h-10 rounded-full bg-muted hover:opacity-70 transition-opacity"> <Link to="/profile" className="relative flex items-center justify-center w-10 h-10 rounded-full bg-muted hover:opacity-70 transition-opacity">
<User className="w-5 h-5 text-muted-foreground" /> <User className="w-5 h-5 text-muted-foreground" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-primary rounded-full border-2 border-background"></span> <span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-primary rounded-full border-2 border-background" />
</Link> </Link>
</header> </header>
); );
+167 -43
View File
@@ -1,61 +1,185 @@
import { Calendar, FileText } from 'lucide-react'; import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import {
Calendar, AlertCircle, Bell, MessageCircle, Tag,
X, ChevronLeft, ChevronRight, ArrowRight,
} from 'lucide-react';
import { getActiveMessages, dismissMessage, CompanyMessage } from '../data/companyMessages';
interface Notification { // ─── Тип-конфиг ──────────────────────────────────────────────────────────────
id: string;
type: 'urgent' | 'reminder'; type TypeConfig = {
label: string;
icon: React.ReactNode; icon: React.ReactNode;
title: string; bg: string;
description: string; iconWrap: string;
action: string; badge: string;
actionColor: string;
};
function getConfig(type: CompanyMessage['type']): TypeConfig {
switch (type) {
case 'urgent':
return {
label: 'Срочно',
icon: <AlertCircle className="w-4 h-4" />,
bg: 'bg-destructive/8 border-destructive/25',
iconWrap: 'bg-destructive/12 text-destructive',
badge: 'bg-destructive/12 text-destructive',
actionColor: 'text-destructive',
};
case 'reminder':
return {
label: 'Напоминание',
icon: <Calendar 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 'message':
return {
label: 'Сообщение',
icon: <MessageCircle className="w-4 h-4" />,
bg: 'bg-[#e8f5e9] border-[#a5d6a7]',
iconWrap: 'bg-[#c8e6c9] text-[#2e7d32]',
badge: 'bg-[#c8e6c9] text-[#2e7d32]',
actionColor: 'text-[#2e7d32]',
};
case 'promo':
return {
label: 'Предложение',
icon: <Tag className="w-4 h-4" />,
bg: 'bg-[#fff8e1] border-[#ffe082]',
iconWrap: 'bg-[#fff3cd] text-[#e65100]',
badge: 'bg-[#fff3cd] text-[#e65100]',
actionColor: 'text-[#e65100]',
};
default:
return {
label: 'Новость',
icon: <Bell className="w-4 h-4" />,
bg: 'bg-muted/60 border-border',
iconWrap: 'bg-muted text-muted-foreground',
badge: 'bg-muted text-muted-foreground',
actionColor: 'text-foreground/70',
};
}
} }
const notifications: Notification[] = [ // ─── Основной компонент ───────────────────────────────────────────────────────
{
id: '1',
type: 'urgent',
icon: <Calendar className="w-4 h-4" />,
title: 'Продление патента через 14 дней',
description: 'Не забудьте подать документы заранее',
action: 'Подробнее'
},
{
id: '2',
type: 'reminder',
icon: <FileText className="w-4 h-4" />,
title: 'Проверьте статус РВП',
description: 'Возможно, уже готово к получению',
action: 'Проверить'
}
];
export function Notifications() { export function Notifications() {
const navigate = useNavigate(); const navigate = useNavigate();
const [messages, setMessages] = useState(() => getActiveMessages());
const [index, setIndex] = useState(0);
if (notifications.length === 0) return null; const handleDismiss = useCallback((id: string) => {
dismissMessage(id);
const next = getActiveMessages();
setMessages(next);
setIndex(i => Math.min(i, Math.max(next.length - 1, 0)));
}, []);
if (messages.length === 0) return null;
const msg = messages[index];
const cfg = getConfig(msg.type);
const hasMultiple = messages.length > 1;
function handleAction() {
if (msg.type === 'message' && msg.chatId) {
navigate(`/chat/${msg.chatId}`);
} else if (msg.type === 'promo') {
// внешний переход — в реальном приложении будет ссылка
navigate(`/notification/${msg.id}`);
} else {
navigate(`/notification/${msg.id}`);
}
}
const actionLabel =
msg.type === 'message' ? 'Открыть чат →' :
msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' :
'Подробнее →';
return ( return (
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3">
{notifications.map((notification) => ( <div className={`rounded-xl border ${cfg.bg} overflow-hidden`}>
<div {/* Верхняя строка: метка + навигация + закрыть */}
key={notification.id} <div className="flex items-center justify-between px-3 pt-2.5 pb-2">
className="bg-primary/5 border border-primary/20 rounded-lg p-3 flex items-start gap-2.5" <span className={`text-[11px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full ${cfg.badge}`}>
> {cfg.label}
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary flex-shrink-0 mt-0.5"> </span>
{notification.icon}
<div className="flex items-center gap-0.5">
{hasMultiple && (
<>
<button
onClick={() => setIndex(i => (i - 1 + messages.length) % messages.length)}
className="w-6 h-6 flex items-center justify-center rounded-md hover:bg-black/8 transition-colors"
>
<ChevronLeft className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<span className="text-[11px] text-muted-foreground tabular-nums min-w-[28px] text-center">
{index + 1}/{messages.length}
</span>
<button
onClick={() => setIndex(i => (i + 1) % messages.length)}
className="w-6 h-6 flex items-center justify-center rounded-md hover:bg-black/8 transition-colors"
>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</>
)}
<button
onClick={() => handleDismiss(msg.id)}
className="w-6 h-6 flex items-center justify-center rounded-md hover:bg-black/8 transition-colors ml-1"
aria-label="Скрыть"
>
<X className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div>
{/* Тело */}
<div className="flex items-start gap-3 px-3 pb-3">
<div className={`flex items-center justify-center w-8 h-8 rounded-full flex-shrink-0 ${cfg.iconWrap}`}>
{cfg.icon}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-medium text-sm mb-0.5">{notification.title}</h3> <p className="font-medium text-sm text-foreground leading-snug mb-0.5">{msg.title}</p>
<p className="text-xs text-muted-foreground leading-tight">{notification.description}</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>
{msg.promo.originalPrice && (
<span className="text-xs text-muted-foreground line-through">{msg.promo.originalPrice}</span>
)}
</div>
)}
</div> </div>
<button
onClick={() => navigate(`/notification/${notification.id}`)}
className="text-xs text-primary font-medium flex-shrink-0 mt-1 hover:underline"
>
{notification.action}
</button>
</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`}
>
{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>
)}
</div>
</div>
</div> </div>
); );
} }
+15 -4
View File
@@ -1,11 +1,22 @@
import { Headphones } from 'lucide-react'; import { Headphones, MessageCircle } from 'lucide-react';
import { useNavigate } from 'react-router';
export function QuickActions() { export function QuickActions() {
const navigate = useNavigate();
return ( return (
<div className="px-4 pb-4"> <div className="px-4 pb-4 flex gap-2">
<button className="w-full flex items-center justify-center gap-2 py-2.5 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"> <button
onClick={() => navigate('/chat/1')}
className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<MessageCircle className="w-4 h-4" />
<span className="text-sm font-medium">Чат</span>
</button>
<button className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors">
<Headphones className="w-4 h-4" /> <Headphones className="w-4 h-4" />
<span className="text-sm">Связь с оператором</span> <span className="text-sm">Оператор</span>
</button> </button>
</div> </div>
); );
+121
View File
@@ -0,0 +1,121 @@
export interface CompanyMessage {
id: string;
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo';
title: string;
description: string;
fullContent: string;
deadline?: string;
steps?: string[];
date: string;
// Для type === 'message' — ID чата куда перейти
chatId?: string;
// Для type === 'promo'
promo?: {
price: string;
originalPrice?: string;
cta: string;
};
}
export const companyMessages: CompanyMessage[] = [
{
id: '1',
type: 'urgent',
title: 'Продление патента через 14 дней',
description: 'Не забудьте подать документы заранее',
fullContent: 'Ваш патент на работу истекает 19 июня 2026 года. Рекомендуем начать процесс продления заранее, чтобы избежать перерыва в легальном статусе.',
deadline: '19 июня 2026',
steps: [
'Подготовьте пакет документов (паспорт, миграционная карта, текущий патент)',
'Оплатите госпошлину и НДФЛ',
'Подайте документы в МФЦ или МВД',
'Получите новый патент в течение 10 рабочих дней',
],
date: 'Сегодня, 09:00',
},
{
id: '2',
type: 'reminder',
title: 'Проверьте статус РВП',
description: 'Возможно, уже готово к получению',
fullContent: 'Прошло более 6 месяцев с момента подачи документов на РВП. Рекомендуем проверить готовность документа на сайте МВД или обратиться в отделение лично.',
steps: [
'Зайдите на сайт гувм.мвд.рф',
'Проверьте статус по номеру заявления',
'При готовности запишитесь на получение',
'Подготовьте документы для получения РВП',
],
date: 'Вчера, 11:30',
},
{
id: '3',
type: 'info',
title: 'Новые услуги в приложении',
description: 'Теперь можно загружать документы прямо в чат',
fullContent: 'Мы обновили приложение: теперь вы можете прикладывать фотографии документов прямо в диалог с консультантом. HAN проверит данные и подскажет, всё ли в порядке.',
date: '20 мая, 15:00',
},
{
id: '4',
type: 'message',
title: 'Ваш консультант ответил',
description: 'Готов пакет документов для подачи на ВНЖ',
fullContent: 'Консультант подготовил полный список документов для подачи на вид на жительство. Откройте чат, чтобы скачать список и задать вопросы.',
date: 'Сегодня, 12:14',
chatId: '2',
},
{
id: '5',
type: 'urgent',
title: 'Истекает регистрация — 3 дня',
description: 'Продлите регистрацию, чтобы избежать штрафа',
fullContent: 'Срок вашей регистрации по месту пребывания истекает 24 июля 2026 года. Нарушение сроков регистрации влечёт административный штраф до 5 000 ₽ и риск аннулирования патента.',
deadline: '24 июля 2026',
steps: [
'Обратитесь к собственнику жилья для продления уведомления',
'Подайте уведомление в МФЦ или МВД',
'Получите отметку о регистрации в течение 1 рабочего дня',
],
date: 'Сегодня, 08:15',
},
{
id: '7',
type: 'promo',
title: 'Полное оформление ВНЖ под ключ',
description: 'Юрист сам подаст документы — вам только расписаться',
fullContent: 'Наш партнёр «МиграЛекс» берёт на себя весь процесс: сбор документов, перевод, нотариус, подача в МВД и отслеживание статуса. Вы приходите только на получение.',
date: 'Вчера, 10:00',
promo: {
price: '8 900 ₽',
originalPrice: '14 000 ₽',
cta: 'Узнать подробнее',
},
},
];
const DISMISSED_KEY = 'han_dismissed_notifications';
export function getDismissedIds(): string[] {
try {
return JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? '[]');
} catch {
return [];
}
}
export function dismissMessage(id: string) {
const current = getDismissedIds();
if (!current.includes(id)) {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...current, id]));
}
}
export function getActiveMessages(): CompanyMessage[] {
const dismissed = getDismissedIds();
return companyMessages.filter(m => !dismissed.includes(m.id));
}
export function getAllMessages(): (CompanyMessage & { dismissed: boolean })[] {
const dismissed = getDismissedIds();
return companyMessages.map(m => ({ ...m, dismissed: dismissed.includes(m.id) }));
}
+70 -66
View File
@@ -1,52 +1,33 @@
import { MessageSquare, Clock, ArrowLeft } from 'lucide-react'; import { Clock, ArrowLeft, Bell, AlertCircle, Calendar, MessageCircle, Tag } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { getActiveMessages, CompanyMessage } from '../data/companyMessages';
interface ChatSession { function getIcon(type: CompanyMessage['type']) {
id: string; if (type === 'urgent') return <AlertCircle className="w-4 h-4" />;
title: string; if (type === 'reminder') return <Calendar className="w-4 h-4" />;
lastMessage: string; if (type === 'message') return <MessageCircle className="w-4 h-4" />;
timestamp: string; if (type === 'promo') return <Tag className="w-4 h-4" />;
unread?: boolean; return <Bell className="w-4 h-4" />;
} }
const chatHistory: ChatSession[] = [ function typeColors(type: CompanyMessage['type']) {
{ if (type === 'urgent') return { icon: 'bg-destructive/12 text-destructive', dot: 'bg-destructive' };
id: '1', if (type === 'reminder') return { icon: 'bg-primary/10 text-primary', dot: 'bg-primary' };
title: 'Продление патента', if (type === 'message') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
lastMessage: 'Спасибо за помощь! Я понял что нужно делать.', if (type === 'promo') return { icon: 'bg-[#fff3cd] text-[#e65100]', dot: 'bg-[#fb8c00]' };
timestamp: 'Сегодня, 14:30', return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' };
unread: false }
},
{
id: '2',
title: 'Документы для РВП',
lastMessage: 'Какие документы нужны для подачи на РВП?',
timestamp: 'Вчера, 18:45',
unread: false
},
{
id: '3',
title: 'Регистрация по месту жительства',
lastMessage: 'HAN: Вам нужно обратиться в МФЦ с паспортом и...',
timestamp: '20 мая, 10:15',
unread: false
},
{
id: '4',
title: 'Оплата патента',
lastMessage: 'Где можно оплатить патент?',
timestamp: '18 мая, 16:20',
unread: false
},
];
export function History() { export function History() {
const navigate = useNavigate(); const navigate = useNavigate();
const messages = getActiveMessages();
return ( return (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-4 py-4"> <div className="px-4 py-4 space-y-4">
<div className="flex items-center gap-3 mb-4">
{/* Заголовок страницы */}
<div className="flex items-center gap-3">
<button <button
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="flex items-center justify-center w-9 h-9 rounded-full hover:bg-muted transition-colors" className="flex items-center justify-center w-9 h-9 rounded-full hover:bg-muted transition-colors"
@@ -54,35 +35,58 @@ export function History() {
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
</button> </button>
<div> <h1 className="flex-1 text-xl font-medium">Центр уведомлений</h1>
<h1 className="text-xl font-medium">История общения</h1> {messages.length > 0 && (
<p className="text-sm text-muted-foreground">Все ваши диалоги с HAN</p> <span className="text-xs font-medium text-primary bg-primary/10 rounded-full px-2.5 py-1">
</div> {messages.length}
</span>
)}
</div> </div>
<div className="space-y-2"> {/* Список уведомлений */}
{chatHistory.map((chat) => ( {messages.length > 0 ? (
<button <div className="space-y-2">
key={chat.id} {messages.map(msg => {
onClick={() => navigate(`/chat/${chat.id}`)} const colors = typeColors(msg.type);
className="w-full bg-card border border-border rounded-lg p-4 flex items-start gap-3 hover:bg-accent/50 transition-colors text-left" return (
> <button
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10 text-primary flex-shrink-0"> key={msg.id}
<MessageSquare className="w-5 h-5" /> onClick={() => msg.type === 'message' && msg.chatId
</div> ? navigate(`/chat/${msg.chatId}`)
<div className="flex-1 min-w-0"> : navigate(`/notification/${msg.id}`)
<h3 className="font-medium text-sm mb-1">{chat.title}</h3> }
<p className="text-xs text-muted-foreground line-clamp-1 mb-2"> className="w-full bg-card border border-border rounded-xl p-3.5 flex items-start gap-3 hover:bg-accent/50 transition-colors text-left"
{chat.lastMessage} >
</p> <div className={`relative flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0 ${colors.icon}`}>
<div className="flex items-center gap-1 text-xs text-muted-foreground"> {getIcon(msg.type)}
<Clock className="w-3 h-3" /> <span className={`absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background ${colors.dot}`} />
<span>{chat.timestamp}</span> </div>
</div> <div className="flex-1 min-w-0">
</div> <p className="text-sm font-medium text-foreground leading-snug line-clamp-1 mb-0.5">
</button> {msg.title}
))} </p>
</div> <p className="text-xs text-muted-foreground line-clamp-1 mb-1.5">
{msg.description}
</p>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Clock className="w-3 h-3" />
<span>{msg.date}</span>
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
<Bell className="w-5 h-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground mb-1">Всё актуально</p>
<p className="text-xs text-muted-foreground">Новых уведомлений нет</p>
</div>
)}
</div> </div>
</div> </div>
); );
+50 -78
View File
@@ -1,89 +1,70 @@
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { ArrowLeft, Calendar, FileText, CheckCircle, EyeOff, Clock } from 'lucide-react'; import { ArrowLeft, Calendar, AlertCircle, Bell, CheckCircle, EyeOff, Clock } from 'lucide-react';
import { companyMessages, dismissMessage } from '../data/companyMessages';
interface NotificationData { function getIcon(type: 'urgent' | 'reminder' | 'info', className = 'w-5 h-5') {
id: string; if (type === 'urgent') return <AlertCircle className={className} />;
type: 'urgent' | 'reminder'; if (type === 'reminder') return <Calendar className={className} />;
icon: React.ReactNode; return <Bell className={className} />;
title: string;
description: string;
fullContent: string;
deadline?: string;
steps?: string[];
} }
const notificationDetails: Record<string, NotificationData> = { function typeStyle(type: 'urgent' | 'reminder' | 'info') {
'1': { if (type === 'urgent') return {
id: '1', wrapper: 'bg-destructive/10 text-destructive',
type: 'urgent', deadline: 'bg-destructive/6 border-destructive/20',
icon: <Calendar className="w-6 h-6" />, deadlineText: 'text-destructive',
title: 'Продление патента через 14 дней', step: 'bg-destructive/10 text-destructive',
description: 'Не забудьте подать документы заранее', };
fullContent: 'Ваш патент на работу истекает 19 июня 2026 года. Рекомендуем начать процесс продления заранее, чтобы избежать перерыва в легальном статусе.', if (type === 'reminder') return {
deadline: '19 июня 2026', wrapper: 'bg-primary/10 text-primary',
steps: [ deadline: 'bg-primary/5 border-primary/20',
'Подготовьте пакет документов (паспорт, миграционная карта, текущий патент)', deadlineText: 'text-primary',
'Оплатите госпошлину и НДФЛ', step: 'bg-primary/10 text-primary',
'Подайте документы в МФЦ или МВД', };
'Получите новый патент в течение 10 рабочих дней' return {
] wrapper: 'bg-muted text-muted-foreground',
}, deadline: 'bg-muted border-border',
'2': { deadlineText: 'text-foreground',
id: '2', step: 'bg-muted text-muted-foreground',
type: 'reminder', };
icon: <FileText className="w-6 h-6" />, }
title: 'Проверьте статус РВП',
description: 'Возможно, уже готово к получению',
fullContent: 'Прошло более 6 месяцев с момента подачи документов на РВП. Рекомендуем проверить готовность документа на сайте МВД или обратиться в отделение лично.',
steps: [
'Зайдите на сайт гувм.мвд.рф',
'Проверьте статус по номеру заявления',
'При готовности запишитесь на получение',
'Подготовьте документы для получения РВП'
]
}
};
export function NotificationDetail() { export function NotificationDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const notification = id ? notificationDetails[id] : null; const notification = id ? companyMessages.find(m => m.id === id) : null;
if (!notification) { if (!notification) {
return ( return (
<div className="flex flex-col items-center justify-center h-full p-4"> <div className="flex flex-col items-center justify-center h-full p-4">
<p className="text-muted-foreground">Уведомление не найдено</p> <p className="text-muted-foreground">Уведомление не найдено</p>
<button <button onClick={() => navigate('/')} className="mt-4 text-primary font-medium">
onClick={() => navigate('/')}
className="mt-4 text-primary font-medium"
>
Вернуться на главную Вернуться на главную
</button> </button>
</div> </div>
); );
} }
const style = typeStyle(notification.type);
const handleComplete = () => { const handleComplete = () => {
// В реальном приложении здесь будет логика отметки уведомления как выполненного dismissMessage(notification.id);
navigate('/'); navigate('/');
}; };
const handleHide = () => { const handleHide = () => {
// В реальном приложении здесь будет логика скрытия уведомления dismissMessage(notification.id);
navigate('/'); navigate('/');
}; };
const handleLater = () => { const handleLater = () => navigate('/');
// В реальном приложении здесь будет логика напоминания позже
navigate('/');
};
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Хедер с кнопкой назад */} {/* Хедер */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/40"> <div className="flex items-center gap-3 px-4 py-3 border-b border-border/40">
<button <button
onClick={() => navigate('/')} onClick={() => navigate(-1)}
className="p-1.5 -ml-1.5 hover:bg-muted/50 rounded-lg transition-colors" className="p-1.5 -ml-1.5 hover:bg-muted/50 rounded-lg transition-colors"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
@@ -96,12 +77,8 @@ export function NotificationDetail() {
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{/* Иконка и заголовок */} {/* Иконка и заголовок */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`flex items-center justify-center w-12 h-12 rounded-full flex-shrink-0 ${ <div className={`flex items-center justify-center w-12 h-12 rounded-full flex-shrink-0 ${style.wrapper}`}>
notification.type === 'urgent' {getIcon(notification.type, 'w-6 h-6')}
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{notification.icon}
</div> </div>
<div className="flex-1 min-w-0 pt-1"> <div className="flex-1 min-w-0 pt-1">
<h2 className="font-semibold text-base mb-1">{notification.title}</h2> <h2 className="font-semibold text-base mb-1">{notification.title}</h2>
@@ -109,13 +86,13 @@ export function NotificationDetail() {
</div> </div>
</div> </div>
{/* Срок, если есть */} {/* Дедлайн */}
{notification.deadline && ( {notification.deadline && (
<div className="bg-primary/5 border border-primary/20 rounded-lg p-3"> <div className={`border rounded-lg p-3 ${style.deadline}`}>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-primary" /> <Calendar className={`w-4 h-4 ${style.deadlineText}`} />
<span className="font-medium">Срок:</span> <span className="font-medium">Срок:</span>
<span className="text-primary font-semibold">{notification.deadline}</span> <span className={`font-semibold ${style.deadlineText}`}>{notification.deadline}</span>
</div> </div>
</div> </div>
)} )}
@@ -123,24 +100,20 @@ export function NotificationDetail() {
{/* Полное описание */} {/* Полное описание */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-medium text-sm">Подробности</h3> <h3 className="font-medium text-sm">Подробности</h3>
<p className="text-sm text-foreground/90 leading-relaxed"> <p className="text-sm text-foreground/90 leading-relaxed">{notification.fullContent}</p>
{notification.fullContent}
</p>
</div> </div>
{/* Шаги к действию */} {/* Шаги */}
{notification.steps && notification.steps.length > 0 && ( {notification.steps && notification.steps.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-medium text-sm">Что нужно сделать</h3> <h3 className="font-medium text-sm">Что нужно сделать</h3>
<div className="space-y-2"> <div className="space-y-2">
{notification.steps.map((step, index) => ( {notification.steps.map((step, i) => (
<div key={index} className="flex gap-2.5"> <div key={i} className="flex gap-2.5">
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium flex-shrink-0 mt-0.5"> <div className={`flex items-center justify-center w-5 h-5 rounded-full text-xs font-medium flex-shrink-0 mt-0.5 ${style.step}`}>
{index + 1} {i + 1}
</div> </div>
<p className="text-sm text-foreground/90 leading-relaxed flex-1"> <p className="text-sm text-foreground/90 leading-relaxed flex-1">{step}</p>
{step}
</p>
</div> </div>
))} ))}
</div> </div>
@@ -149,7 +122,7 @@ export function NotificationDetail() {
</div> </div>
</div> </div>
{/* Кнопки управления */} {/* Кнопки */}
<div className="border-t border-border/40 p-4 space-y-2 bg-background"> <div className="border-t border-border/40 p-4 space-y-2 bg-background">
<button <button
onClick={handleComplete} onClick={handleComplete}
@@ -167,13 +140,12 @@ export function NotificationDetail() {
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
Сделаю позже Сделаю позже
</button> </button>
<button <button
onClick={handleHide} 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" 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" /> <EyeOff className="w-4 h-4" />
Скрыть уведомление Скрыть
</button> </button>
</div> </div>
</div> </div>