Поправлен интерфейс
This commit is contained in:
@@ -182,7 +182,10 @@ export default function HomeScreen() {
|
|||||||
sending={sending}
|
sending={sending}
|
||||||
value={message}
|
value={message}
|
||||||
/>
|
/>
|
||||||
<QuickActions phone={config.data?.operator.call_phone} />
|
<QuickActions
|
||||||
|
authenticated={authStatus === "authenticated"}
|
||||||
|
phone={config.data?.operator.call_phone}
|
||||||
|
/>
|
||||||
{Boolean(sendError) && (
|
{Boolean(sendError) && (
|
||||||
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
|
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
|
||||||
<ErrorNotice error={sendError} />
|
<ErrorNotice error={sendError} />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useApp } from "../app-context";
|
|||||||
import { notificationApi, notificationKeys } from "../notification-api";
|
import { notificationApi, notificationKeys } from "../notification-api";
|
||||||
import { colors, radii, spacing } from "../theme";
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
export function AppHeader({ guestLabel }: { guestLabel?: string | undefined }) {
|
export function AppHeader(_props: { guestLabel?: string | undefined }) {
|
||||||
const { authStatus } = useApp();
|
const { authStatus } = useApp();
|
||||||
const authenticated = authStatus === "authenticated";
|
const authenticated = authStatus === "authenticated";
|
||||||
const counter = useQuery({
|
const counter = useQuery({
|
||||||
@@ -20,7 +20,7 @@ export function AppHeader({ guestLabel }: { guestLabel?: string | undefined }) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Link href="/notifications" asChild>
|
<Link href="/notifications" asChild>
|
||||||
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.centerLink, pressed && styles.pressed]}>
|
<Pressable accessibilityLabel="Уведомления" accessibilityRole="link" style={({ pressed }) => [styles.centerLink, pressed && styles.pressed]}>
|
||||||
<View>
|
<View>
|
||||||
<Feather name="bell" size={20} color={colors.foreground} />
|
<Feather name="bell" size={20} color={colors.foreground} />
|
||||||
{authenticated && unread > 0 && (
|
{authenticated && unread > 0 && (
|
||||||
@@ -29,20 +29,16 @@ export function AppHeader({ guestLabel }: { guestLabel?: string | undefined }) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.centerText}>Центр</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<View style={styles.right}>
|
|
||||||
{guestLabel && <Text style={styles.guestBadge}>{guestLabel}</Text>}
|
|
||||||
<Link href="/profile" asChild>
|
<Link href="/profile" asChild>
|
||||||
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.avatar, pressed && styles.pressed]}>
|
<Pressable accessibilityRole="link" accessibilityLabel="Личный кабинет" style={({ pressed }) => [styles.avatar, pressed && styles.pressed]}>
|
||||||
<Feather name="user" size={20} color={colors.mutedForeground} />
|
<Feather name="user" size={20} color={colors.mutedForeground} />
|
||||||
<View style={styles.dot} />
|
<View style={styles.dot} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +53,7 @@ const styles = StyleSheet.create({
|
|||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
centerLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
|
centerLink: { width: 40, height: 40, alignItems: "center", justifyContent: "center" },
|
||||||
centerText: { fontSize: 14, color: colors.foreground },
|
|
||||||
notificationBadge: {
|
notificationBadge: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: -9,
|
top: -9,
|
||||||
@@ -74,8 +69,6 @@ const styles = StyleSheet.create({
|
|||||||
borderColor: colors.background,
|
borderColor: colors.background,
|
||||||
},
|
},
|
||||||
notificationBadgeText: { color: colors.primaryForeground, fontSize: 9, fontWeight: "700" },
|
notificationBadgeText: { color: colors.primaryForeground, fontSize: 9, fontWeight: "700" },
|
||||||
right: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
|
|
||||||
guestBadge: { fontSize: 12, color: colors.mutedForeground },
|
|
||||||
avatar: {
|
avatar: {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Feather } from "@expo/vector-icons";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { formatNotificationPrice, notificationIcon, notificationPalette } from "../notification-presenter";
|
import { formatNotificationPrice, notificationIcon, notificationPalette } from "../notification-presenter";
|
||||||
import { radii, spacing } from "../theme";
|
import { colors, radii, spacing } from "../theme";
|
||||||
import type { NotificationItem, NotificationType } from "../types";
|
import type { NotificationItem, NotificationType } from "../types";
|
||||||
|
|
||||||
export function NotificationCard({
|
export function NotificationCard({
|
||||||
@@ -10,6 +10,11 @@ export function NotificationCard({
|
|||||||
type,
|
type,
|
||||||
onCta,
|
onCta,
|
||||||
onHide,
|
onHide,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
width,
|
||||||
compact = false,
|
compact = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
@@ -17,6 +22,11 @@ export function NotificationCard({
|
|||||||
type: NotificationType | undefined;
|
type: NotificationType | undefined;
|
||||||
onCta: () => void;
|
onCta: () => void;
|
||||||
onHide?: () => void;
|
onHide?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
onPrevious?: () => void;
|
||||||
|
position?: number;
|
||||||
|
total?: number;
|
||||||
|
width?: number;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -29,42 +39,65 @@ export function NotificationCard({
|
|||||||
<View style={[
|
<View style={[
|
||||||
local.card,
|
local.card,
|
||||||
compact && local.compact,
|
compact && local.compact,
|
||||||
{ backgroundColor: palette.background, borderColor: `${palette.accent}40` },
|
width !== undefined && { width },
|
||||||
|
{ backgroundColor: palette.background, borderColor: palette.border },
|
||||||
]}>
|
]}>
|
||||||
<View style={local.headerRow}>
|
<View style={local.headerRow}>
|
||||||
<View style={local.labelRow}>
|
<View style={[local.labelBadge, { backgroundColor: palette.accentBackground }]}>
|
||||||
<Feather name={notificationIcon(type?.icon_code)} size={18} color={palette.accent} />
|
<Text style={[local.label, { color: palette.accent }]}>{type?.label ?? "Уведомление"}</Text>
|
||||||
<Text style={[local.label, { color: palette.foreground }]}>{type?.label ?? "Уведомление"}</Text>
|
|
||||||
{type?.countable && item.is_read === false && <View accessibilityLabel="Непрочитано" style={[local.unread, { backgroundColor: palette.accent }]} />}
|
{type?.countable && item.is_read === false && <View accessibilityLabel="Непрочитано" style={[local.unread, { backgroundColor: palette.accent }]} />}
|
||||||
</View>
|
</View>
|
||||||
|
<View style={local.controls}>
|
||||||
|
{onPrevious && total && total > 1 ? (
|
||||||
|
<Pressable accessibilityLabel="Предыдущее уведомление" accessibilityRole="button" hitSlop={8} onPress={onPrevious} style={local.controlButton}>
|
||||||
|
<Feather name="chevron-left" size={16} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
{position !== undefined && total && total > 1 ? (
|
||||||
|
<Text accessibilityLabel={`${position} из ${total}`} style={local.position}>{position}/{total}</Text>
|
||||||
|
) : null}
|
||||||
|
{onNext && total && total > 1 ? (
|
||||||
|
<Pressable accessibilityLabel="Следующее уведомление" accessibilityRole="button" hitSlop={8} onPress={onNext} style={local.controlButton}>
|
||||||
|
<Feather name="chevron-right" size={16} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
{onHide && (
|
{onHide && (
|
||||||
<Pressable accessibilityLabel="Скрыть уведомление" accessibilityRole="button" hitSlop={10} onPress={onHide}>
|
<Pressable accessibilityLabel="Скрыть уведомление" accessibilityRole="button" hitSlop={8} onPress={onHide} style={local.hideButton}>
|
||||||
<Feather name="x" size={18} color={palette.foreground} />
|
<Feather name="x" size={16} color={colors.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={local.body}>
|
||||||
|
<View style={[local.iconWrap, { backgroundColor: palette.accentBackground }]}>
|
||||||
|
<Feather name={notificationIcon(type?.icon_code)} size={17} color={palette.accent} />
|
||||||
|
</View>
|
||||||
|
<View style={local.content}>
|
||||||
<Text style={[local.title, { color: palette.foreground }]}>{item.header}</Text>
|
<Text style={[local.title, { color: palette.foreground }]}>{item.header}</Text>
|
||||||
{item.text ? <Text numberOfLines={compact ? 2 : 3} style={[local.text, { color: palette.foreground }]}>{item.text}</Text> : null}
|
{item.text ? <Text numberOfLines={compact ? 2 : 3} style={local.text}>{item.text}</Text> : null}
|
||||||
{deadline ? (
|
{deadline ? (
|
||||||
<View style={local.deadline}>
|
<View style={local.deadline}>
|
||||||
<Feather name="clock" size={14} color={palette.foreground} />
|
<Feather name="clock" size={13} color={colors.mutedForeground} />
|
||||||
<Text style={[local.meta, { color: palette.foreground }]}>до {new Date(deadline).toLocaleDateString("ru-RU")}</Text>
|
<Text style={local.meta}>до {new Date(deadline).toLocaleDateString("ru-RU")}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{price ? (
|
{price ? (
|
||||||
<View style={local.priceRow}>
|
<View style={local.priceRow}>
|
||||||
<Text style={[local.price, { color: palette.foreground }]}>{price}</Text>
|
<Text style={[local.price, { color: palette.accent }]}>{price}</Text>
|
||||||
{oldPrice ? <Text style={[local.oldPrice, { color: palette.foreground }]}>{oldPrice}</Text> : null}
|
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onPress={onCta}
|
onPress={onCta}
|
||||||
style={({ pressed }) => [local.cta, { backgroundColor: palette.accent }, pressed && local.pressed, disabled && local.disabled]}
|
style={({ pressed }) => [local.cta, pressed && local.pressed, disabled && local.disabled]}
|
||||||
>
|
>
|
||||||
<Text style={local.ctaText}>{type?.cta_text ?? "Подробнее →"}</Text>
|
<Text style={[local.ctaText, { color: palette.accent }]}>{type?.cta_text ?? "Подробнее →"}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -73,26 +106,31 @@ export function NotificationCard({
|
|||||||
const local = StyleSheet.create({
|
const local = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
width: 326,
|
width: 326,
|
||||||
minHeight: 190,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: radii.xl,
|
borderRadius: radii.xl,
|
||||||
padding: spacing.lg,
|
overflow: "hidden",
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
},
|
||||||
compact: { width: "100%", minHeight: 0 },
|
compact: { width: "100%", minHeight: 0 },
|
||||||
headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
headerRow: { minHeight: 42, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||||
labelRow: { flexDirection: "row", alignItems: "center", gap: spacing.sm, flexShrink: 1 },
|
labelBadge: { minHeight: 24, borderRadius: radii.full, paddingHorizontal: 10, flexDirection: "row", alignItems: "center", gap: 6, flexShrink: 1 },
|
||||||
label: { fontSize: 12, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.4 },
|
label: { fontSize: 11, fontWeight: "700", textTransform: "uppercase", letterSpacing: 0.5 },
|
||||||
unread: { width: 8, height: 8, borderRadius: radii.full },
|
unread: { width: 8, height: 8, borderRadius: radii.full },
|
||||||
title: { fontSize: 18, lineHeight: 23, fontWeight: "600" },
|
controls: { flexDirection: "row", alignItems: "center" },
|
||||||
text: { fontSize: 14, lineHeight: 20, opacity: 0.88 },
|
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 },
|
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 },
|
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
|
||||||
price: { fontSize: 18, fontWeight: "700" },
|
price: { fontSize: 14, fontWeight: "700" },
|
||||||
oldPrice: { fontSize: 13, textDecorationLine: "line-through", opacity: 0.65 },
|
oldPrice: { fontSize: 11, color: colors.mutedForeground, textDecorationLine: "line-through" },
|
||||||
cta: { alignSelf: "flex-start", minHeight: 38, justifyContent: "center", borderRadius: radii.md, paddingHorizontal: spacing.md, marginTop: "auto" },
|
cta: { minHeight: 39, justifyContent: "center", borderTopWidth: 1, borderTopColor: "rgba(0, 0, 0, 0.06)", paddingHorizontal: 14 },
|
||||||
ctaText: { color: "#ffffff", fontSize: 14, fontWeight: "600" },
|
ctaText: { fontSize: 12, fontWeight: "700" },
|
||||||
pressed: { opacity: 0.78 },
|
pressed: { opacity: 0.78 },
|
||||||
disabled: { opacity: 0.5 },
|
disabled: { opacity: 0.5 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
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 { notificationApi, notificationKeys } from "../notification-api";
|
||||||
import { useNotificationAction } from "../notification-actions";
|
import { useNotificationAction } from "../notification-actions";
|
||||||
import { typeMap } from "../notification-presenter";
|
import { typeMap } from "../notification-presenter";
|
||||||
import { colors, spacing } from "../theme";
|
import { layout, spacing } from "../theme";
|
||||||
import type { NotificationItem } from "../types";
|
import type { NotificationItem } from "../types";
|
||||||
import { ErrorNotice, Loading, styles } from "../ui";
|
import { ErrorNotice, Loading } from "../ui";
|
||||||
import { NotificationCard } from "./NotificationCard";
|
import { NotificationCard } from "./NotificationCard";
|
||||||
|
|
||||||
export function NotificationCarousel({
|
export function NotificationCarousel({
|
||||||
@@ -24,6 +24,9 @@ export function NotificationCarousel({
|
|||||||
const list = useRef<FlatList<NotificationItem>>(null);
|
const list = useRef<FlatList<NotificationItem>>(null);
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [actionError, setActionError] = useState<unknown>();
|
const [actionError, setActionError] = useState<unknown>();
|
||||||
|
const [hiddenGuestIds, setHiddenGuestIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const window = useWindowDimensions();
|
||||||
|
const cardWidth = Math.max(0, Math.min(window.width, layout.maxWidth) - spacing.lg * 2);
|
||||||
const catalog = useQuery({
|
const catalog = useQuery({
|
||||||
queryKey: notificationKeys.catalog,
|
queryKey: notificationKeys.catalog,
|
||||||
queryFn: notificationApi.catalog,
|
queryFn: notificationApi.catalog,
|
||||||
@@ -45,7 +48,14 @@ export function NotificationCarousel({
|
|||||||
},
|
},
|
||||||
onError: setActionError,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!autoplay || data.length < 2) return;
|
if (!autoplay || data.length < 2) return;
|
||||||
@@ -59,6 +69,13 @@ export function NotificationCarousel({
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [autoplay, autoplayIntervalMs, data.length]);
|
}, [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) {
|
if (notifications.isLoading || catalog.isLoading) {
|
||||||
return <View style={local.state}><Loading /></View>;
|
return <View style={local.state}><Loading /></View>;
|
||||||
}
|
}
|
||||||
@@ -76,17 +93,21 @@ export function NotificationCarousel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={local.section}>
|
<View style={local.section}>
|
||||||
<Text accessibilityRole="header" style={[styles.heading, local.heading]}>Важное для вас</Text>
|
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={list}
|
ref={list}
|
||||||
horizontal
|
horizontal
|
||||||
data={data}
|
data={data}
|
||||||
|
decelerationRate="fast"
|
||||||
|
pagingEnabled
|
||||||
|
snapToInterval={cardWidth}
|
||||||
|
snapToAlignment="start"
|
||||||
|
getItemLayout={(_, index) => ({ length: cardWidth, offset: cardWidth * index, index })}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={local.content}
|
style={local.carousel}
|
||||||
ItemSeparatorComponent={() => <View style={{ width: spacing.md }} />}
|
|
||||||
onMomentumScrollEnd={(event) => {
|
onMomentumScrollEnd={(event) => {
|
||||||
const width = event.nativeEvent.layoutMeasurement.width;
|
if (cardWidth > 0) {
|
||||||
if (width > 0) setActiveIndex(Math.round(event.nativeEvent.contentOffset.x / width));
|
setActiveIndex(Math.min(data.length - 1, Math.round(event.nativeEvent.contentOffset.x / cardWidth)));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
@@ -94,16 +115,22 @@ export function NotificationCarousel({
|
|||||||
item={item}
|
item={item}
|
||||||
type={byCode.get(item.notification_type)}
|
type={byCode.get(item.notification_type)}
|
||||||
onCta={() => void action(item, byCode.get(item.notification_type))}
|
onCta={() => 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}
|
showsHorizontalScrollIndicator={false}
|
||||||
/>
|
/>
|
||||||
{data.length > 1 && (
|
|
||||||
<View style={local.dots} accessibilityLabel={`${activeIndex + 1} из ${data.length}`}>
|
|
||||||
{data.map((item, index) => <View key={item.id} style={[local.dot, index === activeIndex && local.dotActive]} />)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{Boolean(actionError) && <View style={local.error}><ErrorNotice error={actionError} /></View>}
|
{Boolean(actionError) && <View style={local.error}><ErrorNotice error={actionError} /></View>}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -111,11 +138,7 @@ export function NotificationCarousel({
|
|||||||
|
|
||||||
const local = StyleSheet.create({
|
const local = StyleSheet.create({
|
||||||
section: { paddingVertical: spacing.md },
|
section: { paddingVertical: spacing.md },
|
||||||
heading: { paddingHorizontal: spacing.lg, marginBottom: spacing.sm },
|
carousel: { marginHorizontal: spacing.lg },
|
||||||
content: { paddingHorizontal: spacing.lg },
|
|
||||||
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
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 },
|
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function PopularQuestionsList({ questions, onSelect }: { questions: Quest
|
|||||||
style={({ pressed }) => [styles.item, pressed && styles.pressed]}
|
style={({ pressed }) => [styles.item, pressed && styles.pressed]}
|
||||||
>
|
>
|
||||||
<View style={styles.iconWrap}>
|
<View style={styles.iconWrap}>
|
||||||
<Feather name={icons[index % icons.length]} size={16} color={colors.primary} />
|
<Feather name={icons[index % icons.length]} size={17} color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.itemText}>{question.text}</Text>
|
<Text style={styles.itemText}>{question.text}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -54,6 +54,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "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 },
|
pressed: { backgroundColor: colors.accent },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,137 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import React from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Linking, Pressable, StyleSheet, Text } from "react-native";
|
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";
|
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 (
|
||||||
|
<View accessibilityLabel="Есть непрочитанные сообщения" style={styles.badgePosition}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.pulse,
|
||||||
|
{
|
||||||
|
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.75, 0] }),
|
||||||
|
transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 2] }) }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>!</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
const call = () => {
|
||||||
if (phone) void Linking.openURL(`tel:${phone}`);
|
if (phone) void Linking.openURL(`tel:${phone}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessibilityLabel="Чат"
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => router.push("/dialogs")}
|
||||||
|
style={({ pressed }) => [styles.button, styles.chatButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Feather name="message-circle" size={17} color={colors.primaryForeground} />
|
||||||
|
{hasUnreadMessages ? <UnreadMessageBadge /> : null}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.text, styles.chatText]}>Чат</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Оператор"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
disabled={!phone}
|
disabled={!phone}
|
||||||
onPress={call}
|
onPress={call}
|
||||||
style={({ pressed }) => [styles.button, pressed && styles.pressed, !phone && styles.disabled]}
|
style={({ pressed }) => [styles.button, styles.operatorButton, pressed && styles.pressed, !phone && styles.disabled]}
|
||||||
>
|
>
|
||||||
<Feather name="headphones" size={16} color={colors.secondaryForeground} />
|
<Feather name="headphones" size={17} color={colors.secondaryForeground} />
|
||||||
<Text style={styles.text}>Связь с оператором</Text>
|
<Text style={styles.text}>Оператор</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
button: {
|
button: {
|
||||||
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
marginHorizontal: spacing.lg,
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderRadius: radii.lg,
|
borderRadius: radii.lg,
|
||||||
|
},
|
||||||
|
chatButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
operatorButton: {
|
||||||
backgroundColor: colors.secondary,
|
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 },
|
pressed: { opacity: 0.8 },
|
||||||
disabled: { opacity: 0.5 },
|
disabled: { opacity: 0.5 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,20 +5,54 @@ export type NotificationPalette = {
|
|||||||
background: string;
|
background: string;
|
||||||
foreground: string;
|
foreground: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
|
accentBackground: string;
|
||||||
|
border: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const neutral: NotificationPalette = {
|
const neutral: NotificationPalette = {
|
||||||
background: "#f3f3f5",
|
background: "#f3f3f5",
|
||||||
foreground: "#252525",
|
foreground: "#252525",
|
||||||
accent: "#030213",
|
accent: "#030213",
|
||||||
|
accentBackground: "#e4e4e8",
|
||||||
|
border: "#d7d7dc",
|
||||||
};
|
};
|
||||||
|
|
||||||
const palettes: Record<string, NotificationPalette> = {
|
const palettes: Record<string, NotificationPalette> = {
|
||||||
critical: { background: "#feecef", foreground: "#7f1d1d", accent: "#d4183d" },
|
critical: {
|
||||||
warning: { background: "#fff7df", foreground: "#713f12", accent: "#ca8a04" },
|
background: "#fdecef",
|
||||||
success: { background: "#eaf8ee", foreground: "#14532d", accent: "#16a34a" },
|
foreground: "#252525",
|
||||||
info: { background: "#eaf2ff", foreground: "#1e3a8a", accent: "#2563eb" },
|
accent: "#d4183d",
|
||||||
promo: { background: "#f4edff", foreground: "#4c1d95", accent: "#7c3aed" },
|
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,
|
neutral,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ export type TokenSet = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DialogStatus = "open" | "waiting_for_company" | "waiting_for_client" | "closed";
|
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 = {
|
export type Attachment = {
|
||||||
attachment_id: string;
|
attachment_id: string;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
|
|||||||
@@ -55,17 +55,18 @@ test("гостевой экран загружает публичный конт
|
|||||||
await expect(page.getByRole("button", { name: "Голосовое сообщение" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Голосовое сообщение" })).toHaveCount(0);
|
||||||
await expect(page.getByRole("button", { name: "Прикрепить файл" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Прикрепить файл" })).toBeVisible();
|
||||||
await expect(page.getByText("Продление патента через 14 дней")).toHaveCount(0);
|
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("+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.goto("/");
|
||||||
await page.getByRole("link", { name: "История" }).click();
|
await page.getByRole("link", { name: "Уведомления" }).click();
|
||||||
await expect(page).toHaveURL(/\/dialogs$/);
|
await expect(page).toHaveURL(/\/notifications$/);
|
||||||
await expect(page.getByRole("heading", { name: "Чат" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Центр уведомлений" })).toBeVisible();
|
||||||
await expect(page.getByText(/доступен после авторизации/)).toBeVisible();
|
await expect(page.getByText(/доступны после входа/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("первое сообщение требует обязательные согласия", async ({ page }) => {
|
test("первое сообщение требует обязательные согласия", async ({ page }) => {
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user