Проект разделен на два репозитория
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user