diff --git a/codebase/backend/frontend-test-site/Dockerfile b/codebase/backend/frontend-test-site/Dockerfile
index bd24e0f..80574b9 100644
--- a/codebase/backend/frontend-test-site/Dockerfile
+++ b/codebase/backend/frontend-test-site/Dockerfile
@@ -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_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \
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.
FROM alpine:3.22 AS static
diff --git a/codebase/backend/frontend-test-site/README.md b/codebase/backend/frontend-test-site/README.md
index 147eed4..9a66248 100644
--- a/codebase/backend/frontend-test-site/README.md
+++ b/codebase/backend/frontend-test-site/README.md
@@ -10,11 +10,31 @@ npm install
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
-`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-сервера:
diff --git a/codebase/backend/frontend-test-site/app.config.ts b/codebase/backend/frontend-test-site/app.config.ts
index 900b6bc..22a0ddf 100644
--- a/codebase/backend/frontend-test-site/app.config.ts
+++ b/codebase/backend/frontend-test-site/app.config.ts
@@ -9,7 +9,18 @@ const config: ExpoConfig = {
userInterfaceStyle: "light",
experiments: { typedRoutes: true },
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;
diff --git a/codebase/backend/frontend-test-site/app/+html.tsx b/codebase/backend/frontend-test-site/app/+html.tsx
new file mode 100644
index 0000000..c35b697
--- /dev/null
+++ b/codebase/backend/frontend-test-site/app/+html.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/codebase/backend/frontend-test-site/package.json b/codebase/backend/frontend-test-site/package.json
index 520c70c..310c990 100644
--- a/codebase/backend/frontend-test-site/package.json
+++ b/codebase/backend/frontend-test-site/package.json
@@ -11,6 +11,8 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"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"
},
"dependencies": {
@@ -44,6 +46,7 @@
"@vitejs/plugin-react": "6.0.3",
"typescript": "7.0.2",
"vite": "8.1.4",
- "vitest": "4.1.10"
+ "vitest": "4.1.10",
+ "workbox-cli": "7.4.0"
}
}
diff --git a/codebase/backend/frontend-test-site/public/apple-touch-icon.png b/codebase/backend/frontend-test-site/public/apple-touch-icon.png
new file mode 100644
index 0000000..118fbe0
Binary files /dev/null and b/codebase/backend/frontend-test-site/public/apple-touch-icon.png differ
diff --git a/codebase/backend/frontend-test-site/public/favicon.png b/codebase/backend/frontend-test-site/public/favicon.png
new file mode 100644
index 0000000..cb67bb6
Binary files /dev/null and b/codebase/backend/frontend-test-site/public/favicon.png differ
diff --git a/codebase/backend/frontend-test-site/public/icon-192.png b/codebase/backend/frontend-test-site/public/icon-192.png
new file mode 100644
index 0000000..e27bcc6
Binary files /dev/null and b/codebase/backend/frontend-test-site/public/icon-192.png differ
diff --git a/codebase/backend/frontend-test-site/public/icon-512.png b/codebase/backend/frontend-test-site/public/icon-512.png
new file mode 100644
index 0000000..3e76b53
Binary files /dev/null and b/codebase/backend/frontend-test-site/public/icon-512.png differ
diff --git a/codebase/backend/frontend-test-site/public/manifest.json b/codebase/backend/frontend-test-site/public/manifest.json
new file mode 100644
index 0000000..5ac94ea
--- /dev/null
+++ b/codebase/backend/frontend-test-site/public/manifest.json
@@ -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"
+}
diff --git a/codebase/backend/frontend-test-site/scripts/generate-pwa-icons.ps1 b/codebase/backend/frontend-test-site/scripts/generate-pwa-icons.ps1
new file mode 100644
index 0000000..2fca02e
--- /dev/null
+++ b/codebase/backend/frontend-test-site/scripts/generate-pwa-icons.ps1
@@ -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")
diff --git a/codebase/backend/frontend-test-site/workbox-config.js b/codebase/backend/frontend-test-site/workbox-config.js
new file mode 100644
index 0000000..676f0fb
--- /dev/null
+++ b/codebase/backend/frontend-test-site/workbox-config.js
@@ -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/],
+};
diff --git a/codebase/backend/nginx/templates/frontend-static.conf.template b/codebase/backend/nginx/templates/frontend-static.conf.template
index 2fae6e9..ccc8aa3 100644
--- a/codebase/backend/nginx/templates/frontend-static.conf.template
+++ b/codebase/backend/nginx/templates/frontend-static.conf.template
@@ -9,6 +9,11 @@ location = /index.html {
add_header Cache-Control "no-cache";
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 {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache";
diff --git a/deploy-steps.md b/deploy-steps.md
index 480a81e..e6e7e55 100644
--- a/deploy-steps.md
+++ b/deploy-steps.md
@@ -17,13 +17,19 @@ rm han-chat-backend.tar.gz
cd /opt/han-chat/backend
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
tar -xzf /tmp/han-chat-backend.tar.gz
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
+docker compose --env-file .env build frontend-static keycloak
+docker compose --env-file .env up -d \
+ --no-deps \
+ --force-recreate frontend-static keycloak
# Разворачиваем инфраструктуру в Селектел ч1
diff --git a/figma/Main page specification.zip b/figma/Main page specification.zip
new file mode 100644
index 0000000..3ba6bd9
Binary files /dev/null and b/figma/Main page specification.zip differ
diff --git a/figma/src/app/components/HanLogo.tsx b/figma/src/app/components/HanLogo.tsx
index 6836fbc..aade3cc 100644
--- a/figma/src/app/components/HanLogo.tsx
+++ b/figma/src/app/components/HanLogo.tsx
@@ -1,13 +1,11 @@
+
export function HanLogo() {
return (
+
Привет! Я HAN
Помощник по документам и жизни в России
+
);
}
diff --git a/figma/src/app/components/Header.tsx b/figma/src/app/components/Header.tsx
index 2f91b1e..f5e9943 100644
--- a/figma/src/app/components/Header.tsx
+++ b/figma/src/app/components/Header.tsx
@@ -1,17 +1,26 @@
-import { User, History } from 'lucide-react';
+import { User, Bell } from 'lucide-react';
import { Link } from 'react-router';
+import { getActiveMessages } from '../data/companyMessages';
export function Header() {
+ const count = getActiveMessages().length;
+
return (
-
-
- История
+ {/* Колокольчик с бейджем */}
+
+
+ {count > 0 && (
+
+ {count}
+
+ )}
+ {/* Профиль */}
-
+
);
diff --git a/figma/src/app/components/Notifications.tsx b/figma/src/app/components/Notifications.tsx
index 7ba4d12..1f36da0 100644
--- a/figma/src/app/components/Notifications.tsx
+++ b/figma/src/app/components/Notifications.tsx
@@ -1,61 +1,185 @@
-import { Calendar, FileText } from 'lucide-react';
+import { useState, useCallback } from 'react';
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;
- title: string;
- description: string;
- action: string;
+ bg: string;
+ iconWrap: string;
+ badge: string;
+ actionColor: string;
+};
+
+function getConfig(type: CompanyMessage['type']): TypeConfig {
+ switch (type) {
+ case 'urgent':
+ return {
+ label: 'Срочно',
+ icon: ,
+ 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: ,
+ 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: ,
+ bg: 'bg-[#e8f5e9] border-[#a5d6a7]',
+ iconWrap: 'bg-[#c8e6c9] text-[#2e7d32]',
+ badge: 'bg-[#c8e6c9] text-[#2e7d32]',
+ actionColor: 'text-[#2e7d32]',
+ };
+ case 'promo':
+ return {
+ label: 'Предложение',
+ icon: ,
+ bg: 'bg-[#fff8e1] border-[#ffe082]',
+ iconWrap: 'bg-[#fff3cd] text-[#e65100]',
+ badge: 'bg-[#fff3cd] text-[#e65100]',
+ actionColor: 'text-[#e65100]',
+ };
+ default:
+ return {
+ label: 'Новость',
+ icon: ,
+ 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: ,
- title: 'Продление патента через 14 дней',
- description: 'Не забудьте подать документы заранее',
- action: 'Подробнее'
- },
- {
- id: '2',
- type: 'reminder',
- icon: ,
- title: 'Проверьте статус РВП',
- description: 'Возможно, уже готово к получению',
- action: 'Проверить'
- }
-];
+// ─── Основной компонент ───────────────────────────────────────────────────────
export function Notifications() {
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 (
-
- {notifications.map((notification) => (
-
-
- {notification.icon}
+
+
+ {/* Верхняя строка: метка + навигация + закрыть */}
+
+
+ {cfg.label}
+
+
+
+ {hasMultiple && (
+ <>
+
+
+ {index + 1}/{messages.length}
+
+
+ >
+ )}
+
+
+
+
+ {/* Тело */}
+
+
+ {cfg.icon}
-
{notification.title}
-
{notification.description}
+
{msg.title}
+
{msg.description}
+
+ {/* Промо-цена */}
+ {msg.type === 'promo' && msg.promo && (
+
+ {msg.promo.price}
+ {msg.promo.originalPrice && (
+ {msg.promo.originalPrice}
+ )}
+
+ )}
-
- ))}
+
+ {/* Футер с действием */}
+
+
+
+ {/* Для промо — дополнительно показываем дату */}
+ {msg.type === 'promo' && (
+
{msg.date}
+ )}
+
+
);
}
diff --git a/figma/src/app/components/QuickActions.tsx b/figma/src/app/components/QuickActions.tsx
index 76efd36..ac2bbf2 100644
--- a/figma/src/app/components/QuickActions.tsx
+++ b/figma/src/app/components/QuickActions.tsx
@@ -1,11 +1,22 @@
-import { Headphones } from 'lucide-react';
+import { Headphones, MessageCircle } from 'lucide-react';
+import { useNavigate } from 'react-router';
export function QuickActions() {
+ const navigate = useNavigate();
+
return (
-
-