Добавлены изменения для 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_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
|
||||
|
||||
@@ -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-сервера:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: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"
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
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";
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,13 +1,11 @@
|
||||
|
||||
export function HanLogo() {
|
||||
return (
|
||||
<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">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="w-7 h-7"
|
||||
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"
|
||||
<svg viewBox="0 0 100 100" className="w-7 h-7" 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"
|
||||
strokeWidth="6"
|
||||
fill="none"
|
||||
@@ -16,12 +14,14 @@ export function HanLogo() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h1 className="text-base font-medium mb-0.5">Привет! Я HAN</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Помощник по документам и жизни в России
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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" />
|
||||
<span className="text-sm">История</span>
|
||||
{/* Колокольчик с бейджем */}
|
||||
<Link to="/history" className="relative flex items-center justify-center w-10 h-10 rounded-full hover:bg-muted transition-colors">
|
||||
<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 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" />
|
||||
<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>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -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: <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() {
|
||||
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 (
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="bg-primary/5 border border-primary/20 rounded-lg p-3 flex items-start gap-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary flex-shrink-0 mt-0.5">
|
||||
{notification.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
<div className="px-4 py-3">
|
||||
<div className={`rounded-xl border ${cfg.bg} overflow-hidden`}>
|
||||
{/* Верхняя строка: метка + навигация + закрыть */}
|
||||
<div className="flex items-center justify-between px-3 pt-2.5 pb-2">
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full ${cfg.badge}`}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{hasMultiple && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate(`/notification/${notification.id}`)}
|
||||
className="text-xs text-primary font-medium flex-shrink-0 mt-1 hover:underline"
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="px-4 pb-4">
|
||||
<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">
|
||||
<div className="px-4 pb-4 flex gap-2">
|
||||
<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" />
|
||||
<span className="text-sm">Связь с оператором</span>
|
||||
<span className="text-sm">Оператор</span>
|
||||
</button>
|
||||
</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 { getActiveMessages, CompanyMessage } from '../data/companyMessages';
|
||||
|
||||
interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: string;
|
||||
unread?: boolean;
|
||||
function getIcon(type: CompanyMessage['type']) {
|
||||
if (type === 'urgent') return <AlertCircle className="w-4 h-4" />;
|
||||
if (type === 'reminder') return <Calendar className="w-4 h-4" />;
|
||||
if (type === 'message') return <MessageCircle className="w-4 h-4" />;
|
||||
if (type === 'promo') return <Tag className="w-4 h-4" />;
|
||||
return <Bell className="w-4 h-4" />;
|
||||
}
|
||||
|
||||
const chatHistory: ChatSession[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Продление патента',
|
||||
lastMessage: 'Спасибо за помощь! Я понял что нужно делать.',
|
||||
timestamp: 'Сегодня, 14:30',
|
||||
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
|
||||
},
|
||||
];
|
||||
function typeColors(type: CompanyMessage['type']) {
|
||||
if (type === 'urgent') return { icon: 'bg-destructive/12 text-destructive', dot: 'bg-destructive' };
|
||||
if (type === 'reminder') return { icon: 'bg-primary/10 text-primary', dot: 'bg-primary' };
|
||||
if (type === 'message') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
|
||||
if (type === 'promo') return { icon: 'bg-[#fff3cd] text-[#e65100]', dot: 'bg-[#fb8c00]' };
|
||||
return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' };
|
||||
}
|
||||
|
||||
export function History() {
|
||||
const navigate = useNavigate();
|
||||
const messages = getActiveMessages();
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-4 py-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="px-4 py-4 space-y-4">
|
||||
|
||||
{/* Заголовок страницы */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
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" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-medium">История общения</h1>
|
||||
<p className="text-sm text-muted-foreground">Все ваши диалоги с HAN</p>
|
||||
</div>
|
||||
<h1 className="flex-1 text-xl font-medium">Центр уведомлений</h1>
|
||||
{messages.length > 0 && (
|
||||
<span className="text-xs font-medium text-primary bg-primary/10 rounded-full px-2.5 py-1">
|
||||
{messages.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Список уведомлений */}
|
||||
{messages.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{chatHistory.map((chat) => (
|
||||
{messages.map(msg => {
|
||||
const colors = typeColors(msg.type);
|
||||
return (
|
||||
<button
|
||||
key={chat.id}
|
||||
onClick={() => navigate(`/chat/${chat.id}`)}
|
||||
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"
|
||||
key={msg.id}
|
||||
onClick={() => msg.type === 'message' && msg.chatId
|
||||
? 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">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
<div className={`relative flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0 ${colors.icon}`}>
|
||||
{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 className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm mb-1">{chat.title}</h3>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">
|
||||
{chat.lastMessage}
|
||||
<p className="text-sm font-medium text-foreground leading-snug line-clamp-1 mb-0.5">
|
||||
{msg.title}
|
||||
</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" />
|
||||
<span>{chat.timestamp}</span>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,89 +1,70 @@
|
||||
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 {
|
||||
id: string;
|
||||
type: 'urgent' | 'reminder';
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
fullContent: string;
|
||||
deadline?: string;
|
||||
steps?: string[];
|
||||
function getIcon(type: 'urgent' | 'reminder' | 'info', className = 'w-5 h-5') {
|
||||
if (type === 'urgent') return <AlertCircle className={className} />;
|
||||
if (type === 'reminder') return <Calendar className={className} />;
|
||||
return <Bell className={className} />;
|
||||
}
|
||||
|
||||
const notificationDetails: Record<string, NotificationData> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
type: 'urgent',
|
||||
icon: <Calendar className="w-6 h-6" />,
|
||||
title: 'Продление патента через 14 дней',
|
||||
description: 'Не забудьте подать документы заранее',
|
||||
fullContent: 'Ваш патент на работу истекает 19 июня 2026 года. Рекомендуем начать процесс продления заранее, чтобы избежать перерыва в легальном статусе.',
|
||||
deadline: '19 июня 2026',
|
||||
steps: [
|
||||
'Подготовьте пакет документов (паспорт, миграционная карта, текущий патент)',
|
||||
'Оплатите госпошлину и НДФЛ',
|
||||
'Подайте документы в МФЦ или МВД',
|
||||
'Получите новый патент в течение 10 рабочих дней'
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
type: 'reminder',
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
title: 'Проверьте статус РВП',
|
||||
description: 'Возможно, уже готово к получению',
|
||||
fullContent: 'Прошло более 6 месяцев с момента подачи документов на РВП. Рекомендуем проверить готовность документа на сайте МВД или обратиться в отделение лично.',
|
||||
steps: [
|
||||
'Зайдите на сайт гувм.мвд.рф',
|
||||
'Проверьте статус по номеру заявления',
|
||||
'При готовности запишитесь на получение',
|
||||
'Подготовьте документы для получения РВП'
|
||||
]
|
||||
}
|
||||
function typeStyle(type: 'urgent' | 'reminder' | 'info') {
|
||||
if (type === 'urgent') return {
|
||||
wrapper: 'bg-destructive/10 text-destructive',
|
||||
deadline: 'bg-destructive/6 border-destructive/20',
|
||||
deadlineText: 'text-destructive',
|
||||
step: 'bg-destructive/10 text-destructive',
|
||||
};
|
||||
if (type === 'reminder') return {
|
||||
wrapper: 'bg-primary/10 text-primary',
|
||||
deadline: 'bg-primary/5 border-primary/20',
|
||||
deadlineText: 'text-primary',
|
||||
step: 'bg-primary/10 text-primary',
|
||||
};
|
||||
return {
|
||||
wrapper: 'bg-muted text-muted-foreground',
|
||||
deadline: 'bg-muted border-border',
|
||||
deadlineText: 'text-foreground',
|
||||
step: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
}
|
||||
|
||||
export function NotificationDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const notification = id ? notificationDetails[id] : null;
|
||||
const notification = id ? companyMessages.find(m => m.id === id) : null;
|
||||
|
||||
if (!notification) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4">
|
||||
<p className="text-muted-foreground">Уведомление не найдено</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="mt-4 text-primary font-medium"
|
||||
>
|
||||
<button onClick={() => navigate('/')} className="mt-4 text-primary font-medium">
|
||||
Вернуться на главную
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const style = typeStyle(notification.type);
|
||||
|
||||
const handleComplete = () => {
|
||||
// В реальном приложении здесь будет логика отметки уведомления как выполненного
|
||||
dismissMessage(notification.id);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleHide = () => {
|
||||
// В реальном приложении здесь будет логика скрытия уведомления
|
||||
dismissMessage(notification.id);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleLater = () => {
|
||||
// В реальном приложении здесь будет логика напоминания позже
|
||||
navigate('/');
|
||||
};
|
||||
const handleLater = () => navigate('/');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Хедер с кнопкой назад */}
|
||||
{/* Хедер */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/40">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-1.5 -ml-1.5 hover:bg-muted/50 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
@@ -96,12 +77,8 @@ export function NotificationDetail() {
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Иконка и заголовок */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex items-center justify-center w-12 h-12 rounded-full flex-shrink-0 ${
|
||||
notification.type === 'urgent'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{notification.icon}
|
||||
<div className={`flex items-center justify-center w-12 h-12 rounded-full flex-shrink-0 ${style.wrapper}`}>
|
||||
{getIcon(notification.type, 'w-6 h-6')}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-1">
|
||||
<h2 className="font-semibold text-base mb-1">{notification.title}</h2>
|
||||
@@ -109,13 +86,13 @@ export function NotificationDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Срок, если есть */}
|
||||
{/* Дедлайн */}
|
||||
{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">
|
||||
<Calendar className="w-4 h-4 text-primary" />
|
||||
<Calendar className={`w-4 h-4 ${style.deadlineText}`} />
|
||||
<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>
|
||||
)}
|
||||
@@ -123,24 +100,20 @@ export function NotificationDetail() {
|
||||
{/* Полное описание */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-sm">Подробности</h3>
|
||||
<p className="text-sm text-foreground/90 leading-relaxed">
|
||||
{notification.fullContent}
|
||||
</p>
|
||||
<p className="text-sm text-foreground/90 leading-relaxed">{notification.fullContent}</p>
|
||||
</div>
|
||||
|
||||
{/* Шаги к действию */}
|
||||
{/* Шаги */}
|
||||
{notification.steps && notification.steps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-sm">Что нужно сделать</h3>
|
||||
<div className="space-y-2">
|
||||
{notification.steps.map((step, index) => (
|
||||
<div key={index} 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">
|
||||
{index + 1}
|
||||
{notification.steps.map((step, i) => (
|
||||
<div key={i} className="flex gap-2.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}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<p className="text-sm text-foreground/90 leading-relaxed flex-1">
|
||||
{step}
|
||||
</p>
|
||||
<p className="text-sm text-foreground/90 leading-relaxed flex-1">{step}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -149,7 +122,7 @@ export function NotificationDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки управления */}
|
||||
{/* Кнопки */}
|
||||
<div className="border-t border-border/40 p-4 space-y-2 bg-background">
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
@@ -167,13 +140,12 @@ export function NotificationDetail() {
|
||||
<Clock className="w-4 h-4" />
|
||||
Сделаю позже
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleHide}
|
||||
className="flex-1 bg-muted text-muted-foreground rounded-lg px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
Скрыть уведомление
|
||||
Скрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user