Реализован интерфейс согласий
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { Linking, ScrollView, Switch, Text, View } from "react-native";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { AppHeader } from "../src/components/AppHeader";
|
||||
import { ChatInputBar } from "../src/components/ChatInputBar";
|
||||
import { ConsentModal } from "../src/components/ConsentModal";
|
||||
import { HanLogo } from "../src/components/HanLogo";
|
||||
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
|
||||
import { QuickActions } from "../src/components/QuickActions";
|
||||
@@ -17,14 +18,13 @@ import {
|
||||
} from "../src/pending-intent";
|
||||
import { dialogApi, publicApi, uploadAttachment } from "../src/services";
|
||||
import type { Consents } from "../src/types";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../src/ui";
|
||||
import { ErrorNotice, Loading, styles } from "../src/ui";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { authStatus, authorize } = useApp();
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
|
||||
const [consentOpen, setConsentOpen] = useState(false);
|
||||
const [required, setRequired] = useState({ personal: false, agreement: false, marketing: false });
|
||||
const [pending, setPending] = useState<PendingTextIntent | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [sendError, setSendError] = useState<unknown>();
|
||||
@@ -111,13 +111,16 @@ export default function HomeScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const accept = async () => {
|
||||
if (!required.personal || !required.agreement) return;
|
||||
const accept = async (accepted: {
|
||||
personal_data: boolean;
|
||||
user_agreement: boolean;
|
||||
marketing: boolean;
|
||||
}) => {
|
||||
const versions = config.data?.consents;
|
||||
const consents: Consents = {
|
||||
personal_data: { accepted: true, version: versions?.personal_data?.version ?? "current" },
|
||||
user_agreement: { accepted: true, version: versions?.user_agreement?.version ?? "current" },
|
||||
marketing: { accepted: required.marketing, version: versions?.marketing?.version ?? "current" },
|
||||
personal_data: { accepted: accepted.personal_data, version: versions?.personal_data?.version ?? "current" },
|
||||
user_agreement: { accepted: accepted.user_agreement, version: versions?.user_agreement?.version ?? "current" },
|
||||
marketing: { accepted: accepted.marketing, version: versions?.marketing?.version ?? "current" },
|
||||
};
|
||||
setConsentOpen(false);
|
||||
try {
|
||||
@@ -165,46 +168,18 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
|
||||
{consentOpen && (
|
||||
<View accessibilityViewIsModal style={styles.modalBackdrop}>
|
||||
<View style={styles.modal}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
|
||||
<Text style={styles.text}>Для отправки сообщения или файла необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
|
||||
{(["personal_data", "user_agreement", "marketing"] as const).map((key) => {
|
||||
const item = config.data?.consents?.[key];
|
||||
if (!item) return null;
|
||||
return item.document_url ? (
|
||||
<Text key={key} accessibilityRole="link" style={styles.link} onPress={() => void Linking.openURL(item.document_url!)}>
|
||||
{key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version}
|
||||
</Text>
|
||||
) : null;
|
||||
})}
|
||||
<ConsentRow label="Обработка персональных данных (обязательно)" value={required.personal} onChange={(personal) => setRequired({ ...required, personal })} />
|
||||
<ConsentRow label="Пользовательское соглашение (обязательно)" value={required.agreement} onChange={(agreement) => setRequired({ ...required, agreement })} />
|
||||
<ConsentRow label="Рекламные коммуникации (необязательно)" value={required.marketing} onChange={(marketing) => setRequired({ ...required, marketing })} />
|
||||
<View style={styles.row}>
|
||||
<Button title="Продолжить" disabled={!required.personal || !required.agreement} onPress={() => void accept()} />
|
||||
<Button
|
||||
title="Отмена"
|
||||
secondary
|
||||
onPress={() => {
|
||||
setConsentOpen(false);
|
||||
setPending(null);
|
||||
clearPendingTextIntent();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ConsentModal
|
||||
consents={config.data?.consents}
|
||||
onAccept={(accepted) => void accept(accepted)}
|
||||
onCancel={() => {
|
||||
setConsentOpen(false);
|
||||
setPending(null);
|
||||
clearPendingTextIntent();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const spacing = 16;
|
||||
|
||||
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (value: boolean) => void }) {
|
||||
return <View style={[styles.row, { justifyContent: "space-between" }]}>
|
||||
<Text style={[styles.text, { flex: 1 }]}>{label}</Text>
|
||||
<Switch accessibilityLabel={label} value={value} onValueChange={onChange} />
|
||||
</View>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -59,6 +59,7 @@ export type PublicConfig = {
|
||||
consents: Record<string, {
|
||||
required: boolean;
|
||||
document_url: string | null;
|
||||
privacy_policy_document_url?: string | null;
|
||||
version: string;
|
||||
}>;
|
||||
attachments: {
|
||||
|
||||
@@ -8,7 +8,12 @@ test.beforeEach(async ({ page }) => {
|
||||
ux: { idle_timeout_minutes: 15 },
|
||||
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
|
||||
consents: {
|
||||
personal_data: { required: true, version: "2026-07-01", document_url: "https://example.test/personal" },
|
||||
personal_data: {
|
||||
required: true,
|
||||
version: "2026-07-01",
|
||||
document_url: "https://example.test/personal",
|
||||
privacy_policy_document_url: "https://example.test/privacy",
|
||||
},
|
||||
user_agreement: { required: true, version: "2026-07-01", document_url: "https://example.test/agreement" },
|
||||
marketing: { required: false, version: "2026-07-01", document_url: "https://example.test/marketing" },
|
||||
},
|
||||
@@ -49,7 +54,9 @@ test("первое сообщение требует обязательные с
|
||||
await page.goto("/");
|
||||
await page.getByLabel("Сообщение").fill("Здравствуйте");
|
||||
await page.getByRole("button", { name: "Отправить" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
|
||||
await expect(page.getByText("Согласие на обработку ПД")).toBeVisible();
|
||||
await expect(page.getByText("Политика обработки ПД")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -57,7 +64,7 @@ test("Enter отправляет введённое сообщение", async (
|
||||
await page.goto("/");
|
||||
await page.getByLabel("Сообщение").fill("Отправка с клавиатуры");
|
||||
await page.getByLabel("Сообщение").press("Enter");
|
||||
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("сообщение ограничено 4000 символами", async ({ page }) => {
|
||||
@@ -65,7 +72,7 @@ test("сообщение ограничено 4000 символами", async ({
|
||||
await page.getByLabel("Сообщение").fill("а".repeat(4001));
|
||||
await page.getByRole("button", { name: "Отправить" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("Максимум — 4000 символов");
|
||||
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user