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

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,96 @@
import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
export { sessionMemory } from "./session";
import { sessionMemory } from "./session";
export type ApiErrorEnvelope = {
error: { code: string; message: string; request_id?: string; details?: Record<string, unknown> };
};
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly requestId?: string,
readonly retryAfter?: number,
) {
super(message);
this.name = "ApiError";
}
}
export const isMessageBlockedError = (error: unknown) =>
error instanceof ApiError && error.code === "message_blocked";
export type Diagnostic = {
at: number;
method: string;
path: string;
status: number;
requestId: string;
};
const diagnostics: Diagnostic[] = [];
export const getDiagnostics = () => [...diagnostics];
function traceparent() {
const traceId = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
const spanId = crypto.randomUUID().replaceAll("-", "").slice(0, 16);
return `00-${traceId.slice(0, 32)}-${spanId}-01`;
}
function safePath(path: string) {
return path.split("?")[0] ?? path;
}
async function parseError(response: Response, requestId: string) {
let envelope: ApiErrorEnvelope | undefined;
try { envelope = (await response.json()) as ApiErrorEnvelope; } catch { /* intentionally empty */ }
const code = envelope?.error?.code ?? `http_${response.status}`;
const retry = Number(response.headers.get("Retry-After"));
return new ApiError(
response.status,
code,
envelope?.error?.message ?? "Запрос не выполнен",
envelope?.error?.request_id ?? requestId,
Number.isFinite(retry) ? retry : undefined,
);
}
export async function apiRequest<T>(
path: string,
init: RequestInit & { protected?: boolean } = {},
replayed = false,
): Promise<T> {
const requestId = crypto.randomUUID();
const isProtected = init.protected ?? false;
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
headers.set("X-Request-ID", requestId);
headers.set("traceparent", traceparent());
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (isProtected) {
const token = getAccessToken();
if (!token) throw new ApiError(401, "unauthorized", "Требуется авторизация", requestId);
headers.set("Authorization", `Bearer ${token}`);
if (sessionMemory.id) headers.set("X-Ux-Session-Id", sessionMemory.id);
}
const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers });
diagnostics.unshift({
at: Date.now(), method: init.method ?? "GET", path: safePath(path),
status: response.status, requestId: response.headers.get("X-Request-ID") ?? requestId,
});
diagnostics.splice(20);
if (response.status === 401 && isProtected && !replayed) {
await refreshTokens();
return apiRequest<T>(path, init, true);
}
if (!response.ok) throw await parseError(response, requestId);
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const json = (value: unknown) => JSON.stringify(value);
export const idempotencyHeaders = (key: string) => ({ "Idempotency-Key": key });
@@ -0,0 +1,173 @@
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
import { sessionMemory } from "./api";
import { authApi, publicApi } from "./services";
import { notificationApi, notificationKeys } from "./notification-api";
import { NotificationRealtimeClient } from "./realtime";
import type { Consents, NotificationItem, NotificationRealtimeEvent } from "./types";
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
type AppContextValue = {
authStatus: AuthStatus;
realtimeState: string;
setRealtimeState: (value: string) => void;
authorize: (consents: Consents) => Promise<boolean>;
finishCallback: (code: string, state: string, consents: Consents) => Promise<void>;
signOut: () => Promise<void>;
ensureSession: () => Promise<void>;
};
const Context = createContext<AppContextValue | null>(null);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
});
export function AppProvider({ children }: { children: React.ReactNode }) {
const [authStatus, setAuthStatus] = useState<AuthStatus>("guest");
const [realtimeState, setRealtimeState] = useState("idle");
const [idleTimeoutMs, setIdleTimeoutMs] = useState<number | null>(null);
const router = useRouter();
const toGuest = useCallback(() => {
sessionMemory.clear();
setRealtimeState("idle");
setAuthStatus("guest");
queryClient.clear();
}, []);
useEffect(() => {
configureAuthFailure(toGuest);
void publicApi.config().then((config) => {
const minutes = config.ux.idle_timeout_minutes;
if (minutes > 0) setIdleTimeoutMs(minutes * 60_000);
}).catch(() => undefined);
void refreshTokens()
.then(async () => {
await authApi.startSession("cold_start");
setAuthStatus("authenticated");
})
.catch(() => setAuthStatus("guest"));
}, [toGuest]);
const finishCallback = useCallback(async (code: string, state: string, consents: Consents) => {
setAuthStatus("bootstrapping");
if (!getAccessToken()) await completeAuthorization(code, state);
await authApi.bootstrap(consents);
await authApi.startSession("first_launch");
setAuthStatus("authenticated");
}, []);
const authorize = useCallback(async (consents: Consents) => {
setAuthStatus("authorizing");
if (typeof window !== "undefined") window.sessionStorage.setItem("han.pending-consents", JSON.stringify(consents));
const result = await beginAuthorization();
if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") {
setAuthStatus("guest");
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
return false;
}
await finishCallback(result.params.code, result.params.state, consents);
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
return true;
}, [finishCallback]);
const ensureSession = useCallback(async () => {
if (authStatus !== "authenticated") return;
if (!sessionMemory.id) await authApi.startSession("cold_start");
else if (idleTimeoutMs !== null && Date.now() - sessionMemory.lastActivityAt > idleTimeoutMs) {
await authApi.startSession("idle_timeout");
}
sessionMemory.touch();
}, [authStatus, idleTimeoutMs]);
useEffect(() => {
if (Platform.OS === "web") {
const activity = () => sessionMemory.touch();
const resume = () => { if (!document.hidden) void ensureSession(); };
window.addEventListener("pointerdown", activity);
window.addEventListener("keydown", activity);
document.addEventListener("visibilitychange", resume);
return () => {
window.removeEventListener("pointerdown", activity);
window.removeEventListener("keydown", activity);
document.removeEventListener("visibilitychange", resume);
};
}
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") void ensureSession();
});
return () => subscription.remove();
}, [ensureSession]);
const value = useMemo<AppContextValue>(() => ({
authStatus, realtimeState, setRealtimeState, authorize, finishCallback, ensureSession,
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
return (
<QueryClientProvider client={queryClient}>
<Context.Provider value={value}>
<NotificationRealtimeBridge authenticated={authStatus === "authenticated"} onState={setRealtimeState} />
{children}
</Context.Provider>
</QueryClientProvider>
);
}
export function useApp() {
const value = useContext(Context);
if (!value) throw new Error("AppProvider is missing");
return value;
}
export async function resetAuthForTests() {
await clearTokens();
queryClient.clear();
}
function NotificationRealtimeBridge({
authenticated,
onState,
}: {
authenticated: boolean;
onState: (state: string) => void;
}) {
const client = useQueryClient();
useEffect(() => {
if (!authenticated) return;
const updateFromEvent = (event: NotificationRealtimeEvent) => {
client.setQueryData(notificationKeys.counter, { unread_count: event.unread_count });
if (event.type === "notification.updated") {
client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) =>
old ? { ...old, ...event } : old);
}
if (event.type === "notification.closed") {
client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) =>
old ? { ...old, lifecycle_status: "closed" as const } : old);
}
void client.invalidateQueries({ queryKey: ["notifications"] });
};
const reconcile = async () => {
const [home, center, counter] = await Promise.all([
notificationApi.list("home"),
notificationApi.list("center"),
notificationApi.counter(),
]);
client.setQueryData(notificationKeys.home(true), home);
client.setQueryData(notificationKeys.center, center);
client.setQueryData(notificationKeys.counter, counter);
};
const realtime = new NotificationRealtimeClient(updateFromEvent, reconcile, onState);
realtime.start();
return () => realtime.stop();
}, [authenticated, client, onState]);
return null;
}
@@ -0,0 +1,201 @@
import * as AuthSession from "expo-auth-session";
import * as Crypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
import { env, oidcIssuer } from "./config";
import { buildOidcDeviceMetadata } from "./oidc-device";
import { SingleFlight } from "./single-flight";
import type { TokenSet } from "./types";
WebBrowser.maybeCompleteAuthSession();
const REFRESH_KEY = "han.refresh-token";
const PKCE_KEY = "han.pkce";
const PKCE_TTL_MS = 10 * 60_000;
let tokens: TokenSet | null = null;
const refreshFlight = new SingleFlight<TokenSet>();
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let authFailure: (() => void) | undefined;
const browserStore = {
async get(key: string) {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(key);
},
async set(key: string, value: string) {
if (typeof window !== "undefined") window.localStorage.setItem(key, value);
},
async del(key: string) {
if (typeof window !== "undefined") window.localStorage.removeItem(key);
},
};
const secureStore = {
get: (key: string) =>
Platform.OS === "web" ? browserStore.get(key) : SecureStore.getItemAsync(key),
set: (key: string, value: string) =>
Platform.OS === "web" ? browserStore.set(key, value) : SecureStore.setItemAsync(key, value),
del: (key: string) =>
Platform.OS === "web" ? browserStore.del(key) : SecureStore.deleteItemAsync(key),
};
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
export function configureAuthFailure(callback: () => void) {
authFailure = callback;
}
export function getAccessToken() {
return tokens?.accessToken ?? null;
}
export function getTokenInfo() {
return tokens ? { expiresAt: tokens.expiresAt } : null;
}
async function persist(next: TokenSet) {
tokens = next;
await secureStore.set(REFRESH_KEY, next.refreshToken);
if (refreshTimer) clearTimeout(refreshTimer);
const delay = Math.max(1_000, next.expiresAt - Date.now() - 60_000);
refreshTimer = setTimeout(() => void refreshTokens().catch(() => undefined), delay);
}
async function parseTokenResponse(response: Response): Promise<TokenSet> {
const body = (await response.json()) as Record<string, unknown>;
if (!response.ok || typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
throw new Error(typeof body.error === "string" ? body.error : "token_exchange_failed");
}
return {
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + Number(body.expires_in ?? 300) * 1000,
...(typeof body.id_token === "string" ? { idToken: body.id_token } : {}),
};
}
export async function beginAuthorization() {
const verifier = random();
const state = random();
const nonce = random();
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
encoding: Crypto.CryptoEncoding.BASE64,
});
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
const deviceMetadata = await buildOidcDeviceMetadata(secureStore);
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
client_id: env.clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid offline_access",
code_challenge: challenge,
code_challenge_method: "S256",
state,
nonce,
...deviceMetadata,
})}`;
if (Platform.OS === "web" && typeof window !== "undefined") {
window.location.assign(url);
return new Promise<never>(() => undefined);
}
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
const callback = new URL(result.url);
return {
type: "success" as const,
params: {
code: callback.searchParams.get("code"),
state: callback.searchParams.get("state"),
error: callback.searchParams.get("error"),
},
};
}
export async function completeAuthorization(code: string, state: string) {
const raw = await secureStore.get(PKCE_KEY);
await secureStore.del(PKCE_KEY);
if (!raw) throw new Error("pkce_state_missing");
const saved = JSON.parse(raw) as { verifier: string; state: string; nonce: string; createdAt: number };
if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) {
throw new Error("pkce_state_invalid");
}
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: env.clientId,
redirect_uri: redirectUri,
code,
code_verifier: saved.verifier,
}).toString(),
});
const next = await parseTokenResponse(response);
if (!next.idToken || readJwtClaim(next.idToken, "nonce") !== saved.nonce) {
await clearTokens();
throw new Error("oidc_nonce_invalid");
}
await persist(next);
return next;
}
function readJwtClaim(token: string, claim: string) {
const payload = token.split(".")[1];
if (!payload) return undefined;
try {
const normalized = payload.replaceAll("-", "+").replaceAll("_", "/");
const decoded = decodeURIComponent(
Array.from(atob(normalized), (character) => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""),
);
return (JSON.parse(decoded) as Record<string, unknown>)[claim];
} catch {
return undefined;
}
}
export async function refreshTokens(): Promise<TokenSet> {
return refreshFlight.run(async () => {
const refreshToken = tokens?.refreshToken ?? (await secureStore.get(REFRESH_KEY));
if (!refreshToken) throw new Error("refresh_token_missing");
try {
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: env.clientId,
refresh_token: refreshToken,
}).toString(),
});
const next = await parseTokenResponse(response);
await persist(next);
return next;
} catch (error) {
await clearTokens();
authFailure?.();
throw error;
}
});
}
export async function clearTokens() {
tokens = null;
if (refreshTimer) clearTimeout(refreshTimer);
await secureStore.del(REFRESH_KEY);
}
export async function logout() {
const idToken = tokens?.idToken;
await clearTokens();
if (idToken) {
void fetch(`${oidcIssuer}/protocol/openid-connect/logout`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: env.clientId, id_token_hint: idToken }).toString(),
}).catch(() => undefined);
}
}
@@ -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 },
});
@@ -0,0 +1,16 @@
const required = (value: string | undefined, fallback: string) =>
(value ?? fallback).replace(/\/$/, "");
export const env = Object.freeze({
apiBaseUrl: required(process.env.EXPO_PUBLIC_API_BASE_URL, "http://localhost:8000"),
authBaseUrl: required(
process.env.EXPO_PUBLIC_AUTH_BASE_URL,
"http://localhost:8080/auth",
),
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
});
export const oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`;
export const isProduction = env.appEnv === "production";
@@ -0,0 +1,9 @@
export const DEFAULT_MESSAGE_MAX_LENGTH = 4000;
export function normalizeMessageText(value: string) {
return value.normalize("NFKC").trim();
}
export function messageFitsLimit(value: string, maxLength: number) {
return normalizeMessageText(value).length <= maxLength;
}
@@ -0,0 +1,96 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback } from "react";
import { dialogApi } from "./services";
import { notificationApi } from "./notification-api";
import { actionDialogId, actionUrl, openNewTab } from "./notification-presenter";
import {
clearPendingTextIntent,
createPendingTextIntent,
savePendingTextIntent,
type PendingTextIntent,
} from "./pending-intent";
import type { NotificationItem, NotificationType } from "./types";
type InstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
export function useNotificationAction({
authenticated,
requireAuth,
onError,
}: {
authenticated: boolean;
requireAuth?: (afterAuth?: () => Promise<void>) => void;
onError: (error: unknown) => void;
}) {
const router = useRouter();
const client = useQueryClient();
const sendGuestOffer = useCallback(async (intent: PendingTextIntent) => {
const dialog = await dialogApi.create(intent.dialogKey);
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
clearPendingTextIntent();
router.push(`/dialogs/${dialog.dialog_id}`);
}, [router]);
return useCallback(async (item: NotificationItem, type?: NotificationType) => {
if (!type) return;
onError(undefined);
try {
if (!authenticated) {
if (type.cta_action === "install_app_prompt") {
await promptInstallOrOpenInstruction(item.instruction_url);
return;
}
if (type.cta_action === "send_chat_message" && item.chat_message_text) {
const intent = createPendingTextIntent(item.chat_message_text);
savePendingTextIntent(intent);
requireAuth?.(() => sendGuestOffer(intent));
return;
}
requireAuth?.();
return;
}
const state = await notificationApi.cta(item.id);
client.setQueryData(notificationApiStateKey(item.id), (old: NotificationItem | undefined) =>
old ? { ...old, ...state } : old);
await Promise.all([
client.invalidateQueries({ queryKey: ["notifications"] }),
client.invalidateQueries({ queryKey: ["notifications", "counter"] }),
]);
if (type.cta_action === "open_detail") {
router.push(`/notification/${item.id}`);
return;
}
const url = actionUrl(state);
if (url) openNewTab(url);
const dialogId = actionDialogId(state);
if (dialogId) router.push(`/dialogs/${dialogId}`);
else if (type.cta_action === "send_chat_message") router.push("/dialogs");
} catch (error) {
onError(error);
}
}, [authenticated, client, onError, requireAuth, router, sendGuestOffer]);
}
function notificationApiStateKey(id: string) {
return ["notifications", "detail", id] as const;
}
async function promptInstallOrOpenInstruction(instructionUrl?: string | null) {
const event = typeof window !== "undefined"
? (window as typeof window & { __hanInstallPrompt?: InstallPromptEvent }).__hanInstallPrompt
: undefined;
if (event) {
await event.prompt();
const choice = await event.userChoice;
if (choice.outcome === "accepted") return;
}
if (!instructionUrl) throw new Error("Инструкция по установке недоступна");
openNewTab(instructionUrl);
}
@@ -0,0 +1,108 @@
import { apiRequest, json } from "./api";
import type {
NotificationActionState,
NotificationCounter,
NotificationItem,
NotificationList,
NotificationType,
UploadDraft,
} from "./types";
type CatalogResponse = { items: NotificationType[] };
type UploadListResponse = { items: UploadDraft[] };
export const notificationKeys = {
catalog: ["notification-types"] as const,
home: (authenticated: boolean) => ["notifications", authenticated ? "P" : "G", "home"] as const,
center: ["notifications", "P", "center"] as const,
counter: ["notifications", "counter"] as const,
detail: (id: string) => ["notifications", "detail", id] as const,
};
export const notificationApi = {
catalog: async () => (await apiRequest<CatalogResponse>("/api/v1/public/notification-types")).items,
guestHome: async () => (await apiRequest<NotificationList>("/api/v1/public/notifications")).items,
list: async (place: "home" | "center") =>
(await apiRequest<NotificationList>(
`/api/v1/notifications?place=${place}`,
{ protected: true },
)).items,
counter: () =>
apiRequest<NotificationCounter>("/api/v1/notifications/counter", { protected: true }),
detail: (id: string) =>
apiRequest<NotificationItem>(`/api/v1/notifications/${encodeURIComponent(id)}`, { protected: true }),
read: (id: string) =>
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/read`, {
method: "POST", protected: true, body: "{}",
}),
hide: (id: string) =>
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/hide`, {
method: "POST", protected: true, body: "{}",
}),
cta: (id: string) =>
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/cta`, {
method: "POST", protected: true, body: "{}",
}),
button: (id: string, code: string) =>
apiRequest<NotificationActionState>(
`/api/v1/notifications/${encodeURIComponent(id)}/buttons/${encodeURIComponent(code)}`,
{ method: "POST", protected: true, body: "{}" },
),
documentUrl: (notificationId: string, documentId: string) =>
apiRequest<{ download_url: string; expires_at: string }>(
`/api/v1/notifications/${encodeURIComponent(notificationId)}/documents/${encodeURIComponent(documentId)}/download-url`,
{ protected: true },
),
};
export const uploadDraftApi = {
list: async (notificationId: string) =>
(await apiRequest<UploadListResponse>(
`/api/v1/uploads?context_type=notification&context_id=${encodeURIComponent(notificationId)}`,
{ protected: true },
)).items,
remove: (draftId: string) =>
apiRequest<void>(`/api/v1/uploads/${encodeURIComponent(draftId)}`, {
method: "DELETE", protected: true,
}),
upload: async (notificationId: string, file: File) => {
const checksum = await sha256(file);
const draft = await apiRequest<{
draft_id: string;
upload_url: string;
upload_headers: Record<string, string>;
expires_at: string;
}>("/api/v1/uploads/init", {
method: "POST",
protected: true,
body: json({
context_type: "notification",
context_id: notificationId,
file_name: file.name,
mime_type: file.type,
size_bytes: file.size,
}),
});
const upload = await fetch(draft.upload_url, {
method: "PUT",
headers: draft.upload_headers ?? { "Content-Type": file.type },
body: file,
});
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
await apiRequest<UploadDraft>(`/api/v1/uploads/${encodeURIComponent(draft.draft_id)}/complete`, {
method: "POST",
protected: true,
body: json({ checksum }),
});
return draft.draft_id;
},
};
async function sha256(file: Blob) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
const hex = Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
return `sha256:${hex}`;
}
@@ -0,0 +1,114 @@
import { Feather } from "@expo/vector-icons";
import type { NotificationActionState, NotificationType } from "./types";
export type NotificationPalette = {
background: string;
foreground: string;
accent: string;
accentBackground: string;
border: string;
};
const neutral: NotificationPalette = {
background: "#f3f3f5",
foreground: "#252525",
accent: "#030213",
accentBackground: "#e4e4e8",
border: "#d7d7dc",
};
const palettes: Record<string, NotificationPalette> = {
critical: {
background: "#fdecef",
foreground: "#252525",
accent: "#d4183d",
accentBackground: "#f8dce2",
border: "#efbcc7",
},
warning: {
background: "#fff3e0",
foreground: "#252525",
accent: "#e65100",
accentBackground: "#ffe0b2",
border: "#ffcc80",
},
success: {
background: "#e8f5e9",
foreground: "#252525",
accent: "#2e7d32",
accentBackground: "#c8e6c9",
border: "#a5d6a7",
},
info: {
background: "#eaf2ff",
foreground: "#252525",
accent: "#2563eb",
accentBackground: "#d8e7ff",
border: "#b7d1fb",
},
promo: {
background: "#f4edff",
foreground: "#252525",
accent: "#7c3aed",
accentBackground: "#e7d8ff",
border: "#d4b9fb",
},
neutral,
};
const icons: Record<string, keyof typeof Feather.glyphMap> = {
alert: "alert-circle",
urgent: "alert-triangle",
payment: "credit-card",
documents: "file-text",
document: "file-text",
status: "activity",
reminder: "clock",
news: "bell",
promo: "gift",
ads: "star",
authorize: "log-in",
install: "download",
info: "info",
};
export function notificationPalette(token?: string | null): NotificationPalette {
return token ? (palettes[token] ?? neutral) : neutral;
}
export function notificationIcon(code?: string | null): keyof typeof Feather.glyphMap {
return code ? (icons[code] ?? "bell") : "bell";
}
export function typeMap(catalog: NotificationType[]) {
return new Map(catalog.map((type) => [type.code, type]));
}
export function formatNotificationPrice(value?: number | string | null) {
if (value === null || value === undefined) return null;
const number = Number(value);
if (!Number.isFinite(number)) return null;
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: number % 1 === 0 ? 0 : 2,
}).format(number);
}
export function actionUrl(state: NotificationActionState) {
return state.result?.action === "open_url" ? state.result.url : undefined;
}
export function actionDialogId(state: NotificationActionState) {
return state.result?.action === "chat_message_sent"
? state.result.message.dialog_id
: undefined;
}
export function openNewTab(url: string) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
void import("react-native").then(({ Linking }) => Linking.openURL(url));
}
@@ -0,0 +1,94 @@
import Constants from "expo-constants";
import * as Crypto from "expo-crypto";
import { Platform } from "react-native";
const DEVICE_ID_KEY = "han.web-device-id";
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
type Store = {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
};
export type OidcDeviceMetadata = Partial<Record<
| "han_device_id"
| "han_fingerprint"
| "han_platform"
| "han_os_name"
| "han_os_version"
| "han_app_version",
string
>>;
function safe(value: unknown, maxLength: number): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
if (!normalized || normalized.length > maxLength || CONTROL_CHARACTERS.test(normalized)) {
return undefined;
}
return normalized;
}
type BrowserDetails = {
osName?: string;
osVersion?: string;
fingerprintSource?: string;
};
function browserDetails(): BrowserDetails {
if (typeof navigator === "undefined") return {};
const userAgent = navigator.userAgent;
const platform = safe(navigator.platform, 64);
const windows = userAgent.match(/Windows NT ([\d.]+)/);
const android = userAgent.match(/Android ([\d.]+)/);
const ios = userAgent.match(/(?:iPhone )?OS ([\d_]+)/);
const osName = windows ? "Windows" : android ? "Android" : ios ? "iOS" : platform;
const osVersion = windows?.[1] ?? android?.[1] ?? ios?.[1]?.replaceAll("_", ".");
return {
...(osName ? { osName } : {}),
...(osVersion ? { osVersion } : {}),
fingerprintSource: [
userAgent,
navigator.language,
platform,
Intl.DateTimeFormat().resolvedOptions().timeZone,
typeof screen === "undefined" ? "" : `${screen.width}x${screen.height}`,
].join("|"),
};
}
export async function buildOidcDeviceMetadata(store: Store): Promise<OidcDeviceMetadata> {
const platform = Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "web";
let deviceId = safe(await store.get(DEVICE_ID_KEY), 256);
if (!deviceId) {
deviceId = Crypto.randomUUID();
await store.set(DEVICE_ID_KEY, deviceId);
}
const browser = platform === "web" ? browserDetails() : {};
const constants = Platform.constants as unknown as Record<string, unknown>;
const fingerprintSource = browser.fingerprintSource
?? [platform, constants.Brand, constants.Model, constants.osVersion].join("|");
const fingerprint = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
`${deviceId}|${fingerprintSource}`,
);
const osName = browser.osName
?? safe(constants.systemName, 64)
?? (platform === "ios" ? "iOS" : platform === "android" ? "Android" : undefined);
const osVersion = browser.osVersion
?? safe(String(constants.osVersion ?? Platform.Version ?? ""), 64);
const appVersion = safe(Constants.expoConfig?.version, 64);
const safeOsName = safe(osName, 64);
const safeOsVersion = safe(osVersion, 64);
return {
han_device_id: deviceId,
han_fingerprint: fingerprint,
han_platform: platform,
...(safeOsName ? { han_os_name: safeOsName } : {}),
...(safeOsVersion ? { han_os_version: safeOsVersion } : {}),
...(appVersion ? { han_app_version: appVersion } : {}),
};
}
@@ -0,0 +1,91 @@
export type PendingTextIntent = {
text: string;
dialogKey: string;
messageKey: string;
dialogId?: string;
};
const STORAGE_KEY = "han.pending-message";
let pendingTextIntent: PendingTextIntent | null = null;
let pendingFileIntent: PendingFileIntent | null = null;
export type PendingFileIntent = {
file: File;
dialogKey: string;
dialogId?: string;
messageKey: string;
};
export function createPendingTextIntent(text: string): PendingTextIntent {
return {
text,
dialogKey: crypto.randomUUID(),
messageKey: crypto.randomUUID(),
};
}
export function savePendingTextIntent(intent: PendingTextIntent) {
pendingTextIntent = intent;
if (typeof window !== "undefined") {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(intent));
}
}
export function loadPendingTextIntent(): PendingTextIntent | null {
if (pendingTextIntent) return pendingTextIntent;
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;
}
pendingTextIntent = {
text: value.text,
dialogKey: value.dialogKey,
messageKey: value.messageKey,
...(typeof value.dialogId === "string" ? { dialogId: value.dialogId } : {}),
};
return pendingTextIntent;
} catch {
return null;
}
}
export function bindPendingTextIntent(intent: PendingTextIntent, dialogId: string) {
const bound = { ...intent, dialogId };
savePendingTextIntent(bound);
return bound;
}
export function clearPendingTextIntent() {
pendingTextIntent = null;
if (typeof window !== "undefined") {
window.sessionStorage.removeItem(STORAGE_KEY);
}
}
export function savePendingFileIntent(intent: PendingFileIntent) {
pendingFileIntent = intent;
}
export function bindPendingFileIntent(dialogId: string) {
if (pendingFileIntent) pendingFileIntent = { ...pendingFileIntent, dialogId };
}
export function pendingDialogKey() {
return loadPendingTextIntent()?.dialogKey ?? pendingFileIntent?.dialogKey;
}
export function loadPendingFileIntent(dialogId: string) {
return pendingFileIntent?.dialogId === dialogId ? pendingFileIntent : null;
}
export function clearPendingFileIntent() {
pendingFileIntent = null;
}
@@ -0,0 +1,259 @@
import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
import { dialogApi } from "./services";
import type { Message, NotificationRealtimeEvent } from "./types";
export { reconcileMessages } from "./reconcile";
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
export type RealtimeEvent =
| { type: "message.new"; dialog_id: string; message: Message; cursor?: string }
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
export const NOTIFICATION_OUTAGE_MS = 30_000;
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000;
const safeCursors = new Map<string, string>();
export const getRealtimeDiagnostics = () =>
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
dialog: `${dialogId.slice(0, 8)}`,
cursor,
}));
export function websocketJwtProtocol(token: string) {
const bytes = new TextEncoder().encode(token);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return `han.jwt.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`;
}
export class RealtimeClient {
private socket?: WebSocket;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
private disconnectedAt = 0;
private attempt = 0;
private stopped = true;
private cursors = new Map<string, string>();
constructor(
private dialogIds: string[],
private readonly onEvent: (event: RealtimeEvent) => void,
private readonly onMessages: (dialogId: string, messages: Message[]) => void,
private readonly onState: (state: RealtimeState) => void,
) {}
updateDialogs(ids: string[]) {
this.dialogIds = [...new Set(ids)];
if (this.socket?.readyState === WebSocket.OPEN) this.subscribe();
}
start() {
if (!this.stopped) return;
this.stopped = false;
this.connect();
}
stop() {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.pollingTimer) clearTimeout(this.pollingTimer);
this.socket?.close();
this.onState("idle");
}
private connect() {
if (this.stopped) return;
const token = getAccessToken();
if (!token) return;
this.onState("connecting");
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
this.socket.onopen = () => {
this.attempt = 0;
// Сначала подписываемся, затем читаем REST-gap: события в этом окне
// уже попадут в merge, а дубли устраняются по message_id.
this.subscribe();
void this.reconcileAll().then(() => {
this.stopPolling();
this.onState("websocket");
});
};
this.socket.onmessage = ({ data }) => {
try {
const event = JSON.parse(String(data)) as RealtimeEvent | { type: string };
if (event.type === "ping") return this.socket?.send(JSON.stringify({ type: "pong" }));
if (event.type === "message.new" || event.type === "message.status" || event.type === "dialog.status") {
const known = event as RealtimeEvent;
if (known.cursor) {
this.cursors.set(known.dialog_id, known.cursor);
safeCursors.set(known.dialog_id, known.cursor);
}
this.onEvent(known);
}
} catch { /* unknown and malformed events are safely ignored */ }
};
this.socket.onclose = (event) => {
if (this.stopped) return;
if (!this.disconnectedAt) this.disconnectedAt = Date.now();
if (event.code === 4401 || event.code === 1008) {
void refreshTokens().finally(() => this.scheduleReconnect());
} else {
this.scheduleReconnect();
}
};
this.socket.onerror = () => this.socket?.close();
}
private subscribe() {
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: this.dialogIds }));
}
private scheduleReconnect() {
if (this.stopped) return;
if (Date.now() - this.disconnectedAt >= 30_000) this.startPolling();
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
private async reconcileAll() {
await Promise.all(this.dialogIds.map(async (id) => {
const page = await dialogApi.messages(id, this.cursors.get(id));
if (page.items.length) this.onMessages(id, page.items);
if (page.next_cursor) {
this.cursors.set(id, page.next_cursor);
safeCursors.set(id, page.next_cursor);
}
}));
this.disconnectedAt = 0;
}
private startPolling() {
if (this.pollingTimer) return;
this.onState("polling");
const poll = async () => {
if (this.stopped) return;
await this.reconcileAll().catch(() => undefined);
const delay = typeof document !== "undefined" && document.hidden ? 15_000 : 5_000;
this.pollingTimer = setTimeout(poll, delay);
};
void poll();
}
private stopPolling() {
if (this.pollingTimer) clearTimeout(this.pollingTimer);
this.pollingTimer = undefined;
}
}
export class NotificationRealtimeClient {
private socket?: WebSocket;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
private outageTimer: ReturnType<typeof setTimeout> | undefined;
private disconnectedAt = 0;
private attempt = 0;
private stopped = true;
private readonly eventIds = new Set<string>();
constructor(
private readonly onEvent: (event: NotificationRealtimeEvent) => void,
private readonly reconcile: () => Promise<void>,
private readonly onState: (state: RealtimeState) => void,
) {}
start() {
if (!this.stopped) return;
this.stopped = false;
this.connect();
}
stop() {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.pollingTimer) clearTimeout(this.pollingTimer);
if (this.outageTimer) clearTimeout(this.outageTimer);
this.socket?.close();
this.onState("idle");
}
private connect() {
if (this.stopped) return;
const token = getAccessToken();
if (!token) return;
this.onState("connecting");
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
this.socket.onopen = () => {
this.attempt = 0;
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: [], notifications: true }));
void this.reconcile().then(() => {
this.disconnectedAt = 0;
this.stopPolling();
this.onState("websocket");
}).catch(() => undefined);
};
this.socket.onmessage = ({ data }) => {
try {
const event = JSON.parse(String(data)) as NotificationRealtimeEvent | { type: string };
if (event.type === "ping") {
this.socket?.send(JSON.stringify({ type: "pong" }));
return;
}
if (
event.type === "notification.created"
|| event.type === "notification.updated"
|| event.type === "notification.closed"
) {
const notificationEvent = event as NotificationRealtimeEvent;
if (this.eventIds.has(notificationEvent.event_id)) return;
this.eventIds.add(notificationEvent.event_id);
if (this.eventIds.size > 100) {
const oldest = this.eventIds.values().next().value as string | undefined;
if (oldest) this.eventIds.delete(oldest);
}
this.onEvent(notificationEvent);
}
} catch { /* malformed and unknown messages are ignored */ }
};
this.socket.onclose = (event) => {
if (this.stopped) return;
if (!this.disconnectedAt) {
this.disconnectedAt = Date.now();
this.outageTimer = setTimeout(() => this.startPolling(), NOTIFICATION_OUTAGE_MS);
}
if (event.code === 4401 || event.code === 1008) {
void refreshTokens().finally(() => this.scheduleReconnect());
} else {
this.scheduleReconnect();
}
};
this.socket.onerror = () => this.socket?.close();
}
private scheduleReconnect() {
if (this.stopped) return;
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
private startPolling() {
if (this.stopped || this.pollingTimer || !this.disconnectedAt) return;
this.onState("polling");
const poll = async () => {
if (this.stopped) return;
await this.reconcile().catch(() => undefined);
this.pollingTimer = setTimeout(poll, NOTIFICATION_POLL_INTERVAL_MS);
};
void poll();
}
private stopPolling() {
if (this.pollingTimer) clearTimeout(this.pollingTimer);
if (this.outageTimer) clearTimeout(this.outageTimer);
this.pollingTimer = undefined;
this.outageTimer = undefined;
}
}
@@ -0,0 +1,12 @@
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) => {
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,108 @@
import { apiRequest, idempotencyHeaders, json, sessionMemory } from "./api";
import { Platform } from "react-native";
import type {
Consents, Dialog, DocumentItem, Message, Page, Profile, PublicConfig, PublicContent,
} from "./types";
export const publicApi = {
config: () => apiRequest<PublicConfig>("/api/v1/public/app-config"),
content: () => apiRequest<PublicContent>("/api/v1/public/content"),
};
export const authApi = {
bootstrap: (consents: Consents) =>
apiRequest<{ user_id: string; profile_ready: boolean }>("/api/v1/auth/bootstrap", {
method: "POST", protected: true,
body: json({ consents, device: deviceMetadata() }),
}),
startSession: async (reason: "first_launch" | "cold_start" | "idle_timeout") => {
const result = await apiRequest<{ ux_session_id: string; started_at: string }>(
"/api/v1/analytics/session-start",
{ method: "POST", protected: true, body: json({ start_reason: reason, device: deviceMetadata() }) },
);
sessionMemory.set(result.ux_session_id);
return result;
},
saveConsents: (consents: Consents) =>
apiRequest<void>("/api/v1/consents", {
method: "POST", protected: true, body: json({ consents }),
}),
};
function deviceMetadata() {
const platform = Platform.OS;
if (platform !== "ios" && platform !== "android" && platform !== "web") {
throw new Error(`Unsupported platform: ${platform}`);
}
return {
platform,
app_version: "1.0.0",
device_id: "frontend-test-site",
};
}
export const dialogApi = {
list: (cursor?: string) =>
apiRequest<Page<Dialog>>(`/api/v1/dialogs${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, { protected: true }),
get: (id: string) => apiRequest<Dialog>(`/api/v1/dialogs/${encodeURIComponent(id)}`, { protected: true }),
create: (key: string) =>
apiRequest<Dialog>("/api/v1/dialogs", {
method: "POST", protected: true, headers: idempotencyHeaders(key), body: "{}",
}),
messages: (id: string, after?: string) =>
apiRequest<Page<Message>>(
`/api/v1/dialogs/${encodeURIComponent(id)}/messages?limit=50${after ? `&after=${encodeURIComponent(after)}` : ""}`,
{ protected: true },
),
sendText: (id: string, text: string, key: string) =>
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ content_kind: "text", text }),
}),
sendFile: (id: string, attachmentId: string, checksum: string, key: string) =>
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ content_kind: "file", attachment_id: attachmentId, checksum }),
}),
};
export async function uploadAttachment(dialogId: string, file: File, key = crypto.randomUUID()) {
const checksum = await sha256(file);
const init = await apiRequest<{
attachment_id: string;
upload_url: string;
upload_headers?: Record<string, string>;
expires_at: string;
}>(`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/init`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ file_name: file.name, mime_type: file.type, size_bytes: file.size }),
});
const upload = await fetch(init.upload_url, {
method: "PUT",
headers: init.upload_headers ?? { "Content-Type": file.type },
body: file,
});
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
await apiRequest<void>(
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(init.attachment_id)}/complete`,
{ method: "POST", protected: true, headers: idempotencyHeaders(key), body: json({ checksum }) },
);
return { attachmentId: init.attachment_id, checksum };
}
async function sha256(file: Blob) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return `sha256:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
}
export const profileApi = {
me: () => apiRequest<Profile>("/api/v1/me", { protected: true }),
documents: () => apiRequest<Page<DocumentItem>>("/api/v1/me/documents", { protected: true }),
documentUrl: (id: string) =>
apiRequest<{ download_url: string }>(`/api/v1/documents/${encodeURIComponent(id)}/download-url`, { protected: true }),
attachmentUrl: (dialogId: string, id: string) =>
apiRequest<{ download_url: string }>(
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(id)}/download-url`,
{ protected: true },
),
};
@@ -0,0 +1,10 @@
let uxSessionId: string | null = null;
let lastActivityAt = Date.now();
export const sessionMemory = {
get id() { return uxSessionId; },
get lastActivityAt() { return lastActivityAt; },
touch() { lastActivityAt = Date.now(); },
set(id: string) { uxSessionId = id; lastActivityAt = Date.now(); },
clear() { uxSessionId = null; lastActivityAt = Date.now(); },
};
@@ -0,0 +1,11 @@
export class SingleFlight<T> {
private running: Promise<T> | null = null;
run(operation: () => Promise<T>): Promise<T> {
if (this.running) return this.running;
this.running = operation().finally(() => {
this.running = null;
});
return this.running;
}
}
@@ -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,
};
@@ -0,0 +1,218 @@
export type Consent = { accepted: boolean; version: string };
export type Consents = {
personal_data: Consent;
user_agreement: Consent;
marketing: Consent;
};
export type TokenSet = {
accessToken: string;
refreshToken: string;
expiresAt: number;
idToken?: string;
};
export type DialogStatus = "open" | "waiting_for_company" | "waiting_for_client" | "closed";
export type Dialog = {
dialog_id: string;
status: DialogStatus;
unread_count?: number;
updated_at?: string;
};
export type Attachment = {
attachment_id: string;
file_name: string;
mime_type: string;
size_bytes: number;
scan_status: "pending" | "clean" | "infected" | "failed";
};
export type Message = {
message_id: string;
dialog_id: string;
sender_type: "client" | "company";
content_kind: "text" | "file";
text: string;
attachments: Attachment[];
safety_status: "pending" | "allowed" | "blocked";
delivery_status: "accepted" | "delivered" | "failed" | "rejected";
created_at: string;
};
export type Page<T> = { items: T[]; next_cursor: string | null };
export type Profile = {
user_id: string;
profile: {
personal_data: {
full_name: string | null;
citizenship: string | null;
russian_phone: string | null;
foreign_phone: string | null;
email: string | null;
};
documents: { count: number };
};
};
export type DocumentItem = {
document_id: string;
name: string;
sent_at: string;
};
export type PublicConfig = {
auth: { phone_enabled: boolean; password_enabled: boolean };
operator: { call_phone: string };
messages: { max_text_length: number };
notification?: {
carousel_autoplay_enabled?: boolean;
carousel_autoplay_interval_ms?: number;
};
consents: Record<string, {
required: boolean;
document_url: string | null;
privacy_policy_document_url?: string | null;
version: string;
}>;
attachments: {
max_size_mb: number;
allowed_extensions: string[];
allowed_mime_types: string[];
};
ux: { idle_timeout_minutes: number };
};
export type PublicContent = {
locale: string;
texts: Record<string, string>;
popular_questions: Array<{ id: string; mnemonic: string; text: string }>;
version: string;
};
export type NotificationContour = "G" | "P";
export type NotificationCtaAction =
| "open_detail"
| "open_payment_url"
| "send_chat_message"
| "start_auth"
| "install_app_prompt";
export type NotificationButton = {
code: string;
label: string;
};
export type NotificationType = {
code: string;
label: string;
color_token: string;
icon_code: string | null;
cta_text: string;
cta_action: NotificationCtaAction;
countable: boolean;
contour: NotificationContour;
button_primary: NotificationButton | null;
button_secondary: NotificationButton | null;
};
export type NotificationTodoItem = {
number: number;
text: string;
};
export type NotificationDocument = {
document_id: string;
title: string;
mime_type: string;
size_bytes: number;
};
export type UploadDraft = {
draft_id: string;
context_type: "notification";
context_id: string;
title: string;
mime_type: string;
size_bytes: number;
scan_status: "pending" | "clean" | "infected" | "failed";
state: "draft" | "submitted" | "discarded";
};
export type NotificationDetails = {
deadline?: string | null;
details_header?: string | null;
details_text?: string | null;
todo_header?: string | null;
todo_plan?: NotificationTodoItem[] | null;
send_documents?: boolean;
pending_documents?: UploadDraft[];
documents?: NotificationDocument[] | null;
};
export type NotificationItem = {
id: string;
notification_type: string;
notification_datetime: string;
header: string;
text?: string | null;
date_expired?: string | null;
price?: number | string | null;
old_price?: number | string | null;
instruction_url?: string | null;
instruction_open_mode?: "new_tab" | null;
chat_message_text?: string | null;
details?: NotificationDetails | null;
priority?: number;
lifecycle_status?: "active" | "closed";
visibility?: "visible" | "hidden";
is_read?: boolean;
close_reason?: string | null;
countable?: boolean;
cta_action?: NotificationCtaAction;
};
export type NotificationList = { items: NotificationItem[] };
export type NotificationCounter = { unread_count: number };
export type NotificationActionResult =
| { action: "open_detail"; notification_id: string }
| { action: "open_url"; url: string }
| { action: "chat_message_sent"; message: Message };
export type NotificationActionState = {
notification_id: string;
lifecycle_status: "active" | "closed";
visibility: "visible" | "hidden";
is_read: boolean;
close_reason: string | null;
date_expired: string | null;
unread_count: number;
result: NotificationActionResult | null;
};
export type NotificationRealtimeEvent =
| {
type: "notification.created";
event_id: string;
occurred_at: string;
notification: NotificationItem;
unread_count: number;
}
| {
type: "notification.updated";
event_id: string;
occurred_at: string;
notification_id: string;
unread_count: number;
is_read?: boolean;
visibility?: "visible" | "hidden";
date_expired?: string | null;
close_reason?: string | null;
}
| {
type: "notification.closed";
event_id: string;
occurred_at: string;
notification_id: string;
close_reason: string;
unread_count: number;
is_read?: boolean;
visibility?: "visible" | "hidden";
date_expired?: string | null;
};
@@ -0,0 +1,68 @@
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: { 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: 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: 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 Button({ title, onPress, disabled, secondary, danger }: {
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
}) {
return <Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
disabled && styles.buttonDisabled, pressed && { opacity: 0.8 },
]}
>
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
</Pressable>;
}
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]} 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 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: spacing.sm }}>
<Text accessibilityRole="alert" style={styles.error}>
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
{requestId ? `\nКод обращения: ${requestId}` : ""}
</Text>
{retry && <Button title="Повторить" secondary onPress={retry} />}
</View>;
}