Накатил человеческий дизайн

This commit is contained in:
mi
2026-07-21 16:01:24 +03:00
parent 9cfc157124
commit 627371b221
35 changed files with 2415 additions and 293 deletions
@@ -55,10 +55,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
if (!getAccessToken()) await completeAuthorization(code, state);
await authApi.bootstrap(consents);
await authApi.startSession("first_launch");
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
setAuthStatus("authenticated");
router.replace("/");
}, [router]);
}, []);
const authorize = useCallback(async (consents: Consents) => {
setAuthStatus("authorizing");
@@ -70,6 +68,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return false;
}
await finishCallback(result.params.code, result.params.state, consents);
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
return true;
}, [finishCallback]);
@@ -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,65 @@
import { Feather } from "@expo/vector-icons";
import { Link } from "expo-router";
import React from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { colors, radii, spacing } from "../theme";
export function AppHeader({ guestLabel }: { guestLabel?: string }) {
return (
<View style={styles.header}>
<Link href="/dialogs" asChild>
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.historyLink, pressed && styles.pressed]}>
<Feather name="clock" size={20} color={colors.foreground} />
<Text style={styles.historyText}>История</Text>
</Pressable>
</Link>
<View style={styles.right}>
{guestLabel && <Text style={styles.guestBadge}>{guestLabel}</Text>}
<Link href="/profile" asChild>
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.avatar, pressed && styles.pressed]}>
<Feather name="user" size={20} color={colors.mutedForeground} />
<View style={styles.dot} />
</Pressable>
</Link>
</View>
</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,
},
historyLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
historyText: { fontSize: 14, color: colors.foreground },
right: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
guestBadge: { fontSize: 12, color: colors.mutedForeground },
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,116 @@
import { Feather } from "@expo/vector-icons";
import React, { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
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;
};
export function ChatInputBar({
value,
onChangeText,
onSubmit,
disabled,
sending,
onAttach,
placeholder = "Напишите ваш вопрос...",
hint = "Напишите сообщение или прикрепите документ",
inputLabel = "Сообщение",
}: Props) {
const [focused, setFocused] = useState(false);
const canSend = Boolean(value.trim()) && !disabled && !sending;
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>
{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 },
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,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>;
onAttachmentError?: (error: unknown) => void;
};
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>;
isClient: boolean;
onError?: (error: unknown) => void;
}) {
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,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={16} 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, color: colors.foreground },
pressed: { backgroundColor: colors.accent },
});
@@ -0,0 +1,39 @@
import { Feather } from "@expo/vector-icons";
import React from "react";
import { Linking, Pressable, StyleSheet, Text } from "react-native";
import { colors, radii, spacing } from "../theme";
export function QuickActions({ phone }: { phone?: string }) {
const call = () => {
if (phone) void Linking.openURL(`tel:${phone}`);
};
return (
<Pressable
accessibilityRole="button"
disabled={!phone}
onPress={call}
style={({ pressed }) => [styles.button, pressed && styles.pressed, !phone && styles.disabled]}
>
<Feather name="headphones" size={16} color={colors.secondaryForeground} />
<Text style={styles.text}>Связь с оператором</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
paddingVertical: 10,
borderRadius: radii.lg,
backgroundColor: colors.secondary,
},
text: { fontSize: 14, color: colors.secondaryForeground },
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 },
});
@@ -0,0 +1,50 @@
export type PendingTextIntent = {
text: string;
dialogKey: string;
messageKey: string;
};
const STORAGE_KEY = "han.pending-message";
export function createPendingTextIntent(text: string): PendingTextIntent {
return {
text,
dialogKey: crypto.randomUUID(),
messageKey: crypto.randomUUID(),
};
}
export function savePendingTextIntent(intent: PendingTextIntent) {
if (typeof window !== "undefined") {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(intent));
}
}
export function loadPendingTextIntent(): PendingTextIntent | null {
if (typeof window === "undefined") return null;
const raw = window.sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const value = JSON.parse(raw) as Partial<PendingTextIntent>;
if (
typeof value.text !== "string"
|| typeof value.dialogKey !== "string"
|| typeof value.messageKey !== "string"
) {
return null;
}
return {
text: value.text,
dialogKey: value.dialogKey,
messageKey: value.messageKey,
};
} catch {
return null;
}
}
export function clearPendingTextIntent() {
if (typeof window !== "undefined") {
window.sessionStorage.removeItem(STORAGE_KEY);
}
}
@@ -3,5 +3,10 @@ import type { Message } from "./types";
export function reconcileMessages(current: Message[], incoming: Message[]) {
const byId = new Map(current.map((message) => [message.message_id, message]));
for (const message of incoming) byId.set(message.message_id, { ...byId.get(message.message_id), ...message });
return [...byId.values()].sort((a, b) => a.created_at.localeCompare(b.created_at));
return [...byId.values()].sort((a, b) => {
const left = Date.parse(a.created_at);
const right = Date.parse(b.created_at);
if (Number.isFinite(left) && Number.isFinite(right)) return left - right;
return a.created_at.localeCompare(b.created_at);
});
}
@@ -0,0 +1,38 @@
export const colors = {
background: "#ffffff",
foreground: "#252525",
primary: "#030213",
primaryForeground: "#ffffff",
secondary: "#f3f3f5",
secondaryForeground: "#030213",
muted: "#ececf0",
mutedForeground: "#717182",
accent: "#e9ebef",
destructive: "#d4183d",
border: "rgba(0, 0, 0, 0.1)",
inputBackground: "#f3f3f5",
card: "#ffffff",
success: "#16a34a",
warning: "#ca8a04",
info: "#2563eb",
};
export const radii = {
sm: 6,
md: 8,
lg: 10,
xl: 16,
full: 9999,
};
export const spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
};
export const layout = {
maxWidth: 390,
};
+25 -38
View File
@@ -1,46 +1,33 @@
import { Link } from "expo-router";
import React from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { ApiError } from "./api";
import { colors, radii, spacing } from "./theme";
export const styles = StyleSheet.create({
page: { flex: 1, width: "100%", maxWidth: 920, alignSelf: "center", padding: 20, gap: 16 },
header: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12, paddingBottom: 12, borderBottomWidth: 1, borderColor: "#d7dee8" },
title: { fontSize: 28, lineHeight: 34, fontWeight: "700", color: "#12233f" },
heading: { fontSize: 20, lineHeight: 26, fontWeight: "700", color: "#12233f" },
text: { fontSize: 16, lineHeight: 23, color: "#233653" },
muted: { fontSize: 14, lineHeight: 20, color: "#5f6f85" },
card: { padding: 16, gap: 10, borderWidth: 1, borderColor: "#d7dee8", borderRadius: 12, backgroundColor: "#fff" },
input: { minHeight: 48, borderWidth: 1, borderColor: "#8493a8", borderRadius: 8, padding: 12, fontSize: 16, backgroundColor: "#fff" },
page: { flexGrow: 1, backgroundColor: colors.background },
scrollContent: { padding: spacing.lg, gap: spacing.lg },
title: { fontSize: 20, fontWeight: "500", color: colors.foreground },
heading: { fontSize: 16, fontWeight: "500", color: colors.foreground },
text: { fontSize: 14, lineHeight: 20, color: colors.foreground },
muted: { fontSize: 12, lineHeight: 18, color: colors.mutedForeground },
card: { padding: spacing.lg, gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.lg, backgroundColor: colors.card },
input: { minHeight: 48, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, fontSize: 16, backgroundColor: colors.inputBackground, color: colors.foreground },
textarea: { minHeight: 104, textAlignVertical: "top" },
row: { flexDirection: "row", flexWrap: "wrap", gap: 10, alignItems: "center" },
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: 8, backgroundColor: "#185abd" },
buttonSecondary: { backgroundColor: "#e8eef8" },
buttonDanger: { backgroundColor: "#b42318" },
row: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm, alignItems: "center" },
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: radii.lg, backgroundColor: colors.primary },
buttonSecondary: { backgroundColor: colors.secondary },
buttonDanger: { backgroundColor: colors.destructive },
buttonDisabled: { opacity: 0.5 },
buttonText: { color: "#fff", fontWeight: "700", fontSize: 15 },
buttonTextSecondary: { color: "#173b70" },
link: { color: "#075db7", fontSize: 16, textDecorationLine: "underline", paddingVertical: 10 },
badge: { borderRadius: 20, backgroundColor: "#edf2f8", color: "#263b58", paddingHorizontal: 10, paddingVertical: 5, fontSize: 13 },
error: { borderLeftWidth: 4, borderColor: "#b42318", backgroundColor: "#fff1f0", padding: 12, color: "#7a271a" },
success: { borderLeftWidth: 4, borderColor: "#16803c", backgroundColor: "#edfdf2", padding: 12, color: "#14532d" },
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(10,25,45,.45)", alignItems: "center", justifyContent: "center", padding: 20 },
modal: { width: "100%", maxWidth: 560, borderRadius: 14, backgroundColor: "#fff", padding: 20, gap: 14 },
messageClient: { alignSelf: "flex-end", maxWidth: "82%", backgroundColor: "#e4efff", padding: 12, borderRadius: 12 },
messageCompany: { alignSelf: "flex-start", maxWidth: "82%", backgroundColor: "#f0f2f5", padding: 12, borderRadius: 12 },
buttonText: { color: colors.primaryForeground, fontWeight: "500", fontSize: 14, textAlign: "center" },
buttonTextSecondary: { color: colors.secondaryForeground },
link: { color: colors.primary, fontSize: 14, textDecorationLine: "underline", paddingVertical: spacing.sm },
badge: { borderRadius: radii.full, backgroundColor: colors.muted, color: colors.foreground, paddingHorizontal: 10, paddingVertical: 5, fontSize: 12, alignSelf: "flex-start" },
error: { borderLeftWidth: 4, borderColor: colors.destructive, backgroundColor: "#fef2f2", padding: spacing.md, color: "#7f1d1d", fontSize: 14 },
success: { borderLeftWidth: 4, borderColor: colors.success, backgroundColor: "#f0fdf4", padding: spacing.md, color: "#14532d", fontSize: 14 },
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(3, 2, 19, 0.45)", alignItems: "center", justifyContent: "center", padding: spacing.lg },
modal: { width: "100%", maxWidth: 360, borderRadius: radii.xl, backgroundColor: colors.card, padding: spacing.lg, gap: spacing.md, borderWidth: 1, borderColor: colors.border },
});
export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) {
return <View style={styles.header}>
<Link href="/" style={styles.link}>HAN Chat</Link>
<Link href="/dialogs" style={styles.link}>Чат</Link>
<Link href="/profile" style={styles.link}>Профиль</Link>
<Link href="/diagnostics" style={styles.link}>Диагностика</Link>
<Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text>
{onLogout && <Button title="Выйти" secondary onPress={onLogout} />}
</View>;
}
export function Button({ title, onPress, disabled, secondary, danger }: {
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
}) {
@@ -50,7 +37,7 @@ export function Button({ title, onPress, disabled, secondary, danger }: {
onPress={onPress}
style={({ focused }) => [
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
disabled && styles.buttonDisabled, focused && { borderWidth: 3, borderColor: "#ffbf47" },
disabled && styles.buttonDisabled, focused && { borderWidth: 2, borderColor: colors.primary },
]}
>
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
@@ -60,18 +47,18 @@ export function Button({ title, onPress, disabled, secondary, danger }: {
export function Field(props: React.ComponentProps<typeof TextInput> & { label: string; error?: string }) {
return <View style={{ gap: 6 }}>
<Text style={styles.text}>{props.label}</Text>
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} />
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} placeholderTextColor={colors.mutedForeground} />
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
</View>;
}
export function Loading() {
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator /><Text style={styles.muted}>Загрузка</Text></View>;
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator color={colors.primary} /><Text style={styles.muted}>Загрузка</Text></View>;
}
export function ErrorNotice({ error, retry }: { error: unknown; retry?: () => void }) {
const requestId = error instanceof ApiError ? error.requestId : undefined;
return <View style={{ gap: 8 }}>
return <View style={{ gap: spacing.sm }}>
<Text accessibilityRole="alert" style={styles.error}>
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
{requestId ? `\nКод обращения: ${requestId}` : ""}