Добавлены изменения для PWA
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
<div className="flex items-center gap-0.5">
|
||||||
<div className="flex-1 min-w-0">
|
{hasMultiple && (
|
||||||
<h3 className="font-medium text-sm mb-0.5">{notification.title}</h3>
|
<>
|
||||||
<p className="text-xs text-muted-foreground leading-tight">{notification.description}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/notification/${notification.id}`)}
|
onClick={() => setIndex(i => (i - 1 + messages.length) % messages.length)}
|
||||||
className="text-xs text-primary font-medium flex-shrink-0 mt-1 hover:underline"
|
className="w-6 h-6 flex items-center justify-center rounded-md hover:bg-black/8 transition-colors"
|
||||||
>
|
>
|
||||||
{notification.action}
|
<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>
|
</button>
|
||||||
</div>
|
</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 className="flex-1 min-w-0">
|
||||||
|
<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>
|
||||||
|
{msg.promo.originalPrice && (
|
||||||
|
<span className="text-xs text-muted-foreground line-through">{msg.promo.originalPrice}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) }));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
{/* Список уведомлений */}
|
||||||
|
{messages.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{chatHistory.map((chat) => (
|
{messages.map(msg => {
|
||||||
|
const colors = typeColors(msg.type);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={chat.id}
|
key={msg.id}
|
||||||
onClick={() => navigate(`/chat/${chat.id}`)}
|
onClick={() => msg.type === 'message' && msg.chatId
|
||||||
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"
|
? navigate(`/chat/${msg.chatId}`)
|
||||||
|
: navigate(`/notification/${msg.id}`)
|
||||||
|
}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10 text-primary flex-shrink-0">
|
<div className={`relative flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0 ${colors.icon}`}>
|
||||||
<MessageSquare className="w-5 h-5" />
|
{getIcon(msg.type)}
|
||||||
|
<span className={`absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background ${colors.dot}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium text-sm mb-1">{chat.title}</h3>
|
<p className="text-sm font-medium text-foreground leading-snug line-clamp-1 mb-0.5">
|
||||||
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">
|
{msg.title}
|
||||||
{chat.lastMessage}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<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" />
|
<Clock className="w-3 h-3" />
|
||||||
<span>{chat.timestamp}</span>
|
<span>{msg.date}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user