From 3ed7239efa1867792744820747322acc3018e445 Mon Sep 17 00:00:00 2001 From: mi Date: Mon, 27 Jul 2026 19:33:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/frontend-test-site/app/index.tsx | 5 +- .../src/components/AppHeader.tsx | 25 ++-- .../src/components/NotificationCard.tsx | 112 ++++++++++----- .../src/components/NotificationCarousel.tsx | 63 ++++++--- .../src/components/PopularQuestionsList.tsx | 4 +- .../src/components/QuickActions.tsx | 128 ++++++++++++++++-- .../src/notification-presenter.ts | 44 +++++- .../backend/frontend-test-site/src/types.ts | 7 +- .../tests/e2e/smoke.spec.ts | 15 +- .../ATTRIBUTIONS.md | 0 figma/Main page specification.zip | Bin 96955 -> 96955 bytes figma/{Main page specification => }/README.md | 0 .../default_shadcn_theme.css | 0 .../guidelines/Guidelines.md | 0 .../{Main page specification => }/index.html | 0 .../package.json | 0 .../pnpm-workspace.yaml | 0 .../postcss.config.mjs | 0 .../src/app/App.tsx | 0 .../src/app/components/ChatInput.tsx | 0 .../src/app/components/HanLogo.tsx | 0 .../src/app/components/Header.tsx | 0 .../src/app/components/Notifications.tsx | 0 .../src/app/components/PopularQuestions.tsx | 0 .../src/app/components/QuickActions.tsx | 0 .../components/figma/ImageWithFallback.tsx | 0 .../src/app/components/ui/accordion.tsx | 0 .../src/app/components/ui/alert-dialog.tsx | 0 .../src/app/components/ui/alert.tsx | 0 .../src/app/components/ui/aspect-ratio.tsx | 0 .../src/app/components/ui/avatar.tsx | 0 .../src/app/components/ui/badge.tsx | 0 .../src/app/components/ui/breadcrumb.tsx | 0 .../src/app/components/ui/button.tsx | 0 .../src/app/components/ui/calendar.tsx | 0 .../src/app/components/ui/card.tsx | 0 .../src/app/components/ui/carousel.tsx | 0 .../src/app/components/ui/chart.tsx | 0 .../src/app/components/ui/checkbox.tsx | 0 .../src/app/components/ui/collapsible.tsx | 0 .../src/app/components/ui/command.tsx | 0 .../src/app/components/ui/context-menu.tsx | 0 .../src/app/components/ui/dialog.tsx | 0 .../src/app/components/ui/drawer.tsx | 0 .../src/app/components/ui/dropdown-menu.tsx | 0 .../src/app/components/ui/form.tsx | 0 .../src/app/components/ui/hover-card.tsx | 0 .../src/app/components/ui/input-otp.tsx | 0 .../src/app/components/ui/input.tsx | 0 .../src/app/components/ui/label.tsx | 0 .../src/app/components/ui/menubar.tsx | 0 .../src/app/components/ui/navigation-menu.tsx | 0 .../src/app/components/ui/pagination.tsx | 0 .../src/app/components/ui/popover.tsx | 0 .../src/app/components/ui/progress.tsx | 0 .../src/app/components/ui/radio-group.tsx | 0 .../src/app/components/ui/resizable.tsx | 0 .../src/app/components/ui/scroll-area.tsx | 0 .../src/app/components/ui/select.tsx | 0 .../src/app/components/ui/separator.tsx | 0 .../src/app/components/ui/sheet.tsx | 0 .../src/app/components/ui/sidebar.tsx | 0 .../src/app/components/ui/skeleton.tsx | 0 .../src/app/components/ui/slider.tsx | 0 .../src/app/components/ui/sonner.tsx | 0 .../src/app/components/ui/switch.tsx | 0 .../src/app/components/ui/table.tsx | 0 .../src/app/components/ui/tabs.tsx | 0 .../src/app/components/ui/textarea.tsx | 0 .../src/app/components/ui/toggle-group.tsx | 0 .../src/app/components/ui/toggle.tsx | 0 .../src/app/components/ui/tooltip.tsx | 0 .../src/app/components/ui/use-mobile.ts | 0 .../src/app/components/ui/utils.ts | 0 .../src/app/data/companyMessages.ts | 0 .../src/app/data/session.ts | 0 .../src/app/pages/AuthConsent.tsx | 0 .../src/app/pages/AuthLoading.tsx | 0 .../src/app/pages/AuthOtp.tsx | 0 .../src/app/pages/AuthPhone.tsx | 0 .../src/app/pages/Calendar.tsx | 0 .../src/app/pages/Chat.tsx | 0 .../src/app/pages/History.tsx | 0 .../src/app/pages/Home.tsx | 0 .../src/app/pages/NotificationDetail.tsx | 0 .../src/app/pages/Profile.tsx | 0 .../src/app/pages/Root.tsx | 0 .../src/app/routes.tsx | 0 .../src/main.tsx | 0 .../src/styles/fonts.css | 0 .../src/styles/globals.css | 0 .../src/styles/index.css | 0 .../src/styles/tailwind.css | 0 .../src/styles/theme.css | 0 .../vite.config.ts | 0 95 files changed, 299 insertions(+), 104 deletions(-) rename figma/{Main page specification => }/ATTRIBUTIONS.md (100%) rename figma/{Main page specification => }/README.md (100%) rename figma/{Main page specification => }/default_shadcn_theme.css (100%) rename figma/{Main page specification => }/guidelines/Guidelines.md (100%) rename figma/{Main page specification => }/index.html (100%) rename figma/{Main page specification => }/package.json (100%) rename figma/{Main page specification => }/pnpm-workspace.yaml (100%) rename figma/{Main page specification => }/postcss.config.mjs (100%) rename figma/{Main page specification => }/src/app/App.tsx (100%) rename figma/{Main page specification => }/src/app/components/ChatInput.tsx (100%) rename figma/{Main page specification => }/src/app/components/HanLogo.tsx (100%) rename figma/{Main page specification => }/src/app/components/Header.tsx (100%) rename figma/{Main page specification => }/src/app/components/Notifications.tsx (100%) rename figma/{Main page specification => }/src/app/components/PopularQuestions.tsx (100%) rename figma/{Main page specification => }/src/app/components/QuickActions.tsx (100%) rename figma/{Main page specification => }/src/app/components/figma/ImageWithFallback.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/accordion.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/alert-dialog.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/alert.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/aspect-ratio.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/avatar.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/badge.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/breadcrumb.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/button.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/calendar.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/card.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/carousel.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/chart.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/checkbox.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/collapsible.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/command.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/context-menu.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/dialog.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/drawer.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/dropdown-menu.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/form.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/hover-card.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/input-otp.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/input.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/label.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/menubar.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/navigation-menu.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/pagination.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/popover.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/progress.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/radio-group.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/resizable.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/scroll-area.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/select.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/separator.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/sheet.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/sidebar.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/skeleton.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/slider.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/sonner.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/switch.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/table.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/tabs.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/textarea.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/toggle-group.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/toggle.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/tooltip.tsx (100%) rename figma/{Main page specification => }/src/app/components/ui/use-mobile.ts (100%) rename figma/{Main page specification => }/src/app/components/ui/utils.ts (100%) rename figma/{Main page specification => }/src/app/data/companyMessages.ts (100%) rename figma/{Main page specification => }/src/app/data/session.ts (100%) rename figma/{Main page specification => }/src/app/pages/AuthConsent.tsx (100%) rename figma/{Main page specification => }/src/app/pages/AuthLoading.tsx (100%) rename figma/{Main page specification => }/src/app/pages/AuthOtp.tsx (100%) rename figma/{Main page specification => }/src/app/pages/AuthPhone.tsx (100%) rename figma/{Main page specification => }/src/app/pages/Calendar.tsx (100%) rename figma/{Main page specification => }/src/app/pages/Chat.tsx (100%) rename figma/{Main page specification => }/src/app/pages/History.tsx (100%) rename figma/{Main page specification => }/src/app/pages/Home.tsx (100%) rename figma/{Main page specification => }/src/app/pages/NotificationDetail.tsx (100%) rename figma/{Main page specification => }/src/app/pages/Profile.tsx (100%) rename figma/{Main page specification => }/src/app/pages/Root.tsx (100%) rename figma/{Main page specification => }/src/app/routes.tsx (100%) rename figma/{Main page specification => }/src/main.tsx (100%) rename figma/{Main page specification => }/src/styles/fonts.css (100%) rename figma/{Main page specification => }/src/styles/globals.css (100%) rename figma/{Main page specification => }/src/styles/index.css (100%) rename figma/{Main page specification => }/src/styles/tailwind.css (100%) rename figma/{Main page specification => }/src/styles/theme.css (100%) rename figma/{Main page specification => }/vite.config.ts (100%) diff --git a/codebase/backend/frontend-test-site/app/index.tsx b/codebase/backend/frontend-test-site/app/index.tsx index e06cdf4..10cdff6 100644 --- a/codebase/backend/frontend-test-site/app/index.tsx +++ b/codebase/backend/frontend-test-site/app/index.tsx @@ -182,7 +182,10 @@ export default function HomeScreen() { sending={sending} value={message} /> - + {Boolean(sendError) && ( diff --git a/codebase/backend/frontend-test-site/src/components/AppHeader.tsx b/codebase/backend/frontend-test-site/src/components/AppHeader.tsx index 9c305c3..e08944a 100644 --- a/codebase/backend/frontend-test-site/src/components/AppHeader.tsx +++ b/codebase/backend/frontend-test-site/src/components/AppHeader.tsx @@ -7,7 +7,7 @@ import { useApp } from "../app-context"; import { notificationApi, notificationKeys } from "../notification-api"; import { colors, radii, spacing } from "../theme"; -export function AppHeader({ guestLabel }: { guestLabel?: string | undefined }) { +export function AppHeader(_props: { guestLabel?: string | undefined }) { const { authStatus } = useApp(); const authenticated = authStatus === "authenticated"; const counter = useQuery({ @@ -20,7 +20,7 @@ export function AppHeader({ guestLabel }: { guestLabel?: string | undefined }) { return ( - [styles.centerLink, pressed && styles.pressed]}> + [styles.centerLink, pressed && styles.pressed]}> {authenticated && unread > 0 && ( @@ -29,19 +29,15 @@ export function AppHeader({ guestLabel }: { guestLabel?: string | undefined }) { )} - Центр - - {guestLabel && {guestLabel}} - - [styles.avatar, pressed && styles.pressed]}> - - - - - + + [styles.avatar, pressed && styles.pressed]}> + + + + ); } @@ -57,8 +53,7 @@ const styles = StyleSheet.create({ borderBottomWidth: 1, borderBottomColor: colors.border, }, - centerLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm }, - centerText: { fontSize: 14, color: colors.foreground }, + centerLink: { width: 40, height: 40, alignItems: "center", justifyContent: "center" }, notificationBadge: { position: "absolute", top: -9, @@ -74,8 +69,6 @@ const styles = StyleSheet.create({ borderColor: colors.background, }, notificationBadgeText: { color: colors.primaryForeground, fontSize: 9, fontWeight: "700" }, - right: { flexDirection: "row", alignItems: "center", gap: spacing.sm }, - guestBadge: { fontSize: 12, color: colors.mutedForeground }, avatar: { width: 40, height: 40, diff --git a/codebase/backend/frontend-test-site/src/components/NotificationCard.tsx b/codebase/backend/frontend-test-site/src/components/NotificationCard.tsx index f8b15bb..61d603b 100644 --- a/codebase/backend/frontend-test-site/src/components/NotificationCard.tsx +++ b/codebase/backend/frontend-test-site/src/components/NotificationCard.tsx @@ -2,7 +2,7 @@ import { Feather } from "@expo/vector-icons"; import React from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { formatNotificationPrice, notificationIcon, notificationPalette } from "../notification-presenter"; -import { radii, spacing } from "../theme"; +import { colors, radii, spacing } from "../theme"; import type { NotificationItem, NotificationType } from "../types"; export function NotificationCard({ @@ -10,6 +10,11 @@ export function NotificationCard({ type, onCta, onHide, + onNext, + onPrevious, + position, + total, + width, compact = false, disabled = false, }: { @@ -17,6 +22,11 @@ export function NotificationCard({ type: NotificationType | undefined; onCta: () => void; onHide?: () => void; + onNext?: () => void; + onPrevious?: () => void; + position?: number; + total?: number; + width?: number; compact?: boolean; disabled?: boolean; }) { @@ -29,42 +39,65 @@ export function NotificationCard({ - - - {type?.label ?? "Уведомление"} + + {type?.label ?? "Уведомление"} {type?.countable && item.is_read === false && } - {onHide && ( - - - - )} + + {onPrevious && total && total > 1 ? ( + + + + ) : null} + {position !== undefined && total && total > 1 ? ( + {position}/{total} + ) : null} + {onNext && total && total > 1 ? ( + + + + ) : null} + {onHide && ( + + + + )} + - {item.header} - {item.text ? {item.text} : null} - {deadline ? ( - - - до {new Date(deadline).toLocaleDateString("ru-RU")} + + + - ) : null} - {price ? ( - - {price} - {oldPrice ? {oldPrice} : null} + + {item.header} + {item.text ? {item.text} : null} + {deadline ? ( + + + до {new Date(deadline).toLocaleDateString("ru-RU")} + + ) : null} + {price ? ( + + {price} + {oldPrice ? {oldPrice} : null} + + ) : null} - ) : null} + + [local.cta, { backgroundColor: palette.accent }, pressed && local.pressed, disabled && local.disabled]} + style={({ pressed }) => [local.cta, pressed && local.pressed, disabled && local.disabled]} > - {type?.cta_text ?? "Подробнее →"} + {type?.cta_text ?? "Подробнее →"} ); @@ -73,26 +106,31 @@ export function NotificationCard({ const local = StyleSheet.create({ card: { width: 326, - minHeight: 190, borderWidth: 1, borderRadius: radii.xl, - padding: spacing.lg, - gap: spacing.sm, + overflow: "hidden", }, compact: { width: "100%", minHeight: 0 }, - headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, - labelRow: { flexDirection: "row", alignItems: "center", gap: spacing.sm, flexShrink: 1 }, - label: { fontSize: 12, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.4 }, + headerRow: { minHeight: 42, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6, flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + labelBadge: { minHeight: 24, borderRadius: radii.full, paddingHorizontal: 10, flexDirection: "row", alignItems: "center", gap: 6, flexShrink: 1 }, + label: { fontSize: 11, fontWeight: "700", textTransform: "uppercase", letterSpacing: 0.5 }, unread: { width: 8, height: 8, borderRadius: radii.full }, - title: { fontSize: 18, lineHeight: 23, fontWeight: "600" }, - text: { fontSize: 14, lineHeight: 20, opacity: 0.88 }, + controls: { flexDirection: "row", alignItems: "center" }, + controlButton: { width: 28, height: 28, alignItems: "center", justifyContent: "center" }, + hideButton: { width: 28, height: 28, marginLeft: 2, alignItems: "center", justifyContent: "center" }, + position: { minWidth: 32, textAlign: "center", fontSize: 11, color: colors.mutedForeground }, + body: { flexDirection: "row", alignItems: "flex-start", gap: spacing.md, paddingHorizontal: 14, paddingBottom: spacing.md }, + iconWrap: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center", flexShrink: 0 }, + content: { flex: 1, minWidth: 0 }, + title: { fontSize: 14, lineHeight: 19, fontWeight: "600", marginBottom: 2 }, + text: { fontSize: 12, lineHeight: 17, color: colors.mutedForeground }, deadline: { flexDirection: "row", alignItems: "center", gap: spacing.xs }, - meta: { fontSize: 12 }, + meta: { fontSize: 11, color: colors.mutedForeground }, priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm }, - price: { fontSize: 18, fontWeight: "700" }, - oldPrice: { fontSize: 13, textDecorationLine: "line-through", opacity: 0.65 }, - cta: { alignSelf: "flex-start", minHeight: 38, justifyContent: "center", borderRadius: radii.md, paddingHorizontal: spacing.md, marginTop: "auto" }, - ctaText: { color: "#ffffff", fontSize: 14, fontWeight: "600" }, + price: { fontSize: 14, fontWeight: "700" }, + oldPrice: { fontSize: 11, color: colors.mutedForeground, textDecorationLine: "line-through" }, + cta: { minHeight: 39, justifyContent: "center", borderTopWidth: 1, borderTopColor: "rgba(0, 0, 0, 0.06)", paddingHorizontal: 14 }, + ctaText: { fontSize: 12, fontWeight: "700" }, pressed: { opacity: 0.78 }, disabled: { opacity: 0.5 }, }); diff --git a/codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx b/codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx index 5f4938c..ad583c9 100644 --- a/codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx +++ b/codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx @@ -1,12 +1,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { FlatList, StyleSheet, Text, View } from "react-native"; +import { FlatList, StyleSheet, useWindowDimensions, View } from "react-native"; import { notificationApi, notificationKeys } from "../notification-api"; import { useNotificationAction } from "../notification-actions"; import { typeMap } from "../notification-presenter"; -import { colors, spacing } from "../theme"; +import { layout, spacing } from "../theme"; import type { NotificationItem } from "../types"; -import { ErrorNotice, Loading, styles } from "../ui"; +import { ErrorNotice, Loading } from "../ui"; import { NotificationCard } from "./NotificationCard"; export function NotificationCarousel({ @@ -24,6 +24,9 @@ export function NotificationCarousel({ const list = useRef>(null); const [activeIndex, setActiveIndex] = useState(0); const [actionError, setActionError] = useState(); + const [hiddenGuestIds, setHiddenGuestIds] = useState>(() => new Set()); + const window = useWindowDimensions(); + const cardWidth = Math.max(0, Math.min(window.width, layout.maxWidth) - spacing.lg * 2); const catalog = useQuery({ queryKey: notificationKeys.catalog, queryFn: notificationApi.catalog, @@ -45,7 +48,14 @@ export function NotificationCarousel({ }, onError: setActionError, }); - const data = notifications.data ?? []; + const data = (notifications.data ?? []).filter((item) => authenticated || !hiddenGuestIds.has(item.id)); + + const goTo = (index: number) => { + if (data.length < 2) return; + const next = (index + data.length) % data.length; + setActiveIndex(next); + list.current?.scrollToIndex({ index: next, animated: true }); + }; useEffect(() => { if (!autoplay || data.length < 2) return; @@ -59,6 +69,13 @@ export function NotificationCarousel({ return () => clearInterval(timer); }, [autoplay, autoplayIntervalMs, data.length]); + useEffect(() => { + if (activeIndex < data.length) return; + const next = Math.max(data.length - 1, 0); + setActiveIndex(next); + list.current?.scrollToIndex({ index: next, animated: false }); + }, [activeIndex, data.length]); + if (notifications.isLoading || catalog.isLoading) { return ; } @@ -76,17 +93,21 @@ export function NotificationCarousel({ return ( - Важное для вас ({ length: cardWidth, offset: cardWidth * index, index })} keyExtractor={(item) => item.id} - contentContainerStyle={local.content} - ItemSeparatorComponent={() => } + style={local.carousel} onMomentumScrollEnd={(event) => { - const width = event.nativeEvent.layoutMeasurement.width; - if (width > 0) setActiveIndex(Math.round(event.nativeEvent.contentOffset.x / width)); + if (cardWidth > 0) { + setActiveIndex(Math.min(data.length - 1, Math.round(event.nativeEvent.contentOffset.x / cardWidth))); + } }} renderItem={({ item }) => ( void action(item, byCode.get(item.notification_type))} - {...(authenticated ? { onHide: () => hide.mutate(item.id) } : {})} + onNext={() => goTo(activeIndex + 1)} + onPrevious={() => goTo(activeIndex - 1)} + position={activeIndex + 1} + total={data.length} + width={cardWidth} + onHide={() => { + if (authenticated) { + hide.mutate(item.id); + } else { + setHiddenGuestIds((current) => new Set(current).add(item.id)); + } + }} /> )} showsHorizontalScrollIndicator={false} /> - {data.length > 1 && ( - - {data.map((item, index) => )} - - )} {Boolean(actionError) && } ); @@ -111,11 +138,7 @@ export function NotificationCarousel({ const local = StyleSheet.create({ section: { paddingVertical: spacing.md }, - heading: { paddingHorizontal: spacing.lg, marginBottom: spacing.sm }, - content: { paddingHorizontal: spacing.lg }, + carousel: { marginHorizontal: spacing.lg }, state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md }, - dots: { flexDirection: "row", justifyContent: "center", gap: 6, marginTop: spacing.sm }, - dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.muted }, - dotActive: { width: 16, backgroundColor: colors.primary }, error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm }, }); diff --git a/codebase/backend/frontend-test-site/src/components/PopularQuestionsList.tsx b/codebase/backend/frontend-test-site/src/components/PopularQuestionsList.tsx index 524c688..88b98b7 100644 --- a/codebase/backend/frontend-test-site/src/components/PopularQuestionsList.tsx +++ b/codebase/backend/frontend-test-site/src/components/PopularQuestionsList.tsx @@ -22,7 +22,7 @@ export function PopularQuestionsList({ questions, onSelect }: { questions: Quest style={({ pressed }) => [styles.item, pressed && styles.pressed]} > - + {question.text} @@ -54,6 +54,6 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, - itemText: { flex: 1, fontSize: 14, color: colors.foreground }, + itemText: { flex: 1, fontSize: 14, fontWeight: "600", color: colors.foreground }, pressed: { backgroundColor: colors.accent }, }); diff --git a/codebase/backend/frontend-test-site/src/components/QuickActions.tsx b/codebase/backend/frontend-test-site/src/components/QuickActions.tsx index 5115918..2987412 100644 --- a/codebase/backend/frontend-test-site/src/components/QuickActions.tsx +++ b/codebase/backend/frontend-test-site/src/components/QuickActions.tsx @@ -1,39 +1,137 @@ import { Feather } from "@expo/vector-icons"; -import React from "react"; -import { Linking, Pressable, StyleSheet, Text } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import React, { useEffect, useRef } from "react"; +import { Animated, Linking, Pressable, StyleSheet, Text, View } from "react-native"; +import { dialogApi } from "../services"; import { colors, radii, spacing } from "../theme"; -export function QuickActions({ phone }: { phone?: string | undefined }) { +function UnreadMessageBadge() { + const pulse = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const animation = Animated.loop( + Animated.timing(pulse, { + toValue: 1, + duration: 1100, + useNativeDriver: true, + }), + ); + animation.start(); + return () => animation.stop(); + }, [pulse]); + + return ( + + + + ! + + + ); +} + +export function QuickActions({ phone, authenticated = false }: { phone?: string | undefined; authenticated?: boolean }) { + const router = useRouter(); + const dialogs = useQuery({ + queryKey: ["dialogs", "unread-indicator"], + queryFn: () => dialogApi.list(), + enabled: authenticated, + staleTime: 30_000, + refetchInterval: 30_000, + }); + const hasUnreadMessages = (dialogs.data?.items ?? []).some((dialog) => (dialog.unread_count ?? 0) > 0); + const call = () => { if (phone) void Linking.openURL(`tel:${phone}`); }; return ( - [styles.button, pressed && styles.pressed, !phone && styles.disabled]} - > - - Связь с оператором - + + router.push("/dialogs")} + style={({ pressed }) => [styles.button, styles.chatButton, pressed && styles.pressed]} + > + + + {hasUnreadMessages ? : null} + + Чат + + + [styles.button, styles.operatorButton, pressed && styles.pressed, !phone && styles.disabled]} + > + + Оператор + + ); } const styles = StyleSheet.create({ + row: { + flexDirection: "row", + gap: spacing.sm, + marginHorizontal: spacing.lg, + marginBottom: spacing.lg, + }, button: { + flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.sm, - marginHorizontal: spacing.lg, - marginBottom: spacing.lg, paddingVertical: 10, borderRadius: radii.lg, + }, + chatButton: { + backgroundColor: colors.primary, + }, + operatorButton: { backgroundColor: colors.secondary, }, - text: { fontSize: 14, color: colors.secondaryForeground }, + text: { fontSize: 14, fontWeight: "600", color: colors.secondaryForeground }, + chatText: { color: colors.primaryForeground }, + badgePosition: { + position: "absolute", + top: -8, + right: -9, + width: 14, + height: 14, + alignItems: "center", + justifyContent: "center", + }, + pulse: { + position: "absolute", + width: 12, + height: 12, + borderRadius: radii.full, + backgroundColor: colors.destructive, + }, + badge: { + width: 14, + height: 14, + borderRadius: radii.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.destructive, + }, + badgeText: { color: colors.primaryForeground, fontSize: 10, lineHeight: 12, fontWeight: "800" }, pressed: { opacity: 0.8 }, disabled: { opacity: 0.5 }, }); diff --git a/codebase/backend/frontend-test-site/src/notification-presenter.ts b/codebase/backend/frontend-test-site/src/notification-presenter.ts index 67c855f..0ab72c3 100644 --- a/codebase/backend/frontend-test-site/src/notification-presenter.ts +++ b/codebase/backend/frontend-test-site/src/notification-presenter.ts @@ -5,20 +5,54 @@ export type NotificationPalette = { background: string; foreground: string; accent: string; + accentBackground: string; + border: string; }; const neutral: NotificationPalette = { background: "#f3f3f5", foreground: "#252525", accent: "#030213", + accentBackground: "#e4e4e8", + border: "#d7d7dc", }; const palettes: Record = { - critical: { background: "#feecef", foreground: "#7f1d1d", accent: "#d4183d" }, - warning: { background: "#fff7df", foreground: "#713f12", accent: "#ca8a04" }, - success: { background: "#eaf8ee", foreground: "#14532d", accent: "#16a34a" }, - info: { background: "#eaf2ff", foreground: "#1e3a8a", accent: "#2563eb" }, - promo: { background: "#f4edff", foreground: "#4c1d95", accent: "#7c3aed" }, + critical: { + background: "#fdecef", + foreground: "#252525", + accent: "#d4183d", + accentBackground: "#f8dce2", + border: "#efbcc7", + }, + warning: { + background: "#fff3e0", + foreground: "#252525", + accent: "#e65100", + accentBackground: "#ffe0b2", + border: "#ffcc80", + }, + success: { + background: "#e8f5e9", + foreground: "#252525", + accent: "#2e7d32", + accentBackground: "#c8e6c9", + border: "#a5d6a7", + }, + info: { + background: "#eaf2ff", + foreground: "#252525", + accent: "#2563eb", + accentBackground: "#d8e7ff", + border: "#b7d1fb", + }, + promo: { + background: "#f4edff", + foreground: "#252525", + accent: "#7c3aed", + accentBackground: "#e7d8ff", + border: "#d4b9fb", + }, neutral, }; diff --git a/codebase/backend/frontend-test-site/src/types.ts b/codebase/backend/frontend-test-site/src/types.ts index ad4a02b..ec209cb 100644 --- a/codebase/backend/frontend-test-site/src/types.ts +++ b/codebase/backend/frontend-test-site/src/types.ts @@ -13,7 +13,12 @@ export type TokenSet = { }; export type DialogStatus = "open" | "waiting_for_company" | "waiting_for_client" | "closed"; -export type Dialog = { dialog_id: string; status: DialogStatus; updated_at?: string }; +export type Dialog = { + dialog_id: string; + status: DialogStatus; + unread_count?: number; + updated_at?: string; +}; export type Attachment = { attachment_id: string; file_name: string; diff --git a/codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts b/codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts index 5218d9b..1c33f25 100644 --- a/codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts +++ b/codebase/backend/frontend-test-site/tests/e2e/smoke.spec.ts @@ -55,17 +55,18 @@ test("гостевой экран загружает публичный конт await expect(page.getByRole("button", { name: "Голосовое сообщение" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Прикрепить файл" })).toBeVisible(); await expect(page.getByText("Продление патента через 14 дней")).toHaveCount(0); - await expect(page.getByRole("button", { name: "Связь с оператором" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Чат" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Оператор" })).toBeEnabled(); await expect(page.getByText("+74950000000")).toHaveCount(0); - await expect(page.getByText(/Гость/)).toBeVisible(); + await expect(page.getByText(/Гость/)).toHaveCount(0); }); -test("История открывает текущий единый чат", async ({ page }) => { +test("Колокольчик открывает центр уведомлений", async ({ page }) => { await page.goto("/"); - await page.getByRole("link", { name: "История" }).click(); - await expect(page).toHaveURL(/\/dialogs$/); - await expect(page.getByRole("heading", { name: "Чат" })).toBeVisible(); - await expect(page.getByText(/доступен после авторизации/)).toBeVisible(); + await page.getByRole("link", { name: "Уведомления" }).click(); + await expect(page).toHaveURL(/\/notifications$/); + await expect(page.getByRole("heading", { name: "Центр уведомлений" })).toBeVisible(); + await expect(page.getByText(/доступны после входа/)).toBeVisible(); }); test("первое сообщение требует обязательные согласия", async ({ page }) => { diff --git a/figma/Main page specification/ATTRIBUTIONS.md b/figma/ATTRIBUTIONS.md similarity index 100% rename from figma/Main page specification/ATTRIBUTIONS.md rename to figma/ATTRIBUTIONS.md diff --git a/figma/Main page specification.zip b/figma/Main page specification.zip index 417bcbf346dba63ddf8c04d00da761fbb1bc683c..cfcabbbb0e3853737d8016a480680e1e545a28d9 100644 GIT binary patch delta 9643 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