Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,71 @@
import { Feather } from "@expo/vector-icons";
import React, { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { colors, radii, spacing } from "../theme";
type Item = { title: string; value?: string; action?: string; icon?: keyof typeof Feather.glyphMap };
export function AccordionSection({
title,
defaultOpen = false,
items,
onItemPress,
}: {
title: string;
defaultOpen?: boolean;
items: Item[];
onItemPress?: (index: number) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<View style={styles.section}>
<Pressable
accessibilityRole="button"
onPress={() => setOpen((value) => !value)}
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
>
<Text style={styles.triggerText}>{title}</Text>
<Feather name={open ? "chevron-up" : "chevron-down"} size={20} color={colors.mutedForeground} />
</Pressable>
{open && (
<View style={styles.content}>
{items.map((item, index) => (
<Pressable
key={`${item.title}-${index}`}
accessibilityRole={onItemPress ? "button" : "text"}
disabled={!onItemPress}
onPress={() => onItemPress?.(index)}
style={({ pressed }) => [styles.item, index > 0 && styles.itemBorder, pressed && onItemPress && styles.pressed]}
>
{item.icon && (
<View style={styles.iconWrap}>
<Feather name={item.icon} size={18} color={colors.mutedForeground} />
</View>
)}
<View style={styles.itemBody}>
<Text style={styles.itemTitle}>{item.title}</Text>
{item.value ? <Text style={styles.itemValue}>{item.value}</Text> : null}
</View>
{item.action && <Feather name="chevron-right" size={20} color={colors.mutedForeground} />}
</Pressable>
))}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
section: { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.border, borderRadius: radii.lg, overflow: "hidden", marginBottom: spacing.md },
trigger: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
triggerText: { fontSize: 14, fontWeight: "500", color: colors.foreground },
content: { borderTopWidth: 1, borderTopColor: colors.border },
item: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.md },
itemBorder: { borderTopWidth: 1, borderTopColor: colors.border },
iconWrap: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.muted, alignItems: "center", justifyContent: "center" },
itemBody: { flex: 1 },
itemTitle: { fontSize: 14, fontWeight: "500", color: colors.foreground },
itemValue: { fontSize: 12, color: colors.mutedForeground, marginTop: 2 },
pressed: { backgroundColor: colors.accent },
});
@@ -0,0 +1,92 @@
import { Feather } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useApp } from "../app-context";
import { notificationApi, notificationKeys } from "../notification-api";
import { colors, radii, spacing } from "../theme";
export function AppHeader(_props: { guestLabel?: string | undefined }) {
const { authStatus } = useApp();
const authenticated = authStatus === "authenticated";
const counter = useQuery({
queryKey: notificationKeys.counter,
queryFn: notificationApi.counter,
enabled: authenticated,
});
const unread = counter.data?.unread_count ?? 0;
return (
<View style={styles.header}>
<Link href="/notifications" asChild>
<Pressable accessibilityLabel="Уведомления" accessibilityRole="link" style={({ pressed }) => [styles.centerLink, pressed && styles.pressed]}>
<View>
<Feather name="bell" size={20} color={colors.foreground} />
{authenticated && unread > 0 && (
<View accessibilityLabel={`${unread} непрочитанных уведомлений`} style={styles.notificationBadge}>
<Text style={styles.notificationBadgeText}>{unread > 99 ? "99+" : unread}</Text>
</View>
)}
</View>
</Pressable>
</Link>
<Link href="/profile" asChild>
<Pressable accessibilityRole="link" accessibilityLabel="Личный кабинет" style={({ pressed }) => [styles.avatar, pressed && styles.pressed]}>
<Feather name="user" size={20} color={colors.mutedForeground} />
<View style={styles.dot} />
</Pressable>
</Link>
</View>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
centerLink: { width: 40, height: 40, alignItems: "center", justifyContent: "center" },
notificationBadge: {
position: "absolute",
top: -9,
right: -12,
minWidth: 18,
height: 18,
borderRadius: radii.full,
paddingHorizontal: 4,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.destructive,
borderWidth: 2,
borderColor: colors.background,
},
notificationBadgeText: { color: colors.primaryForeground, fontSize: 9, fontWeight: "700" },
avatar: {
width: 40,
height: 40,
borderRadius: radii.full,
backgroundColor: colors.muted,
alignItems: "center",
justifyContent: "center",
},
dot: {
position: "absolute",
top: 2,
right: 2,
width: 12,
height: 12,
borderRadius: radii.full,
backgroundColor: colors.primary,
borderWidth: 2,
borderColor: colors.background,
},
pressed: { opacity: 0.7 },
});
@@ -0,0 +1,135 @@
import React, { useEffect, useRef } from "react";
import { Animated, StyleSheet, Text, View } from "react-native";
import { colors, radii, spacing } from "../theme";
export function AuthLoadingView() {
const pulse = useRef(new Animated.Value(0)).current;
const dots = useRef([
new Animated.Value(0),
new Animated.Value(0),
new Animated.Value(0),
]).current;
useEffect(() => {
const pulseAnimation = Animated.loop(
Animated.timing(pulse, {
toValue: 1,
duration: 1800,
useNativeDriver: true,
}),
);
const dotAnimations = dots.map((dot, index) => Animated.loop(
Animated.sequence([
Animated.delay(index * 200),
Animated.timing(dot, { toValue: -8, duration: 280, useNativeDriver: true }),
Animated.timing(dot, { toValue: 0, duration: 280, useNativeDriver: true }),
Animated.delay((2 - index) * 200 + 240),
]),
));
pulseAnimation.start();
dotAnimations.forEach((animation) => animation.start());
return () => {
pulseAnimation.stop();
dotAnimations.forEach((animation) => animation.stop());
};
}, [dots, pulse]);
return (
<View style={styles.screen}>
<View style={styles.center}>
<View style={styles.logoArea}>
<Animated.View
style={[
styles.pulseRing,
{
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] }),
transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.45] }) }],
},
]}
/>
<View style={styles.innerRing} />
<View style={styles.logo}>
<Text style={styles.logoText}>HAN</Text>
</View>
</View>
<View style={styles.dots}>
{dots.map((dot, index) => (
<Animated.View key={index} style={[styles.dot, { transform: [{ translateY: dot }] }]} />
))}
</View>
<Text accessibilityRole="header" style={styles.title}>Выполняем вход</Text>
<Text style={styles.subtitle}>Проверяем данные...</Text>
</View>
<Text style={styles.footer}>
HAN ваш персональный консультант по вопросам миграции в России
</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
minHeight: 560,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 48,
backgroundColor: colors.background,
},
center: { alignItems: "center" },
logoArea: {
width: 116,
height: 116,
alignItems: "center",
justifyContent: "center",
marginBottom: spacing.xl,
},
pulseRing: {
position: "absolute",
width: 80,
height: 80,
borderWidth: 2,
borderColor: colors.primary,
borderRadius: radii.full,
},
innerRing: {
position: "absolute",
width: 96,
height: 96,
borderWidth: 2,
borderColor: "rgba(3, 2, 19, 0.12)",
borderRadius: radii.full,
},
logo: {
width: 80,
height: 80,
alignItems: "center",
justifyContent: "center",
borderRadius: radii.full,
backgroundColor: colors.primary,
shadowColor: colors.primary,
shadowOpacity: 0.2,
shadowRadius: 14,
shadowOffset: { width: 0, height: 8 },
},
logoText: { color: colors.primaryForeground, fontSize: 20, fontWeight: "700", letterSpacing: 1.5 },
dots: { flexDirection: "row", gap: 6, height: 24, alignItems: "center", marginBottom: 28 },
dot: { width: 8, height: 8, borderRadius: radii.full, backgroundColor: colors.primary },
title: { color: colors.foreground, fontSize: 20, fontWeight: "600", marginBottom: spacing.sm },
subtitle: { color: colors.mutedForeground, fontSize: 14 },
footer: {
position: "absolute",
right: 32,
bottom: 48,
left: 32,
color: colors.mutedForeground,
fontSize: 12,
lineHeight: 18,
textAlign: "center",
},
});
@@ -0,0 +1,136 @@
import { Feather } from "@expo/vector-icons";
import React, { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { messageFitsLimit } from "../message-text";
import { colors, radii, spacing } from "../theme";
type Props = {
value: string;
onChangeText: (text: string) => void;
onSubmit: () => void;
disabled?: boolean;
sending?: boolean;
onAttach?: () => void;
placeholder?: string;
hint?: string;
inputLabel?: string;
maxLength?: number;
};
export function ChatInputBar({
value,
onChangeText,
onSubmit,
disabled,
sending,
onAttach,
placeholder = "Напишите ваш вопрос...",
hint = "Напишите сообщение или прикрепите документ",
inputLabel = "Сообщение",
maxLength,
}: Props) {
const [focused, setFocused] = useState(false);
const withinLimit = maxLength === undefined || messageFitsLimit(value, maxLength);
const canSend = Boolean(value.trim()) && withinLimit && !disabled && !sending;
const nearLimit = maxLength !== undefined && value.length >= maxLength * 0.9;
return (
<View style={styles.wrapper}>
<View style={[styles.inputBox, focused && styles.inputBoxFocused]}>
<TextInput
accessibilityLabel={inputLabel}
editable={!disabled && !sending}
multiline
onBlur={() => { if (!value.trim()) setFocused(false); }}
onChangeText={onChangeText}
onFocus={() => setFocused(true)}
onKeyPress={(event) => {
if (event.nativeEvent.key !== "Enter") return;
event.preventDefault();
if (canSend) onSubmit();
}}
placeholder={placeholder}
placeholderTextColor={colors.mutedForeground}
returnKeyType="send"
style={[styles.input, focused && styles.inputExpanded]}
value={value}
/>
<View style={styles.actions}>
{onAttach && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Прикрепить файл"
disabled={disabled || sending}
onPress={onAttach}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
<Feather name="paperclip" size={20} color={colors.mutedForeground} />
</Pressable>
)}
<Pressable
accessibilityRole="button"
accessibilityLabel="Отправить"
disabled={!canSend}
onPress={onSubmit}
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && canSend && styles.pressed,
]}
>
<Feather name="send" size={16} color={colors.primaryForeground} />
</Pressable>
</View>
</View>
{maxLength !== undefined ? (
<Text
accessibilityLiveRegion={withinLimit ? "none" : "polite"}
style={[
styles.counter,
nearLimit && styles.counterWarning,
!withinLimit && styles.counterError,
]}
>
{value.length}/{maxLength}
</Text>
) : null}
{hint ? <Text style={styles.hint}>{hint}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrapper: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm, paddingBottom: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.background },
inputBox: {
flexDirection: "row",
alignItems: "flex-end",
gap: spacing.sm,
backgroundColor: colors.card,
borderWidth: 2,
borderColor: "rgba(3, 2, 19, 0.2)",
borderRadius: radii.xl,
padding: 10,
},
inputBoxFocused: { borderColor: colors.primary },
input: {
flex: 1,
minHeight: 80,
maxHeight: 256,
fontSize: 16,
color: colors.foreground,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm,
backgroundColor: "transparent",
},
inputExpanded: { minHeight: 144 },
actions: { alignItems: "center", justifyContent: "flex-end", gap: spacing.sm },
iconButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
sendButton: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center" },
sendButtonDisabled: { opacity: 0.4 },
counter: { fontSize: 12, color: colors.mutedForeground, textAlign: "right", marginTop: spacing.xs },
counterWarning: { color: colors.warning },
counterError: { color: colors.destructive },
hint: { fontSize: 12, color: colors.mutedForeground, textAlign: "center", marginTop: spacing.sm },
pressed: { opacity: 0.7 },
});
@@ -0,0 +1,44 @@
import { Feather } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { colors, radii, spacing } from "../theme";
export function ChatScreenHeader({ title = "HAN Помощник", subtitle = "Онлайн" }: { title?: string; subtitle?: string }) {
const router = useRouter();
return (
<View style={styles.header}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Назад"
onPress={() => router.replace("/")}
style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
>
<Feather name="arrow-left" size={20} color={colors.foreground} />
</Pressable>
<View style={styles.info}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.background,
},
backButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
info: { flex: 1 },
title: { fontSize: 16, fontWeight: "500", color: colors.foreground },
subtitle: { fontSize: 12, color: colors.mutedForeground },
pressed: { backgroundColor: colors.muted },
});
@@ -0,0 +1,228 @@
import React, { useMemo, useState } from "react";
import { Linking, Pressable, Text, View } from "react-native";
import type { PublicConfig } from "../types";
import { Button, styles } from "../ui";
import { colors, radii, spacing } from "../theme";
type ConsentKey = "personal_data" | "user_agreement" | "marketing";
type ConsentLink = {
label: string;
url: string;
};
type ConsentBlock = {
key: ConsentKey;
text: string;
required: boolean;
links: ConsentLink[];
};
type ConsentConfigItem = PublicConfig["consents"][string];
type Props = {
consents: PublicConfig["consents"] | undefined;
onAccept: (accepted: Record<ConsentKey, boolean>) => void;
onCancel: () => void;
};
const LABELS: Record<ConsentKey, {
text: string;
links: Array<{ label: string; readUrl: (item: ConsentConfigItem) => string | null | undefined }>;
}> = {
personal_data: {
text: "Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных",
links: [
{ label: "Согласие на обработку ПД", readUrl: (item) => item.document_url },
{ label: "Политика обработки ПД", readUrl: (item) => item.privacy_policy_document_url },
],
},
user_agreement: {
text: "Я прочитал и соглашаюсь с Пользовательским соглашением",
links: [{ label: "Пользовательское соглашение", readUrl: (item) => item.document_url }],
},
marketing: {
text: "Я даю своё согласие на получение рекламных и маркетинговых коммуникаций",
links: [{ label: "Условия получения коммуникаций", readUrl: (item) => item.document_url }],
},
};
export function ConsentModal({ consents, onAccept, onCancel }: Props) {
const blocks = useMemo(() => buildBlocks(consents), [consents]);
const [checked, setChecked] = useState<Record<ConsentKey, boolean>>({
personal_data: false,
user_agreement: false,
marketing: false,
});
const requiredDone = blocks.length > 0
&& blocks.filter((block) => block.required).every((block) => checked[block.key]);
const toggle = (key: ConsentKey) => {
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
};
return (
<View accessibilityViewIsModal style={styles.modalBackdrop}>
<View style={[styles.modal, { maxWidth: 400, gap: spacing.lg }]}>
<View style={{ gap: spacing.sm }}>
<View style={consentStyles.iconWrap}>
<Text style={consentStyles.iconGlyph}></Text>
</View>
<Text accessibilityRole="header" style={[styles.title, { fontSize: 22 }]}>
Перед началом работы
</Text>
<Text style={styles.muted}>
Для использования приложения ознакомьтесь со следующими документами и предоставьте необходимые согласия
</Text>
</View>
<View style={{ gap: spacing.md }}>
{blocks.map((block) => {
const isChecked = checked[block.key];
return (
<Pressable
key={block.key}
accessibilityRole="checkbox"
accessibilityState={{ checked: isChecked }}
onPress={() => toggle(block.key)}
style={[
consentStyles.card,
isChecked ? consentStyles.cardChecked : null,
]}
>
<View style={consentStyles.cardRow}>
<View
style={[
consentStyles.checkbox,
isChecked ? consentStyles.checkboxChecked : null,
]}
>
{isChecked ? <Text style={consentStyles.checkMark}></Text> : null}
</View>
<View style={{ flex: 1, gap: spacing.sm }}>
<Text style={styles.text}>
{block.text}
{block.required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
</Text>
{block.links.length > 0 ? (
<View style={consentStyles.links}>
{block.links.map((link) => (
<Pressable
key={link.url}
accessibilityRole="link"
onPress={(event) => {
event?.stopPropagation?.();
void Linking.openURL(link.url);
}}
>
<Text style={consentStyles.link}> {link.label}</Text>
</Pressable>
))}
</View>
) : null}
</View>
</View>
</Pressable>
);
})}
<Text style={styles.muted}>
<Text style={{ color: colors.destructive }}>*</Text>
{" — обязательные согласия"}
</Text>
</View>
<View style={{ gap: spacing.sm }}>
<Button
title="Продолжить"
disabled={!requiredDone}
onPress={() => onAccept(checked)}
/>
<Button title="Отмена" secondary onPress={onCancel} />
</View>
</View>
</View>
);
}
function buildBlocks(consents: PublicConfig["consents"] | undefined): ConsentBlock[] {
return (Object.keys(LABELS) as ConsentKey[]).flatMap((key) => {
const item = consents?.[key];
if (!item) return [];
const meta = LABELS[key];
const links = meta.links.flatMap((link) => {
const url = link.readUrl(item);
return url ? [{ label: link.label, url }] : [];
});
return [{
key,
text: meta.text,
required: item.required,
links,
}];
});
}
const consentStyles = {
iconWrap: {
width: 48,
height: 48,
borderRadius: 16,
backgroundColor: "rgba(3, 2, 19, 0.08)",
alignItems: "center" as const,
justifyContent: "center" as const,
marginBottom: spacing.xs,
},
iconGlyph: {
color: colors.primary,
fontSize: 22,
fontWeight: "700" as const,
},
card: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.xl,
backgroundColor: colors.card,
padding: spacing.md,
},
cardChecked: {
borderColor: "rgba(3, 2, 19, 0.3)",
backgroundColor: "rgba(3, 2, 19, 0.04)",
},
cardRow: {
flexDirection: "row" as const,
alignItems: "flex-start" as const,
gap: spacing.md,
},
checkbox: {
width: 20,
height: 20,
borderRadius: radii.sm,
borderWidth: 2,
borderColor: colors.border,
backgroundColor: colors.background,
alignItems: "center" as const,
justifyContent: "center" as const,
marginTop: 2,
},
checkboxChecked: {
borderColor: colors.primary,
backgroundColor: colors.primary,
},
checkMark: {
color: colors.primaryForeground,
fontSize: 12,
fontWeight: "700" as const,
lineHeight: 14,
},
links: {
flexDirection: "row" as const,
flexWrap: "wrap" as const,
gap: spacing.sm,
},
link: {
color: colors.primary,
fontSize: 12,
textDecorationLine: "underline" as const,
},
};
@@ -0,0 +1,51 @@
import { Feather } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import { colors, radii, spacing } from "../theme";
import { Button, styles } from "../ui";
export function GuestAuthGate({
icon,
title,
description,
}: {
icon: React.ComponentProps<typeof Feather>["name"];
title: string;
description: string;
}) {
const router = useRouter();
return (
<View style={local.gate}>
<View style={local.icon}>
<Feather name={icon} size={34} color={colors.primaryForeground} />
</View>
<Text accessibilityRole="header" style={[styles.title, local.centerText]}>{title}</Text>
<Text style={[styles.text, local.centerText]}>{description}</Text>
<Button
title="Авторизоваться"
onPress={() => router.replace({ pathname: "/", params: { authorize: "1" } })}
/>
</View>
);
}
const local = StyleSheet.create({
gate: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: spacing.md,
padding: spacing.xl,
},
icon: {
width: 68,
height: 68,
borderRadius: radii.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.primary,
},
centerText: { textAlign: "center" },
});
@@ -0,0 +1,35 @@
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import { colors, radii, spacing } from "../theme";
export function HanLogo({ subtitle }: { subtitle?: string }) {
return (
<View style={styles.container}>
<View style={styles.logoBox}>
<Text style={styles.logoText}>HAN</Text>
</View>
<View style={styles.textBlock}>
<Text accessibilityRole="header" style={styles.title}>Привет! Я HAN</Text>
<Text style={styles.subtitle}>
{subtitle ?? "Помощник по документам и жизни в России"}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flexDirection: "row", alignItems: "center", gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
logoBox: {
width: 48,
height: 48,
borderRadius: radii.xl,
backgroundColor: colors.primary,
alignItems: "center",
justifyContent: "center",
},
logoText: { color: colors.primaryForeground, fontSize: 14, fontWeight: "700", letterSpacing: 0.5 },
textBlock: { flex: 1 },
title: { fontSize: 16, fontWeight: "500", color: colors.foreground, marginBottom: 2 },
subtitle: { fontSize: 12, color: colors.mutedForeground, lineHeight: 16 },
});
@@ -0,0 +1,147 @@
import { Feather } from "@expo/vector-icons";
import React, { useEffect, useState } from "react";
import { ActivityIndicator, Image, Linking, Pressable, StyleSheet, Text, View } from "react-native";
import type { Attachment, Message } from "../types";
import { colors, radii, spacing } from "../theme";
const statusLabel: Record<string, string> = {
accepted: "Принято",
delivered: "Доставлено",
failed: "Ошибка",
rejected: "Отклонено",
};
type Props = {
message: Message;
getAttachmentUrl?: ((attachmentId: string) => Promise<string>) | undefined;
onAttachmentError?: ((error: unknown) => void) | undefined;
};
export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }: Props) {
const isClient = message.sender_type === "client";
const time = new Date(message.created_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return (
<View style={[styles.row, isClient ? styles.rowClient : styles.rowCompany]}>
<View style={[styles.bubble, isClient ? styles.bubbleClient : styles.bubbleCompany]}>
{message.content_kind === "text" ? (
<Text style={[styles.text, isClient && styles.textClient]}>{message.text}</Text>
) : message.attachments.length ? (
<View style={styles.attachments}>
{message.attachments.map((attachment) => (
<AttachmentPreview
key={attachment.attachment_id}
attachment={attachment}
getUrl={getAttachmentUrl}
isClient={isClient}
onError={onAttachmentError}
/>
))}
</View>
) : (
<View style={styles.fileFallback}>
<Feather name="file" size={28} color={isClient ? colors.primaryForeground : colors.primary} />
</View>
)}
<Text style={[styles.time, isClient ? styles.timeClient : styles.timeMuted]}>
{time}
{isClient ? ` · ${statusLabel[message.delivery_status] ?? message.delivery_status}` : ""}
</Text>
</View>
</View>
);
}
function AttachmentPreview({ attachment, getUrl, isClient, onError }: {
attachment: Attachment;
getUrl?: ((attachmentId: string) => Promise<string>) | undefined;
isClient: boolean;
onError?: ((error: unknown) => void) | undefined;
}) {
const [previewUrl, setPreviewUrl] = useState<string>();
const [previewFailed, setPreviewFailed] = useState(false);
const isImage = attachment.mime_type.startsWith("image/");
useEffect(() => {
if (!isImage || !getUrl) return;
let active = true;
void getUrl(attachment.attachment_id)
.then((url) => { if (active) setPreviewUrl(url); })
.catch(() => { if (active) setPreviewFailed(true); });
return () => { active = false; };
}, [attachment.attachment_id, getUrl, isImage]);
const open = async () => {
if (!getUrl) return;
try {
const url = previewUrl ?? await getUrl(attachment.attachment_id);
if (typeof window !== "undefined") window.location.assign(url);
else await Linking.openURL(url);
} catch (error) {
onError?.(error);
}
};
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Скачать файл ${attachment.file_name}`}
onPress={() => void open()}
style={({ pressed }) => [styles.attachmentButton, pressed && styles.pressed]}
>
{isImage && !previewFailed ? (
previewUrl ? (
<Image
accessibilityLabel={attachment.file_name}
resizeMode="cover"
source={{ uri: previewUrl }}
style={styles.previewImage}
/>
) : (
<View style={styles.previewLoading}>
<ActivityIndicator color={isClient ? colors.primaryForeground : colors.primary} />
</View>
)
) : (
<View style={[styles.fileCard, isClient && styles.fileCardClient]}>
<Feather name="file-text" size={30} color={isClient ? colors.primaryForeground : colors.primary} />
<Text numberOfLines={2} style={[styles.fileName, isClient && styles.textClient]}>
{attachment.file_name}
</Text>
</View>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
row: { flexDirection: "row", marginBottom: spacing.lg },
rowClient: { justifyContent: "flex-end" },
rowCompany: { justifyContent: "flex-start" },
bubble: { maxWidth: "75%", borderRadius: radii.xl, paddingHorizontal: spacing.lg, paddingVertical: 10 },
bubbleClient: { backgroundColor: colors.primary },
bubbleCompany: { backgroundColor: colors.muted },
text: { fontSize: 14, color: colors.foreground, lineHeight: 20 },
textClient: { color: colors.primaryForeground },
time: { fontSize: 12, marginTop: 4 },
timeClient: { color: "rgba(255,255,255,0.7)" },
timeMuted: { color: colors.mutedForeground },
attachments: { gap: spacing.sm },
attachmentButton: { borderRadius: radii.lg, overflow: "hidden" },
previewImage: { width: 190, height: 128, borderRadius: radii.lg, backgroundColor: colors.inputBackground },
previewLoading: { width: 190, height: 128, alignItems: "center", justifyContent: "center" },
fileCard: {
width: 190,
minHeight: 72,
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
padding: spacing.md,
borderRadius: radii.lg,
backgroundColor: colors.card,
},
fileCardClient: { backgroundColor: "rgba(255,255,255,0.14)" },
fileName: { flex: 1, fontSize: 13, lineHeight: 18, color: colors.foreground },
fileFallback: { width: 72, height: 72, alignItems: "center", justifyContent: "center" },
pressed: { opacity: 0.75 },
});
@@ -0,0 +1,136 @@
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 { colors, radii, spacing } from "../theme";
import type { NotificationItem, NotificationType } from "../types";
export function NotificationCard({
item,
type,
onCta,
onHide,
onNext,
onPrevious,
position,
total,
width,
compact = false,
disabled = false,
}: {
item: NotificationItem;
type: NotificationType | undefined;
onCta: () => void;
onHide?: () => void;
onNext?: () => void;
onPrevious?: () => void;
position?: number;
total?: number;
width?: number;
compact?: boolean;
disabled?: boolean;
}) {
const palette = notificationPalette(type?.color_token);
const price = formatNotificationPrice(item.price);
const oldPrice = formatNotificationPrice(item.old_price);
const deadline = item.details?.deadline;
return (
<View style={[
local.card,
compact && local.compact,
width !== undefined && { width },
{ backgroundColor: palette.background, borderColor: palette.border },
]}>
<View style={local.headerRow}>
<View style={[local.labelBadge, { backgroundColor: palette.accentBackground }]}>
<Text style={[local.label, { color: palette.accent }]}>{type?.label ?? "Уведомление"}</Text>
{type?.countable && item.is_read === false && <View accessibilityLabel="Непрочитано" style={[local.unread, { backgroundColor: palette.accent }]} />}
</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 && (
<Pressable accessibilityLabel="Скрыть уведомление" accessibilityRole="button" hitSlop={8} onPress={onHide} style={local.hideButton}>
<Feather name="x" size={16} color={colors.mutedForeground} />
</Pressable>
)}
</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>
{item.text ? <Text numberOfLines={compact ? 2 : 3} style={local.text}>{item.text}</Text> : null}
{deadline ? (
<View style={local.deadline}>
<Feather name="clock" size={13} color={colors.mutedForeground} />
<Text style={local.meta}>до {new Date(deadline).toLocaleDateString("ru-RU")}</Text>
</View>
) : null}
{price ? (
<View style={local.priceRow}>
<Text style={[local.price, { color: palette.accent }]}>{price}</Text>
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
</View>
) : null}
</View>
</View>
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onCta}
style={({ pressed }) => [local.cta, pressed && local.pressed, disabled && local.disabled]}
>
<Text style={[local.ctaText, { color: palette.accent }]}>{type?.cta_text ?? "Подробнее →"}</Text>
</Pressable>
</View>
);
}
const local = StyleSheet.create({
card: {
width: 326,
borderWidth: 1,
borderRadius: radii.xl,
overflow: "hidden",
},
compact: { width: "100%", minHeight: 0 },
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 },
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: 11, color: colors.mutedForeground },
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
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 },
});
@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useEffect, useMemo, useRef, useState } from "react";
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 { layout, spacing } from "../theme";
import type { NotificationItem } from "../types";
import { ErrorNotice, Loading } from "../ui";
import { NotificationCard } from "./NotificationCard";
const CARD_GAP = spacing.sm;
export function NotificationCarousel({
authenticated,
autoplay = false,
autoplayIntervalMs = 5000,
requireAuth,
}: {
authenticated: boolean;
autoplay?: boolean;
autoplayIntervalMs?: number;
requireAuth: (afterAuth?: () => Promise<void>) => void;
}) {
const client = useQueryClient();
const list = useRef<FlatList<NotificationItem>>(null);
const [activeIndex, setActiveIndex] = useState(0);
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 pageWidth = cardWidth + CARD_GAP;
const catalog = useQuery({
queryKey: notificationKeys.catalog,
queryFn: notificationApi.catalog,
staleTime: Infinity,
});
const notifications = useQuery({
queryKey: notificationKeys.home(authenticated),
queryFn: authenticated ? () => notificationApi.list("home") : notificationApi.guestHome,
});
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
const action = useNotificationAction({ authenticated, requireAuth, onError: setActionError });
const hide = useMutation({
mutationFn: notificationApi.hide,
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: notificationKeys.home(true) }),
client.invalidateQueries({ queryKey: notificationKeys.center }),
]);
},
onError: setActionError,
});
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 });
};
const syncActiveIndex = (offset: number) => {
if (pageWidth <= 0) return;
const next = Math.min(data.length - 1, Math.max(0, Math.round(offset / pageWidth)));
setActiveIndex((current) => current === next ? current : next);
};
useEffect(() => {
if (!autoplay || data.length < 2) return;
const timer = setInterval(() => {
setActiveIndex((current) => {
const next = (current + 1) % data.length;
list.current?.scrollToIndex({ index: next, animated: true });
return next;
});
}, Math.max(1000, autoplayIntervalMs));
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 <View style={local.state}><Loading /></View>;
}
if (notifications.error || catalog.error) {
return (
<View style={local.state}>
<ErrorNotice
error={notifications.error ?? catalog.error}
retry={() => { void notifications.refetch(); void catalog.refetch(); }}
/>
</View>
);
}
if (!data.length) return null;
return (
<View style={local.section}>
<FlatList
ref={list}
horizontal
data={data}
decelerationRate="fast"
disableIntervalMomentum
snapToInterval={pageWidth}
snapToAlignment="start"
getItemLayout={(_, index) => ({ length: pageWidth, offset: pageWidth * index, index })}
ItemSeparatorComponent={() => <View style={local.separator} />}
keyExtractor={(item) => item.id}
style={local.carousel}
onScroll={(event) => syncActiveIndex(event.nativeEvent.contentOffset.x)}
scrollEventThrottle={16}
renderItem={({ item }) => (
<NotificationCard
disabled={hide.isPending}
item={item}
type={byCode.get(item.notification_type)}
onCta={() => void action(item, byCode.get(item.notification_type))}
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}
/>
{Boolean(actionError) && <View style={local.error}><ErrorNotice error={actionError} /></View>}
</View>
);
}
const local = StyleSheet.create({
section: { paddingVertical: spacing.md },
carousel: { marginHorizontal: spacing.lg },
separator: { width: CARD_GAP },
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
});
@@ -0,0 +1,59 @@
import { Feather } from "@expo/vector-icons";
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { colors, radii, spacing } from "../theme";
const icons = ["map-pin", "credit-card", "briefcase", "alert-circle"] as const;
type Question = { id?: string; text: string };
export function PopularQuestionsList({ questions, onSelect }: { questions: Question[]; onSelect: (text: string) => void }) {
if (!questions.length) return null;
return (
<View style={styles.container}>
<Text style={styles.heading}>Популярные вопросы</Text>
<View style={styles.list}>
{questions.map((question, index) => (
<Pressable
key={question.id ?? index}
accessibilityRole="button"
onPress={() => onSelect(question.text)}
style={({ pressed }) => [styles.item, pressed && styles.pressed]}
>
<View style={styles.iconWrap}>
<Feather name={icons[index % icons.length]} size={17} color={colors.primary} />
</View>
<Text style={styles.itemText}>{question.text}</Text>
</Pressable>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.background },
heading: { fontSize: 14, fontWeight: "500", color: colors.mutedForeground, marginBottom: 10, paddingHorizontal: 4 },
list: { gap: spacing.sm },
item: {
flexDirection: "row",
alignItems: "center",
gap: 10,
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.lg,
padding: 10,
},
iconWrap: {
width: 28,
height: 28,
borderRadius: radii.full,
backgroundColor: "rgba(3, 2, 19, 0.1)",
alignItems: "center",
justifyContent: "center",
},
itemText: { flex: 1, fontSize: 14, fontWeight: "600", color: colors.foreground },
pressed: { backgroundColor: colors.accent },
});
@@ -0,0 +1,139 @@
import { Feather } from "@expo/vector-icons";
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";
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 || dialog.status === "waiting_for_client",
);
const call = () => {
if (phone) void Linking.openURL(`tel:${phone}`);
};
return (
<View style={styles.row}>
<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"
disabled={!phone}
onPress={call}
style={({ pressed }) => [styles.button, styles.operatorButton, pressed && styles.pressed, !phone && styles.disabled]}
>
<Feather name="headphones" size={17} color={colors.secondaryForeground} />
<Text style={styles.text}>Оператор</Text>
</Pressable>
</View>
);
}
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,
paddingVertical: 10,
borderRadius: radii.lg,
},
chatButton: {
backgroundColor: colors.primary,
},
operatorButton: {
backgroundColor: colors.secondary,
},
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 },
});
@@ -0,0 +1,16 @@
import React from "react";
import { View, StyleSheet } from "react-native";
import { colors, layout } from "../theme";
export function ScreenShell({ children }: { children: React.ReactNode }) {
return (
<View style={styles.outer}>
<View style={styles.inner}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
outer: { flex: 1, backgroundColor: colors.background, alignItems: "center" },
inner: { flex: 1, width: "100%", maxWidth: layout.maxWidth },
});