diff --git a/TESTS.md b/TESTS.md
index 48e6854..41b5ff9 100644
--- a/TESTS.md
+++ b/TESTS.md
@@ -2,21 +2,93 @@
Файл поддерживается вместе с unit-тестами. На один тест — не больше двух строк: что подаём, что ожидаем, какую проблему ловим.
-## `src/entities/activity/utils.test.ts`
+## `src/shared/lib/publicLink.test.ts`
+
+- `преобразует название в slug`: ввод — строки с пробелами/символами, строка без латиницы, кастомный fallback; вывод — slug или fallback (`activity`/`series`). Проверяет стабильную генерацию безопасной части публичной ссылки для активности и серии.
+- `генерирует slug с коротким случайным суффиксом`: ввод — название; вывод — `slug-[a-z0-9]{6}`. Проверяет, что публичные ссылки получают случайный суффикс.
+- `генерирует edit token из UUID без дефисов`: ввод отсутствует; вывод — токен `[a-f0-9]{32}`. Проверяет формат секретной ссылки управления.
+
+## `src/shared/lib/datetime.test.ts`
-- `преобразует название в slug`: ввод — строки с пробелами/символами и строка без латиницы; вывод — slug или `activity`. Проверяет стабильную генерацию безопасной части публичной ссылки.
-- `генерирует slug с коротким случайным суффиксом`: ввод — название активности; вывод — `slug-[a-z0-9]{6}`. Проверяет, что публичные ссылки получают случайный суффикс.
-- `генерирует edit token из 18 символов`: ввод отсутствует; вывод — токен `[a-z0-9]{18}`. Проверяет формат секретной ссылки управления.
- `проверяет корректность даты`: ввод — валидный `datetime-local`, `12 мая`, пустая строка; вывод — `true/false`. Проверяет защиту от timestamp-ошибки Supabase до запроса.
- `преобразует дату в ISO, если дата корректна`: ввод — валидная и невалидная дата; вывод — ISO или исходная строка. Проверяет корректный payload для поля `starts_at`.
- `преобразует дату с бэка в значение для datetime-local`: ввод — дата с timezone и мусорная строка; вывод — `YYYY-MM-DDTHH:mm` или исходная строка. Проверяет отображение даты на странице управления.
+
+## `src/entities/activity/utils.test.ts`
+
+- `нормализует Telegram-хэндл в t.me ссылку`: ввод — `@anna`/`anna`/`t.me/anna`/полная ссылка/пусто/`null`; вывод — `https://t.me/anna` или `""`. Проверяет префилл `chat_url` из Telegram профиля под URL-валидацию.
- `возвращает следующий статус активности`: ввод — `active`/`cancelled`; вывод — обратный статус. Проверяет логику отмены и возврата активности.
+- `считает остаток мест из capacity`: ввод — `capacity`/`going` в трёх режимах (`null`, есть места, мест нет); вывод — `kind` `unlimited`/`available`+`left`/`full`. Проверяет расчёт остатка мест для страницы ивента.
+
+## `src/entities/series/utils.test.ts`
+
+- `ежедневное правило даёт подряд идущие дни`: ввод — `unit: day`, горизонт 3; вывод — 3 даты с шагом в сутки от старта. Проверяет базовую генерацию слотов серии.
+- `недельное правило по одному дню шагает через неделю`: ввод — `unit: week`, один день; вывод — тот же день недели с шагом 7 дней. Проверяет недельный слот.
+- `недельное правило по нескольким дням чередует дни недели`: ввод — `weekdays: [пн, ср]`, горизонт 4; вывод — `[пн, ср, пн, ср]`. Проверяет несколько дней в неделе.
+- `интервал в 2 недели удваивает шаг`: ввод — `interval: 2`, `unit: week`; вывод — шаг 14 дней. Проверяет интервал повторения.
+- `месячное правило сохраняет день месяца`: ввод — `unit: month`; вывод — тот же день месяца. Проверяет месячный слот.
+- `окончание по числу встреч ограничивает количество`: ввод — `end: count 2`, горизонт 5; вывод — 2 даты. Проверяет окончание по числу встреч.
+- `окончание по дате обрезает по горизонту`: ввод — `end: until` (старт + 2 дня); вывод — 3 даты включительно. Проверяет окончание по дате.
+- `мусорную дату старта отдаёт пустым списком`: ввод — `не дата`; вывод — `[]`. Проверяет защиту от битого старта.
+- `описывает правило текстом для бейджа`: ввод — дневное/недельное правило; вывод — `каждый день · 19:00`, `каждый вт · 19:00`, `по пн, ср · 19:00`. Проверяет текст бейджа серии.
+- `считает идущих: без отметки идёт, пропуск вычитается`: ввод — 3 участника, отметки `skip`+`going`; вывод — 2. Проверяет расчёт «кто идёт» на встречу.
+
+## `src/entities/series/schemas.test.ts`
+
+- `чистит пустые необязательные поля в null`: ввод — форма серии с пустыми описанием/местом/лимитом/обложкой/чатом; вывод — `null` в этих полях. Проверяет контракт чистки перед маппером.
+- `требует название, город и корректную дату`: ввод — пустые название/город и мусорная дата; вывод — `success: false`. Проверяет обязательные поля шаблона и защиту от timestamp-ошибки.
+- `недельное правило требует хотя бы один день недели`: ввод — `unit: week`, `weekdays: []`; вывод — `success: false`. Проверяет обязательность дней недели для недельного правила.
+- `отклоняет неположительный интервал правила`: ввод — `interval: 0`; вывод — `success: false`. Проверяет валидацию интервала повторения.
+- `отклоняет неположительное число встреч в окончании`: ввод — `end: count 0`; вывод — `success: false`. Проверяет валидацию окончания по числу встреч.
+- `в форме обновления добавляет статус`: ввод — статус `finished` и неизвестный `paused`; вывод — `true`/`false`. Проверяет расширение схемы статусом серии.
+
+## `src/entities/series/mappers.test.ts`
+
+- `преобразует форму создания в payload для API`: ввод — провалидированная форма серии с `null` в необязательных полях; вывод — ISO-дата, `slug`, `edit_token`, `active`, `recurrence` как есть. Проверяет payload создания серии.
+- `преобразует форму записи в payload участника`: ввод — форма записи с пустым и заполненным Telegram; вывод — `null`/значение и `series_id`. Проверяет payload постоянного участника.
+- `преобразует null поля серии в пустые строки для формы управления`: ввод — серия с `null` в описании/месте/обложке/чате; вывод — пустые строки. Проверяет префилл формы управления.
+- `сохраняет заполненные поля серии в форме управления`: ввод — серия с чатом, статусом и правилом; вывод — те же значения. Проверяет, что данные серии не теряются в форме.
+
+## `src/entities/series/local.test.ts`
+
+- `повторное добавление того же slug не дублирует запись`: ввод — двойной `addMySeries` одной серии; вывод — список длины 1. Проверяет дедуп локального списка «Мои серии».
+- `повторная запись в ту же серию не дублирует membership`: ввод — двойной `addMembership` одной серии; вывод — список длины 1. Проверяет дедуп «Хожу постоянно».
+- `выход убирает membership и находит по slug`: ввод — `addMembership` + `findMembership` + `removeMembership`; вывод — найденный member, затем `null`. Проверяет поиск и выход из серии.
+
+## `src/entities/reminder/utils.test.ts`
+
+- `возвращает старт минус смещение для будущей активности`: ввод — старт через 5ч, смещение 2ч; вывод — момент за 2ч до старта. Проверяет расчёт времени срабатывания напоминания.
+- `момент напоминания уже в прошлом → null`: ввод — старт через 1ч, смещение 2ч; вывод — `null`. Проверяет отказ планировать напоминание в прошлом.
+- `мусорную дату старта отдаёт null`: ввод — `не дата`; вывод — `null`. Проверяет защиту от битой даты старта.
+- `склоняет час по числу`: ввод — 1/2/11/24; вывод — `час`/`часа`/`часов`/`часа`. Проверяет русскую форму слова в тексте уведомления.
+
+## `src/entities/reminder/local.test.ts`
+
+- `повторное напоминание того же slug не дублирует, берёт новый id`: ввод — двойной `addReminder` одного slug; вывод — список длины 1 с новым `notificationId`. Проверяет дедуп и перезапись напоминания.
+- `удаление по slug убирает запись`: ввод — два напоминания + `removeReminder`; вывод — остаётся другое. Проверяет снятие напоминания при отмене.
+
+## `src/entities/activity/local.test.ts`
+
+- `добавляет и убирает slug при повторном тоггле`: ввод — двойной `toggleLike` одного slug; вывод — `[slug]`, затем `[]`. Проверяет, что повторный лайк снимает отметку.
+- `тоггл идемпотентен: один slug не дублируется`: ввод — лайки разных slug; вывод — список без дублей в порядке добавления. Проверяет device-local состояние лайков.
+- `повторное добавление того же slug не дублирует запись`: ввод — двойной `addMyActivity` одной активности; вывод — список длины 1. Проверяет дедуп локального списка «Мои ивенты».
+
+## `src/entities/activity/schemas.test.ts`
+
+- `чистит пустые необязательные поля в null`: ввод — форма создания с пустыми описанием/местом/лимитом/обложкой/чатом; вывод — `null` в этих полях. Проверяет контракт чистки, на который опирается маппер.
+- `приводит capacity к положительному числу`: ввод — `capacity: "12"`; вывод — `12`. Проверяет coerce строкового лимита в число.
+- `требует название, город и корректную дату`: ввод — пустые название/город и мусорная дата; вывод — `success: false`. Проверяет обязательные поля и защиту от timestamp-ошибки.
+- `отклоняет некорректную ссылку на обложку`: ввод — `cover_url` не-URL; вывод — `success: false`. Проверяет валидацию ссылки обложки.
+- `отклоняет некорректную ссылку на чат`: ввод — `chat_url` не-URL; вывод — `success: false`. Проверяет валидацию ссылки на чат.
+- `в форме обновления добавляет статус`: ввод — статус `cancelled` и неизвестный `deleted`; вывод — `true`/`false`. Проверяет расширение схемы статусом.
## `src/entities/activity/mappers.test.ts`
- `преобразует форму создания в payload для API`: ввод — провалидированная форма с `null` в необязательных полях; вывод — `null`, ISO-дата, `slug`, `edit_token`, `active`. Проверяет payload создания (пустые строки в `null` чистит схема).
-- `сохраняет заполненные необязательные поля при создании`: ввод — форма с описанием, местом, лимитом и обложкой; вывод — те же значения. Проверяет, что маппер не затирает введённые данные.
+- `сохраняет заполненные необязательные поля при создании`: ввод — форма с описанием, местом, лимитом, обложкой и чатом; вывод — те же значения. Проверяет, что маппер не затирает введённые данные.
- `преобразует форму обновления в payload для API`: ввод — провалидированная форма редактирования с `null` в необязательных полях; вывод — `null`, ISO-дата и статус. Проверяет payload сохранения активности.
+- `преобразует null chat_url в пустую строку для формы управления`: ввод — активность с `chat_url: null`; вывод — `""`. Проверяет префилл формы управления пустым полем чата.
+- `сохраняет заполненный chat_url в форме управления`: ввод — активность с `chat_url`; вывод — та же ссылка. Проверяет префилл формы управления заданным чатом.
## `src/entities/participant/utils.test.ts`
@@ -25,10 +97,42 @@
## `src/entities/participant/mappers.test.ts`
-- `преобразует форму отклика в payload для API`: ввод — форма с пустыми Telegram/comment; вывод — `null` и `activity_id`. Проверяет payload отклика без пустых строк.
-- `сохраняет заполненные необязательные поля`: ввод — форма с Telegram/comment; вывод — те же значения. Проверяет, что данные участника не теряются.
+- `преобразует форму отклика в payload для API`: ввод — форма с пустым Telegram; вывод — `null`, `activity_id` и `status: going`. Проверяет payload отклика без пустых строк (статус всегда «иду»).
+- `сохраняет заполненные необязательные поля`: ввод — форма с Telegram; вывод — те же значения и `status: going`. Проверяет, что данные участника не теряются, а статус всегда «иду».
+
+## `src/entities/participant/schemas.test.ts`
+
+- `принимает отклик с обязательным именем и статусом «иду»`: ввод — `name` + `status: going`; вывод — `going`, пустой Telegram. Проверяет минимальную валидную форму отклика.
+- `требует непустое имя`: ввод — имя из пробелов; вывод — `success: false`. Проверяет обязательность имени.
+- `разрешает только статус «иду»`: ввод — `status: maybe`; вывод — `success: false`. Проверяет литерал статуса после сведения к «иду».
+
+## `src/entities/participant/local.test.ts`
+
+- `повторный отклик на тот же slug не дублирует запись`: ввод — двойной `addMyResponse` одного slug; вывод — список длины 1. Проверяет дедуп локального списка «Посещено».
+
+## `src/entities/profile/schemas.test.ts`
+
+- `пустые текстовые поля и соц.ссылки превращает в null`: ввод — форма с пустыми городом, фото и всеми соцсетями; вывод — `null` в этих полях, `categories` по умолчанию `[]`. Проверяет чистку пустых строк перед сохранением профиля.
+- `сохраняет заполненные поля и интересы`: ввод — заполненные город/фото/интересы/Telegram; вывод — те же значения, незаполненные соцсети `null`. Проверяет, что введённые данные не теряются.
+- `принимает локальный URI фото и отклоняет пустое имя`: ввод — локальный `file://` URI фото и имя из пробелов; вывод — `true` для фото, `false` для имени. Проверяет, что device-URI фото не отклоняется, а имя обязательно.
+
+## `src/entities/profile/mappers.test.ts`
+
+- `null в полях профиля превращает в пустую строку для формы`: ввод — профиль с `null` в городе/фото/соцсетях; вывод — пустые строки. Проверяет префилл формы редактирования из хранилища.
+- `сохраняет заполненные поля и не тащит дату регистрации`: ввод — заполненный профиль с `registeredAt`; вывод — те же значения без `registeredAt`. Проверяет, что дата регистрации не попадает в форму.
+
+## `src/entities/profile/utils.test.ts`
+
+- `возвращает только заполненные соц.ссылки в порядке словаря`: ввод — соцсети вперемешку с `null`; вывод — только заполненные в порядке `socialOptions`. Проверяет рендер и переход наружу.
+- `форматирует дату регистрации, мусор отдаёт как есть`: ввод — валидная дата и мусорная строка; вывод — `ДД.ММ.ГГГГ` или исходная строка. Проверяет отображение даты регистрации в профиле.
## `src/shared/errors/getRussianErrorMessage.test.ts`
- `переводит известные технические ошибки`: ввод — timestamp/network ошибки; вывод — русские сообщения. Проверяет, что технические backend/browser ошибки не попадают в UI.
- `возвращает fallback для неизвестной ошибки`: ввод — неизвестная Error и не-Error значение; вывод — fallback. Проверяет безопасное русское сообщение для любых неожиданных ошибок.
+
+## `src/shared/lib/shareActivity.test.ts`
+
+- `успешный шеринг возвращает kind «shared»`: ввод — `Share.share` резолвится; вывод — `{ kind: "shared" }`. Проверяет открытие системного листа на нативе.
+- `отмену листа (AbortError) не считает ошибкой`: ввод — `Share.share` бросает `AbortError`; вывод — `{ kind: "shared" }`. Проверяет, что отмена листа не показывается как ошибка.
+- `прочую ошибку отдаёт с русским сообщением`: ввод — `Share.share` бросает обычную ошибку; вывод — `{ kind: "error" }` с русским текстом. Проверяет, что сырые сообщения не утекают в UI.
diff --git a/app.json b/app.json
index 6c05b2c..2da253b 100644
--- a/app.json
+++ b/app.json
@@ -22,6 +22,17 @@
"web": {
"favicon": "./assets/favicon.png"
},
- "plugins": ["expo-router", "expo-status-bar"]
+ "plugins": [
+ "expo-router",
+ "expo-status-bar",
+ [
+ "expo-image-picker",
+ {
+ "photosPermission": "Venty использует фото из галереи для аватара профиля.",
+ "microphonePermission": false
+ }
+ ],
+ "expo-notifications"
+ ]
}
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index c2a5ab7..3b8299b 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,10 +1,24 @@
import "../global.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import * as Notifications from "expo-notifications";
import { Stack } from "expo-router";
import { useState } from "react";
-import { Text, View } from "react-native";
+import { Platform, Text, View } from "react-native";
import { TextLink } from "../src/shared/ui/TextLink";
+// Показ запланированного напоминания, пока приложение на переднем плане.
+// На web expo-notifications нет — обработчик ставим только на нативе.
+if (Platform.OS !== "web") {
+ Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldPlaySound: false,
+ shouldSetBadge: false,
+ shouldShowBanner: true,
+ shouldShowList: true,
+ }),
+ });
+}
+
export default function RootLayout() {
const [client] = useState(() => new QueryClient());
return (
@@ -14,6 +28,7 @@ export default function RootLayout() {
Главная
Создать
+ Профиль
diff --git a/app/a/[slug].tsx b/app/a/[slug].tsx
index 176898c..dc18b69 100644
--- a/app/a/[slug].tsx
+++ b/app/a/[slug].tsx
@@ -1,12 +1,17 @@
import { Image, Text } from "react-native";
+import { getCapacityState } from "../../src/entities/activity/utils";
import { getParticipantStatusText } from "../../src/entities/participant/utils";
+import { useLikeActivity } from "../../src/features/like-activity/useLikeActivity";
import { ResponseForm } from "../../src/features/respond-to-activity/ResponseForm";
import { useRespondToActivityForm } from "../../src/features/respond-to-activity/useRespondToActivityForm";
import { ActivityNotFound } from "../../src/shared/ui/ActivityNotFound";
import { BackLink } from "../../src/shared/ui/BackLink";
+import { Button } from "../../src/shared/ui/Button";
+import { ChatLinkButton } from "../../src/shared/ui/ChatLinkButton";
import { LoadingPage } from "../../src/shared/ui/LoadingPage";
import { Page } from "../../src/shared/ui/Page";
import { ParticipantList } from "../../src/shared/ui/ParticipantList";
+import { ShareButton } from "../../src/shared/ui/ShareButton";
export default function ActivityPage() {
const {
@@ -20,6 +25,7 @@ export default function ActivityPage() {
submit,
isSaving,
hasResponded,
+ reminderScheduled,
} = useRespondToActivityForm();
if (isLoading) return ;
@@ -43,14 +49,11 @@ export default function ActivityPage() {
{activity.status === "cancelled" ? (
ОТМЕНЕНО
) : null}
-
- Идут: {stats.going} Может быть: {stats.maybe} Не могут: {stats.cant}
-
- {activity.capacity ? (
-
- {stats.going} / {activity.capacity} идут
-
- ) : null}
+ Идут: {stats.going}
+
+
+
+ {activity.chat_url ? : null}
{activity.status !== "cancelled" && !hasResponded ? (
) : null}
@@ -59,6 +62,7 @@ export default function ActivityPage() {
Ты уже откликнулся с этого устройства.
) : null}
+ {reminderScheduled ? Напомним за 2 часа до начала. : null}
);
}
+
+function LikeButton({ slug }: { slug: string }) {
+ const { isLiked, toggle, isToggling } = useLikeActivity(slug);
+
+ return (
+
+ );
+}
+
+type CapacityNoticeProps = {
+ capacity: number | null;
+ going: number;
+};
+
+function CapacityNotice({ capacity, going }: CapacityNoticeProps) {
+ const state = getCapacityState({ capacity, going });
+ if (state.kind === "unlimited") return null;
+
+ if (state.kind === "full") {
+ return Мест нет;
+ }
+
+ return Осталось мест: {state.left};
+}
diff --git a/app/create.tsx b/app/create.tsx
index 52d792b..8d5dd21 100644
--- a/app/create.tsx
+++ b/app/create.tsx
@@ -1,71 +1,32 @@
+import { useState } from "react";
import { Text } from "react-native";
-import { useCreateActivityForm } from "../src/features/create-activity/useCreateActivityForm";
+import { CreateActivityForm } from "../src/features/create-activity/CreateActivityForm";
+import { CreateSeriesForm } from "../src/features/create-series/CreateSeriesForm";
import { BackLink } from "../src/shared/ui/BackLink";
-import { Button } from "../src/shared/ui/Button";
-import { DateTimeInput } from "../src/shared/ui/DateTimeInput";
-import { ErrorText } from "../src/shared/ui/ErrorText";
-import { FormField } from "../src/shared/ui/FormField";
import { Page } from "../src/shared/ui/Page";
-import { Textarea } from "../src/shared/ui/Textarea";
+import { Select } from "../src/shared/ui/Select";
+
+type Mode = "activity" | "series";
+
+const modeOptions: { value: Mode; label: string }[] = [
+ { value: "activity", label: "разовое" },
+ { value: "series", label: "регулярное" },
+];
export default function CreatePage() {
- const { form, submit, isSaving } = useCreateActivityForm();
- const { control, handleSubmit } = form;
- const { errors } = form.formState;
+ const [mode, setMode] = useState("activity");
return (
- Создать активность
-
-
-
-
-
-
-
-
- {errors.root?.server?.message}
+ Создать
+
+ {mode === "activity" ? : }
);
}
diff --git a/app/index.tsx b/app/index.tsx
index 7411745..fdea8a7 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,37 +1,127 @@
+import { useState } from "react";
import { Text, View } from "react-native";
import { useActivities } from "../src/entities/activity/hooks";
+import type { Activity } from "../src/entities/activity/types";
+import { useSeries } from "../src/entities/series/hooks";
+import type { SeriesWithMeetings } from "../src/entities/series/types";
+import {
+ formatRecurrenceText,
+ listOccurrences,
+} from "../src/entities/series/utils";
import { ErrorText } from "../src/shared/ui/ErrorText";
+import { Input } from "../src/shared/ui/Input";
import { LoadingText } from "../src/shared/ui/LoadingText";
import { Page } from "../src/shared/ui/Page";
import { TextLink } from "../src/shared/ui/TextLink";
+type FeedItem = { key: string; date: number; node: React.ReactNode };
+
export default function HomePage() {
- const activities = useActivities();
+ const [city, setCity] = useState("");
+ const [query, setQuery] = useState("");
+ const filter = { city: city.trim(), query: query.trim() };
+ const activities = useActivities(filter);
+ const series = useSeries(filter);
+ const items = buildFeed(activities.data ?? [], series.data ?? []);
+ const isLoading = activities.isLoading || series.isLoading;
+
return (
Venty
- Уродливые ссылки на активности, чтобы делать что-то с людьми
- [Создать активность]
- Последние активности:
- {activities.isLoading ? : null}
+ Уродливые ссылки на мероприятия, чтобы делать что-то с людьми
+ [Создать мероприятие]
+
+
+ Ближайшие мероприятия:
+ {isLoading ? : null}
- {activities.data?.length === 0 ? (
- Активностей пока нет.
+ {!isLoading && items.length === 0 ? (
+ Мероприятий пока нет.
) : null}
-
- {activities.data?.map((activity) => (
-
-
- {activity.title} — {activity.city} —{" "}
- {new Date(activity.starts_at).toLocaleString()}
-
- открыть
-
- ))}
-
+ {items.map((item) => item.node)}
);
}
+
+// Активности и серии в одной ленте по возрастанию даты ближайшей встречи.
+function buildFeed(
+ activities: Activity[],
+ series: SeriesWithMeetings[],
+): FeedItem[] {
+ const activityItems = activities.map(toActivityItem);
+ const seriesItems = series.map(toSeriesItem);
+ return [...activityItems, ...seriesItems].sort((a, b) => a.date - b.date);
+}
+
+function toActivityItem(activity: Activity): FeedItem {
+ const startsAt = new Date(activity.starts_at);
+ return {
+ key: `activity-${activity.id}`,
+ date: startsAt.getTime(),
+ node: (
+
+
+ {activity.title} — {activity.city} — {startsAt.toLocaleString()}
+
+ открыть
+
+ ),
+ };
+}
+
+function toSeriesItem(series: SeriesWithMeetings): FeedItem {
+ const next = nextMeetingDate(series);
+ const schedule = formatRecurrenceText(series.recurrence, series.starts_at);
+ return {
+ key: `series-${series.id}`,
+ date: next?.getTime() ?? Number.POSITIVE_INFINITY,
+ node: (
+
+
+ {series.title} — {series.city} · серия
+
+
+ {next
+ ? `${schedule} · ближайшая: ${next.toLocaleString()}`
+ : `${schedule} · ближайших встреч нет`}
+
+ открыть
+
+ ),
+ };
+}
+
+// Ближайшая встреча в будущем: активный слот или следующая дата по правилу.
+// Будущих встреч нет — возвращаем null, чтобы не поднимать серию прошедшим стартом.
+function nextMeetingDate(series: SeriesWithMeetings): Date | null {
+ const now = Date.now();
+ const fromMeetings = series.meetings
+ .filter((meeting) => meeting.status === "active")
+ .map((meeting) => new Date(meeting.starts_at).getTime());
+ const fromRule = listOccurrences(series.recurrence, series.starts_at).map(
+ (iso) => new Date(iso).getTime(),
+ );
+ const upcoming = [...fromMeetings, ...fromRule]
+ .filter((time) => time >= now)
+ .sort((a, b) => a - b);
+ return upcoming.length > 0 ? new Date(upcoming[0]) : null;
+}
diff --git a/app/manage/[editToken].tsx b/app/manage/[editToken].tsx
index 173d45d..7802c35 100644
--- a/app/manage/[editToken].tsx
+++ b/app/manage/[editToken].tsx
@@ -7,6 +7,7 @@ import { BackLink } from "../../src/shared/ui/BackLink";
import { LoadingPage } from "../../src/shared/ui/LoadingPage";
import { Page } from "../../src/shared/ui/Page";
import { ParticipantList } from "../../src/shared/ui/ParticipantList";
+import { ShareButton } from "../../src/shared/ui/ShareButton";
export default function ManagePage() {
const {
@@ -30,7 +31,7 @@ export default function ManagePage() {
Управление активностью
Публичная ссылка: {publicUrl}
- Копируй и отправляй вручную
+
;
+ if (!series) return ;
+
+ return (
+
+
+ Управление серией
+ Публичная ссылка: {publicUrl}
+
+
+
+
+
+ );
+}
+
+type MeetingsProps = {
+ meetings: Meeting[];
+ onReschedule: (id: string, startsAt: string) => void;
+ onCancel: (id: string) => void;
+ isRescheduling: boolean;
+ isCancelling: boolean;
+};
+
+function Meetings({
+ meetings,
+ onReschedule,
+ onCancel,
+ isRescheduling,
+ isCancelling,
+}: MeetingsProps) {
+ return (
+
+ Встречи
+ Создаются автоматически по правилу.
+ {meetings.length === 0 ? Встреч пока нет. : null}
+ {meetings.map((meeting) => (
+
+ ))}
+
+ );
+}
+
+type MeetingRowProps = {
+ meeting: Meeting;
+ onReschedule: (id: string, startsAt: string) => void;
+ onCancel: (id: string) => void;
+ isRescheduling: boolean;
+ isCancelling: boolean;
+};
+
+function MeetingRow({
+ meeting,
+ onReschedule,
+ onCancel,
+ isRescheduling,
+ isCancelling,
+}: MeetingRowProps) {
+ const [startsAt, setStartsAt] = useState(
+ toDateTimeLocalInputValue(meeting.starts_at),
+ );
+ const cancelled = meeting.status === "cancelled";
+
+ return (
+
+ {new Date(meeting.starts_at).toLocaleString()}
+ {cancelled ? (
+ ОТМЕНЕНА
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+function Members({ members }: { members: SeriesMember[] }) {
+ return (
+
+ Постоянные участники
+ {members.length === 0 ? Пока никто не записан. : null}
+ {members.map((member) => (
+
+
+ {member.name}
+ {member.telegram ? ` — ${member.telegram}` : ""}
+
+
+ ))}
+
+ );
+}
diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx
new file mode 100644
index 0000000..f213589
--- /dev/null
+++ b/app/profile/edit.tsx
@@ -0,0 +1,20 @@
+import { Text } from "react-native";
+import { EditProfileForm } from "../../src/features/edit-profile/EditProfileForm";
+import { useEditProfileForm } from "../../src/features/edit-profile/useEditProfileForm";
+import { BackLink } from "../../src/shared/ui/BackLink";
+import { LoadingPage } from "../../src/shared/ui/LoadingPage";
+import { Page } from "../../src/shared/ui/Page";
+
+export default function EditProfilePage() {
+ const { form, submit, isLoading, isSaving } = useEditProfileForm();
+
+ if (isLoading) return ;
+
+ return (
+
+
+ Редактировать профиль
+
+
+ );
+}
diff --git a/app/profile/index.tsx b/app/profile/index.tsx
new file mode 100644
index 0000000..25d0fd8
--- /dev/null
+++ b/app/profile/index.tsx
@@ -0,0 +1,206 @@
+import { Linking, Text, View } from "react-native";
+import { useMyActivities } from "../../src/entities/activity/hooks";
+import type { MyActivity } from "../../src/entities/activity/local";
+import { useMyResponses } from "../../src/entities/participant/hooks";
+import { useProfile, useResetProfile } from "../../src/entities/profile/hooks";
+import type { Profile } from "../../src/entities/profile/types";
+import {
+ formatRegisteredAt,
+ getFilledSocials,
+} from "../../src/entities/profile/utils";
+import {
+ useReminderSettings,
+ useSaveReminderSettings,
+} from "../../src/entities/reminder/hooks";
+import { defaultReminderSettings } from "../../src/entities/reminder/settings";
+import { hoursWord, offsetOptions } from "../../src/entities/reminder/utils";
+import { useMyMemberships, useMySeries } from "../../src/entities/series/hooks";
+import type { Membership, MySeries } from "../../src/entities/series/local";
+import { Avatar } from "../../src/shared/ui/Avatar";
+import { BackLink } from "../../src/shared/ui/BackLink";
+import { Button } from "../../src/shared/ui/Button";
+import { LoadingPage } from "../../src/shared/ui/LoadingPage";
+import { Page } from "../../src/shared/ui/Page";
+import { TextLink } from "../../src/shared/ui/TextLink";
+
+export default function ProfilePage() {
+ const profileQuery = useProfile();
+ const myActivities = useMyActivities();
+ const myResponses = useMyResponses();
+ const mySeries = useMySeries();
+ const myMemberships = useMyMemberships();
+ const reset = useResetProfile();
+
+ if (profileQuery.isLoading) return ;
+ const profile = profileQuery.data;
+ if (!profile) return ;
+
+ return (
+
+
+
+
+
+
+
+
+
+ [Редактировать профиль]
+ reset.mutate()} />
+
+ );
+}
+
+function EmptyProfile() {
+ return (
+
+
+ Профиль
+ Профиль ещё не создан на этом устройстве.
+ [Создать профиль]
+
+ );
+}
+
+function ProfileHeader({ profile }: { profile: Profile }) {
+ return (
+
+
+ {profile.fullName}
+ {profile.city != null ? {profile.city} : null}
+ {profile.categories.length > 0 ? (
+ Интересы: {profile.categories.join(", ")}
+ ) : null}
+ На Venty с {formatRegisteredAt(profile.registeredAt)}
+
+ );
+}
+
+function Stats({ created, visited }: { created: number; visited: number }) {
+ return (
+
+ Статистика
+ Создано: {created}
+ Посещено: {visited}
+ Друзья: — (появится после авторизации)
+
+ );
+}
+
+function Socials({ profile }: { profile: Profile }) {
+ const filled = getFilledSocials(profile.socials);
+ if (filled.length === 0) return null;
+
+ return (
+
+ Соцсети
+ {filled.map((social) => (
+ openExternal(social.url)}
+ />
+ ))}
+
+ );
+}
+
+function ReminderSettings() {
+ const settingsQuery = useReminderSettings();
+ const save = useSaveReminderSettings();
+ const settings = settingsQuery.data ?? defaultReminderSettings;
+
+ const toggle = () => save.mutate({ ...settings, enabled: !settings.enabled });
+ const pickOffset = (offsetHours: number) =>
+ save.mutate({ enabled: true, offsetHours });
+
+ return (
+
+ Напоминания
+
+ {settings.enabled ? (
+
+ За сколько предупредить:
+
+ {offsetOptions.map((hours) => (
+ pickOffset(hours)}
+ />
+ ))}
+
+
+ Уже запланированные напоминания сохранят прежнее время.
+
+
+ ) : null}
+
+ );
+}
+
+function MyActivities({ items }: { items: MyActivity[] }) {
+ return (
+
+ Мои ивенты
+ {items.length === 0 ? Пока ничего не создано. : null}
+ {items.map((item) => (
+
+
+ {item.title} — {item.city}
+
+
+ страница
+ управление
+
+
+ ))}
+
+ );
+}
+
+function MySeriesList({ items }: { items: MySeries[] }) {
+ return (
+
+ Мои серии
+ {items.length === 0 ? Пока ничего не создано. : null}
+ {items.map((item) => (
+
+
+ {item.title} — {item.city}
+
+
+ страница
+
+ управление
+
+
+
+ ))}
+
+ );
+}
+
+function AttendedSeries({ items }: { items: Membership[] }) {
+ return (
+
+ Хожу постоянно
+ {items.length === 0 ? Пока никуда не записан. : null}
+ {items.map((item) => (
+
+ {item.seriesSlug}
+
+ ))}
+
+ );
+}
+
+function openExternal(url: string) {
+ Linking.openURL(url).catch(() => {});
+}
diff --git a/app/s/[slug].tsx b/app/s/[slug].tsx
new file mode 100644
index 0000000..1d7412f
--- /dev/null
+++ b/app/s/[slug].tsx
@@ -0,0 +1,199 @@
+import { useLocalSearchParams } from "expo-router";
+import { useEffect } from "react";
+import { Image, Text, View } from "react-native";
+import {
+ useEnsureUpcomingMeetings,
+ useMarks,
+ useMeetings,
+ useMembers,
+ useSeriesBySlug,
+} from "../../src/entities/series/hooks";
+import type {
+ Meeting,
+ MeetingMark,
+ Series,
+ SeriesMember,
+} from "../../src/entities/series/types";
+import {
+ formatRecurrenceText,
+ getGoingCount,
+} from "../../src/entities/series/utils";
+import { JoinSeriesForm } from "../../src/features/join-series/JoinSeriesForm";
+import { useJoinSeries } from "../../src/features/join-series/useJoinSeries";
+import { useMarkMeeting } from "../../src/features/mark-meeting/useMarkMeeting";
+import { ActivityNotFound } from "../../src/shared/ui/ActivityNotFound";
+import { BackLink } from "../../src/shared/ui/BackLink";
+import { Button } from "../../src/shared/ui/Button";
+import { ChatLinkButton } from "../../src/shared/ui/ChatLinkButton";
+import { LoadingPage } from "../../src/shared/ui/LoadingPage";
+import { Page } from "../../src/shared/ui/Page";
+import { ShareButton } from "../../src/shared/ui/ShareButton";
+
+export default function SeriesPage() {
+ const { slug } = useLocalSearchParams<{ slug: string }>();
+ const seriesQuery = useSeriesBySlug(slug);
+ const series = seriesQuery.data ?? undefined;
+ const { mutate: ensureMeetings } = useEnsureUpcomingMeetings();
+ const meetings = useMeetings(series?.id).data ?? [];
+ const members = useMembers(series?.id).data ?? [];
+ const marks = useMarks(meetings.map((meeting) => meeting.id)).data ?? [];
+ const { membership, form, join, leave, isJoining } = useJoinSeries(
+ series,
+ meetings,
+ );
+ const { toggle } = useMarkMeeting({
+ memberId: membership?.memberId,
+ series,
+ meetings,
+ });
+
+ // Слоты досоздаются лениво при открытии страницы серии.
+ useEffect(() => {
+ if (series) ensureMeetings(series);
+ }, [series, ensureMeetings]);
+
+ if (seriesQuery.isLoading) return ;
+ if (!series) return ;
+
+ const memberId = membership?.memberId;
+
+ return (
+
+
+
+
+ {membership ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function SeriesHeader({ series }: { series: Series }) {
+ return (
+
+ {series.cover_url ? (
+
+ ) : null}
+ {series.title}
+
+ {formatRecurrenceText(series.recurrence, series.starts_at)}
+
+ {series.city}
+ {series.location_text ? {series.location_text} : null}
+ {series.description ? {series.description} : null}
+
+ );
+}
+
+function JoinedNotice({
+ onLeave,
+ chatUrl,
+}: {
+ onLeave: () => void;
+ chatUrl: string | null;
+}) {
+ return (
+
+ Ты ходишь постоянно.
+ {chatUrl ? : null}
+
+
+ );
+}
+
+type ScheduleProps = {
+ meetings: Meeting[];
+ members: SeriesMember[];
+ marks: MeetingMark[];
+ memberId?: string;
+ onToggle: (meetingId: string, current?: MeetingMark["status"]) => void;
+};
+
+function Schedule({
+ meetings,
+ members,
+ marks,
+ memberId,
+ onToggle,
+}: ScheduleProps) {
+ return (
+
+ Ближайшие встречи
+ {meetings.length === 0 ? (
+ Встречи ещё не запланированы.
+ ) : null}
+ {meetings.map((meeting) => (
+
+ ))}
+
+ );
+}
+
+type MeetingRowProps = {
+ meeting: Meeting;
+ going: number;
+ myMark?: MeetingMark["status"];
+ onToggle: (meetingId: string, current?: MeetingMark["status"]) => void;
+};
+
+function MeetingRow({ meeting, going, myMark, onToggle }: MeetingRowProps) {
+ const cancelled = meeting.status === "cancelled";
+ const isGoing = myMark !== "skip";
+
+ return (
+
+ {new Date(meeting.starts_at).toLocaleString()}
+ {cancelled ? (
+ ОТМЕНЕНА
+ ) : (
+ Идут: {going}
+ )}
+ {!cancelled && myMark !== undefined ? (
+ onToggle(meeting.id, myMark)}
+ />
+ ) : null}
+
+ );
+}
+
+function marksFor(marks: MeetingMark[], meetingId: string) {
+ return marks.filter((mark) => mark.meeting_id === meetingId);
+}
+
+// Отметка текущего участника по встрече. Нет отметки, но участник записан —
+// дефолт «иду»; не записан (`memberId` пуст) — кнопки отметки не показываем.
+function myMark(
+ marks: MeetingMark[],
+ meetingId: string,
+ memberId?: string,
+): MeetingMark["status"] | undefined {
+ if (memberId == null) return undefined;
+ const mark = marks.find(
+ (item) => item.meeting_id === meetingId && item.member_id === memberId,
+ );
+ return mark?.status ?? "going";
+}
diff --git a/docs/about_the_project.md b/docs/about_the_project.md
index 640faf3..daee397 100644
--- a/docs/about_the_project.md
+++ b/docs/about_the_project.md
@@ -22,12 +22,15 @@ Venty — мобильное приложение для организации
## Целевая аудитория
-**Организаторы**
+Отдельной роли «организатор» в продукте нет. Создавать мероприятия может любой пользователь — это две модели поведения одного и того же человека, а не два разных статуса.
+
+**Кто создаёт мероприятия**
- Speaking clubs, спортивные сообщества, образовательные проекты
- Городские инициативы и локальные НКО
+- Любой житель города, которому есть что организовать
-**Участники**
+**Кто ищет и участвует**
- Студенты и молодёжь 18–35 лет
- Люди, ищущие нетворкинг и досуг
@@ -48,6 +51,22 @@ Venty — мобильное приложение для организации
Каждый ивент получает публичный URL. Когда пользователь делится ссылкой, получатель попадает на читаемую страницу события, а не на экран скачивания приложения. Это снижает барьер входа для новых пользователей и делает каждый ивент самостоятельной единицей контента для распространения в мессенджерах и соцсетях.
+Форма отклика на странице — только для тех, у кого ещё нет аккаунта. Это нужно, чтобы организатор мог поделиться ссылкой со знакомыми, и они откликнулись прямо со страницы, не заводя аккаунт. Авторизованные пользователи участвуют через приложение.
+
+---
+
+## Открытое создание мероприятий — осознанный компромисс
+
+Отдельной роли организатора в продукте нет: создавать мероприятия может любой пользователь. Это снижает барьер входа и быстрее накапливает контент и аудиторию.
+
+Это осознанный продуктовый компромисс:
+
+- **Быстрее наполняется лента** — события появляются без ручного допуска и привратников.
+- **Меньше friction для локальных организаторов и небольших комьюнити** — чтобы провести встречу, не нужен статус, медиаканал или одобрение.
+- **Сильнее network effect и органический рост** — каждый новый ивент работает на распространение продукта внутри города.
+
+Да, открытое создание несёт риск спама и хаотичного появления мероприятий. Но эта проблема обычно возникает только при заметном объёме аудитории — то есть сама по себе является признаком роста. На раннем этапе приоритет не строгий контроль публикации, а максимальное упрощение создания событий и распространения контента внутри города и локальных комьюнити.
+
---
## Монетизация
@@ -62,14 +81,14 @@ Venty — мобильное приложение для организации
---
-## Модерация и открытое создание ивентов
+## Модерация
-На раннем этапе создавать мероприятия может любой пользователь. Риск спама решается поэтапно:
+Риск спама решается поэтапно — сложность защиты растёт вслед за аудиторией, а не опережает её.
-- **На старте:** лимиты на количество ивентов, блокировки по IP, жалобы пользователей, ручная проверка
-- **При росте (3k+ MAU):** рейтинг доверия, верификация через номер телефона, система репутации
+- **На MVP — базовые ограничения.** Хватает простых мер: rate limiting, блокировки по IP, лимиты на количество создаваемых событий, жалобы пользователей и ручная модерация.
+- **После появления стабильной аудитории.** Сложные механизмы — верификация (например, через номер телефона), доверительные уровни — актуальны только тогда, когда объём аудитории делает спам реальной проблемой. На повторные нарушения реагируем не оценкой организаторов, а разбором случая: после нескольких жалоб модерация смотрит ситуацию и при подтверждении блокирует пользователя.
-Платная подписка как барьер для создания ивентов — крайняя мера, убивает органический рост контента.
+Платная подписка как **обязательное условие** для создания мероприятий на раннем этапе скорее вредит росту: она ставит барьер ровно там, где нужно убирать friction, и ограничивает появление нового контента. Это крайняя мера, а не стартовый инструмент модерации.
---
@@ -88,4 +107,5 @@ Venty — мобильное приложение для организации
- Алгоритмы рекомендаций в ленте
- Чат со сложными настройками (роли, модерация, боты)
-- Продвинутая модерация (актуальна при 3k+ MAU)
+- Продвинутая модерация — верификация, доверительные уровни, блокировка по разбору нарушений (актуальны после появления стабильной аудитории)
+- Отдельная роль организатора и платный доступ к созданию событий
diff --git a/docs/todo.md b/docs/todo.md
new file mode 100644
index 0000000..2cc904c
--- /dev/null
+++ b/docs/todo.md
@@ -0,0 +1,104 @@
+# Бэклог
+
+Задачи по приоритетам. Внутри тира порядок не строгий. Детали активных задач — в `plan.md`.
+
+## Руководящий принцип: интегрируемся, а не заменяем
+
+Venty не пытается заменить 2gis (карта), Instagram, Telegram (чат) и другие сервисы.
+Venty — это **лента и точка распространения** городских событий; глубокую функциональность
+делегируем туда, где она уже удобна, через ссылки. Это снижает стоимость разработки и
+трение, и укладывается в ugly-MVP. Следствия отмечены в пунктах ниже.
+
+---
+
+## В работе
+
+### Профиль пользователя (device-local)
+
+- План — в `.orch/plan_profile.md`.
+- **Соц.сети в профиле** — пользователь добавляет ссылки (Telegram, Instagram и т.д.).
+ Прямое воплощение принципа «интегрируемся, а не заменяем»: контакт и общение уходят
+ в привычные сервисы, Venty их не дублирует. Полезно на раннем этапе.
+- При появлении авторизации профиль переезжает на серверный аккаунт; «выйти» становится
+ настоящим logout (сейчас — сброс локальных данных), «друзья» получают backend
+ (сейчас — плейсхолдер).
+
+---
+
+## P0 — дёшево и прямо усиливает ядро (лента города + распространение)
+
+- **Фильтр по городу + поиск в ленте.** Само обещание продукта — «все события города».
+ Сейчас лента валит всё подряд; без фильтра «найти интересное» не работает.
+- **OG/preview метатеги на веб-странице ивента.** Сам механизм роста: ссылка в мессенджер
+ → читаемое превью (название/дата/место). Без них главный distribution-канал не работает.
+ Проверить наличие, добавить.
+- **Кнопка «поделиться»** с готовой ссылкой на странице ивента — замыкает growth-петлю.
+- **Лайк.** Часть заявленного флоу «нашёл → лайкнул → вступил в чат», сейчас отсутствует.
+ Device-local, как профиль.
+- **Лента: прятать прошедшие, сортировать по ближайшим.** Сейчас сортировка по `created_at`,
+ прошедшие висят. Нужен фильтр `starts_at >= now`.
+- **«Мест осталось» из `capacity`** + состояние «мест нет». Поле уже есть, не используется.
+- **Статусы отклика: убрать «не могу», свести к «иду».** «Не могу» мёртв для открытой ленты.
+ MVP — бинарно: отклик = один тап, статистика = одно число. (Тоггл «может» — см. P2.)
+
+---
+
+## P1 — заявлено в MVP, дороже; через интеграцию дешевле
+
+- **Чат на ивент — через внешнюю ссылку.** По принципу: организатор кладёт ссылку на
+ внешний чат (Telegram-группа/канал), участники переходят. Дёшево и доступно сразу.
+ Встроенный чат — только если внешнего окажется мало. Это же закрывает удаление поля
+ `comment` из формы отклика: канал общения есть с первого дня, записка организатору —
+ работа чата, второй messaging-surface не плодим.
+- **Карта — встроенная, на технологии 2gis (MapGL).** Решено: подключаем интерактивную
+ карту сразу, а не отдаём место ивента ссылкой во внешнюю карту. Берём стек 2gis —
+ привычный для нашего региона (узнаваемые тайлы, POI, адресация). Место ивента =
+ координаты + пин на карте внутри приложения; лента событий ложится на карту (часть
+ заявленного MVP-функционала). Осознанное отступление от «интегрируемся, а не заменяем»:
+ карта — одна из двух осей продукта (лента + карта), ради UX держим её внутри.
+ При реализации сверить актуальный SDK/MapGL и его совместимость с Expo v56 через
+ context7 (web — MapGL JS, нативно — 2GIS Native SDK).
+- **Категории/теги на активности + фильтр.** Сейчас у активности нет категории, а профиль
+ (интересы) на неё ссылается. **Фундамент** для фильтра ленты, матча с интересами профиля
+ и будущих рекомендаций. Без поля категории интересы профиля декоративны. Если планируем
+ discovery — добавить раньше остального P1.
+- **Базовая модерация.** Кнопка «пожаловаться» (дёшево) + rate-limit / IP-лимиты на создание
+ (сервер). Прямо в MVP-scope доки.
+
+---
+
+## P2 — рост и удержание
+
+- **Пуш-напоминания** о начале ивента (за N часов) — сильный retention-крючок.
+- **Собственный встроенный чат ивента** — эволюция внешней ссылки на чат (см. P1).
+ Свой messaging-surface (realtime, история, привязка к участникам) вместо ухода в
+ Telegram. Делать, только когда внешнего чата окажется мало: дорого (realtime-бэкенд,
+ модерация, роли), поэтому не раньше стабильной аудитории. Внешний `chat_url` тогда
+ остаётся опциональной альтернативой, не выпиливается. План внешнего чата — `.orch/plan_chat.md`.
+- **Вход и регистрация (опционально).** Настоящая авторизация (Supabase Auth, таблица
+ пользователей, владение активностями). **Жёсткое требование:** анонимный путь сохраняется —
+ пользователь может отправить ссылку незарегистрированному другу, и тот откликается просто
+ по форме, как сейчас. Регистрация не должна становиться барьером отклика. Для авторизованных:
+ имя/телеграм в отклик подтягиваются из профиля → форма почти в один тап.
+- **Тоггл «только для авторизованных» при создании ивента** — fast-follow к авторизации
+ (выше), не раньше: без аккаунтов «авторизованный пользователь» не существует, тоггл —
+ no-op. Per-ивент opt-in выбор организатора (закрытая встреча, member-only комьюнити,
+ отсев рандомов/спама), **не** платформенный отказ от анонимной формы. **Жёсткое правило:
+ дефолт — ВЫКЛ**, иначе ломает снижение трения и виральное распространение (анонимный
+ отклик по ссылке — ядро роста). Дёшево по паттерну `chat_url`: поле активности
+ (`require_auth bool`) + одна ветка на `app/a/[slug].tsx` (форма vs «войдите / откройте
+ приложение»). Триггер на реализацию — спрос на приватные/member-only ивенты или спам.
+- **Тоггл «может» при создании ивента** — fast-follow, когда подтвердится спрос
+ capacity-strict организаторов. Стоит не сам тоггл, а полная ветка `maybe` сквозь стек
+ (схема, маппер, колонка БД, переменный UI, статистика, capacity-логика). Если строим:
+ **дефолт — ВКЛ**, opt-out для строгих. Правило: capacity мерит только «иду», «может» =
+ информационный «+N возможно».
+
+---
+
+## P3 — позже, вне раннего MVP
+
+- **Trending / рекомендации в ленте** — док явно выносит за MVP, не трогать рано.
+- **Монетизация** — после 3000–5000 MAU: реклама, подписка организатора, featured placements.
+- **Продвинутая модерация** — верификация (телефон), доверительные уровни, блок по разбору
+ жалоб. Актуально только при стабильной аудитории.
diff --git a/package-lock.json b/package-lock.json
index bddbfa1..4d2dc07 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,9 @@
"expo": "~56.0.8",
"expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4",
+ "expo-image-picker": "~56.0.18",
"expo-linking": "~56.0.13",
+ "expo-notifications": "~56.0.20",
"expo-router": "~56.2.8",
"expo-status-bar": "~56.0.4",
"nativewind": "^4.2.4",
@@ -1508,9 +1510,9 @@
}
},
"node_modules/@expo/env": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz",
- "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==",
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.1.tgz",
+ "integrity": "sha512-JvBdZa5OkTd+dGEtt3fLW9OF6RlIp9SNu9VyMSiWPV5szMDTSAYbX8Uj3cRvZcU1zzaRbqnTSsHk2AE8WXHfxQ==",
"license": "MIT",
"dependencies": {
"chalk": "^4.0.0",
@@ -1550,12 +1552,12 @@
}
},
"node_modules/@expo/image-utils": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz",
- "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==",
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.2.tgz",
+ "integrity": "sha512-qQUGaacqXduoFTCUQAMceIWYzlKU0xX4/BKTyh8TdVj0uHv9/W3MfHOy5yXoOqBVrSccXk++Gm4D/NfS5HnCNA==",
"license": "MIT",
"dependencies": {
- "@expo/require-utils": "^56.1.3",
+ "@expo/require-utils": "^56.1.4",
"@expo/spawn-async": "^1.8.0",
"chalk": "^4.0.0",
"getenv": "^2.0.0",
@@ -1727,9 +1729,9 @@
}
},
"node_modules/@expo/require-utils": {
- "version": "56.1.3",
- "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz",
- "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==",
+ "version": "56.1.4",
+ "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.4.tgz",
+ "integrity": "sha512-IX7XXg9obnrH3ni0TClBbVKgqk0nT0bjTEPTBtbD+WgIeZ0hpcgwxMJH3j5uBsUqEAq1jqUAkJWrPcg7ZRSV7g==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
@@ -3973,6 +3975,12 @@
}
}
},
+ "node_modules/badgin": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
+ "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -4944,13 +4952,22 @@
}
}
},
+ "node_modules/expo-application": {
+ "version": "56.0.3",
+ "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz",
+ "integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-constants": {
- "version": "56.0.16",
- "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.16.tgz",
- "integrity": "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA==",
+ "version": "56.0.20",
+ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.20.tgz",
+ "integrity": "sha512-4HgoVvUiMvcqujr/CUj7L1tumLWj8WX0PLM77OriDPoaJrMEwUHd841m4o4IoUyMPVT8XTiTiPFwJtGali8+9Q==",
"license": "MIT",
"dependencies": {
- "@expo/env": "~2.3.0"
+ "@expo/env": "~2.3.1"
},
"peerDependencies": {
"expo": "*",
@@ -4991,6 +5008,27 @@
"react-native": "*"
}
},
+ "node_modules/expo-image-loader": {
+ "version": "56.0.3",
+ "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz",
+ "integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-image-picker": {
+ "version": "56.0.18",
+ "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.18.tgz",
+ "integrity": "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-image-loader": "~56.0.3"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-linking": {
"version": "56.0.13",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.13.tgz",
@@ -5050,6 +5088,24 @@
"react-native": "*"
}
},
+ "node_modules/expo-notifications": {
+ "version": "56.0.20",
+ "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.20.tgz",
+ "integrity": "sha512-+LsF3O51IDoIk68xxp9q9sSwNDlZWWsykWouQ+jCfXZOa0mi86elRBqWV4IXN4YOTGbx9zYvnIzkga0F0Qar/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@expo/image-utils": "^0.10.2",
+ "abort-controller": "^3.0.0",
+ "badgin": "^1.1.5",
+ "expo-application": "~56.0.3",
+ "expo-constants": "~56.0.20"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-router": {
"version": "56.2.8",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.8.tgz",
diff --git a/package.json b/package.json
index fac3127..1645cbd 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,9 @@
"expo": "~56.0.8",
"expo-constants": "~56.0.16",
"expo-crypto": "~56.0.4",
+ "expo-image-picker": "~56.0.18",
"expo-linking": "~56.0.13",
+ "expo-notifications": "~56.0.20",
"expo-router": "~56.2.8",
"expo-status-bar": "~56.0.4",
"nativewind": "^4.2.4",
diff --git a/src/entities/activity/api.ts b/src/entities/activity/api.ts
index e98a332..54a8638 100644
--- a/src/entities/activity/api.ts
+++ b/src/entities/activity/api.ts
@@ -5,12 +5,24 @@ import type {
UpdateActivityInput,
} from "./types";
-export async function listActivities() {
- const { data, error } = await requireSupabase()
+type Params = {
+ city?: string;
+ query?: string;
+};
+
+export async function listActivities(params: Params = {}) {
+ const { city, query } = params;
+ const nowIso = new Date().toISOString();
+ let request = requireSupabase()
.from("activities")
.select("*")
.eq("status", "active")
- .order("created_at", { ascending: false })
+ .gte("starts_at", nowIso);
+ if (city) request = request.eq("city", city);
+ if (query) request = request.ilike("title", `%${query}%`);
+
+ const { data, error } = await request
+ .order("starts_at", { ascending: true })
.limit(50);
if (error) throw error;
return (data ?? []) as Activity[];
diff --git a/src/entities/activity/hooks.ts b/src/entities/activity/hooks.ts
index b9475e1..e99ecb1 100644
--- a/src/entities/activity/hooks.ts
+++ b/src/entities/activity/hooks.ts
@@ -6,16 +6,27 @@ import {
listActivities,
updateActivity,
} from "./api";
+import { addMyActivity, readMyActivities } from "./local";
import type { UpdateActivityInput } from "./types";
+type ActivitiesFilter = {
+ city?: string;
+ query?: string;
+};
+
export const activityKeys = {
all: ["activities"] as const,
+ list: (filter: ActivitiesFilter) => ["activities", "list", filter] as const,
+ mine: ["activities", "mine"] as const,
slug: (slug: string) => ["activities", "slug", slug] as const,
edit: (editToken: string) => ["activities", "edit", editToken] as const,
};
-export function useActivities() {
- return useQuery({ queryKey: activityKeys.all, queryFn: listActivities });
+export function useActivities(filter: ActivitiesFilter = {}) {
+ return useQuery({
+ queryKey: activityKeys.list(filter),
+ queryFn: () => listActivities(filter),
+ });
}
export function useActivityBySlug(slug?: string) {
return useQuery({
@@ -31,6 +42,9 @@ export function useActivityByEditToken(editToken?: string) {
enabled: Boolean(editToken),
});
}
+export function useMyActivities() {
+ return useQuery({ queryKey: activityKeys.mine, queryFn: readMyActivities });
+}
export function useCreateActivity() {
const queryClient = useQueryClient();
return useMutation({
@@ -39,6 +53,14 @@ export function useCreateActivity() {
queryClient.invalidateQueries({ queryKey: activityKeys.all }),
});
}
+export function useTrackMyActivity() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: addMyActivity,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: activityKeys.mine }),
+ });
+}
export function useUpdateActivity() {
const queryClient = useQueryClient();
return useMutation({
diff --git a/src/entities/activity/local.test.ts b/src/entities/activity/local.test.ts
new file mode 100644
index 0000000..2953d7f
--- /dev/null
+++ b/src/entities/activity/local.test.ts
@@ -0,0 +1,72 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// Хранилище устройства подменяем in-memory: тестируем чистую логику тоггла.
+const store = new Map();
+
+vi.mock("../../shared/lib/storage", () => ({
+ readDeviceJson: async (key: string) => store.get(key) ?? null,
+ writeDeviceJson: async (key: string, value: unknown) => {
+ store.set(key, value);
+ },
+ updateDeviceJson: async (
+ key: string,
+ update: (current: unknown) => unknown,
+ ) => {
+ const next = update(store.get(key) ?? null);
+ store.set(key, next);
+ return next;
+ },
+}));
+
+import {
+ addMyActivity,
+ readLikedSlugs,
+ readMyActivities,
+ toggleLike,
+} from "./local";
+import type { Activity } from "./types";
+
+const activity: Activity = {
+ id: "1",
+ slug: "party",
+ title: "Вечеринка",
+ description: null,
+ city: "Алматы",
+ location_text: null,
+ starts_at: "2026-07-01T10:00:00Z",
+ capacity: null,
+ cover_url: null,
+ chat_url: null,
+ edit_token: "token",
+ status: "active",
+ created_at: "",
+ updated_at: "",
+};
+
+describe("activity/local лайки", () => {
+ beforeEach(() => store.clear());
+
+ it("добавляет и убирает slug при повторном тоггле", async () => {
+ expect(await toggleLike("party")).toEqual(["party"]);
+ expect(await toggleLike("party")).toEqual([]);
+ expect(await readLikedSlugs()).toEqual([]);
+ });
+
+ it("тоггл идемпотентен: один slug не дублируется", async () => {
+ await toggleLike("run");
+ await toggleLike("yoga");
+
+ expect(await readLikedSlugs()).toEqual(["yoga", "run"]);
+ });
+});
+
+describe("activity/local мои ивенты", () => {
+ beforeEach(() => store.clear());
+
+ it("повторное добавление того же slug не дублирует запись", async () => {
+ await addMyActivity(activity);
+ await addMyActivity(activity);
+
+ expect(await readMyActivities()).toHaveLength(1);
+ });
+});
diff --git a/src/entities/activity/local.ts b/src/entities/activity/local.ts
new file mode 100644
index 0000000..c2aaafd
--- /dev/null
+++ b/src/entities/activity/local.ts
@@ -0,0 +1,55 @@
+import { readDeviceJson, updateDeviceJson } from "../../shared/lib/storage";
+import type { Activity } from "./types";
+
+// Локальный список созданных на устройстве активностей — данные для экрана
+// «Мои ивенты». Серверного владения нет, это device-local проекция активности.
+export type MyActivity = {
+ slug: string;
+ editToken: string;
+ title: string;
+ city: string;
+ startsAt: string;
+};
+
+const myActivitiesKey = "venty.my-activities";
+
+export async function readMyActivities(): Promise {
+ const list = await readDeviceJson(myActivitiesKey);
+ return list ?? [];
+}
+
+export async function addMyActivity(activity: Activity) {
+ return updateDeviceJson(myActivitiesKey, (current) => {
+ const list = current ?? [];
+ if (list.some((item) => item.slug === activity.slug)) return list;
+ return [toMyActivity(activity), ...list];
+ });
+}
+
+function toMyActivity(activity: Activity): MyActivity {
+ return {
+ slug: activity.slug,
+ editToken: activity.edit_token,
+ title: activity.title,
+ city: activity.city,
+ startsAt: activity.starts_at,
+ };
+}
+
+// Локальный список лайкнутых активностей по slug. Счётчик лайков на MVP не
+// показываем (нет серверной агрегации) — храним только состояние «лайкнуто/нет».
+const likedSlugsKey = "venty.liked-slugs";
+
+export async function readLikedSlugs(): Promise {
+ const list = await readDeviceJson(likedSlugsKey);
+ return list ?? [];
+}
+
+export async function toggleLike(slug: string): Promise {
+ return updateDeviceJson(likedSlugsKey, (current) => {
+ const list = current ?? [];
+ return list.includes(slug)
+ ? list.filter((item) => item !== slug)
+ : [slug, ...list];
+ });
+}
diff --git a/src/entities/activity/mappers.test.ts b/src/entities/activity/mappers.test.ts
index 9c65e0c..5e7a6f2 100644
--- a/src/entities/activity/mappers.test.ts
+++ b/src/entities/activity/mappers.test.ts
@@ -5,9 +5,11 @@ vi.mock("expo-crypto", () => ({
}));
import {
+ mapActivityToUpdateForm,
mapCreateActivityFormToInput,
mapUpdateActivityFormToInput,
} from "./mappers";
+import type { Activity } from "./types";
describe("activity/mappers", () => {
it("преобразует форму создания в payload для API", () => {
@@ -19,6 +21,7 @@ describe("activity/mappers", () => {
starts_at: "2026-05-30T12:30",
capacity: null,
cover_url: null,
+ chat_url: null,
});
expect(input).toMatchObject({
@@ -28,6 +31,7 @@ describe("activity/mappers", () => {
location_text: null,
capacity: null,
cover_url: null,
+ chat_url: null,
status: "active",
});
expect(input.starts_at).toBe(new Date("2026-05-30T12:30").toISOString());
@@ -44,12 +48,14 @@ describe("activity/mappers", () => {
starts_at: "2026-05-30T12:30",
capacity: 10,
cover_url: "https://example.com/image.jpg",
+ chat_url: "https://t.me/club",
});
expect(input.description).toBe("Описание");
expect(input.location_text).toBe("Парк");
expect(input.capacity).toBe(10);
expect(input.cover_url).toBe("https://example.com/image.jpg");
+ expect(input.chat_url).toBe("https://t.me/club");
});
it("преобразует форму обновления в payload для API", () => {
@@ -61,6 +67,7 @@ describe("activity/mappers", () => {
starts_at: "2026-05-30T12:30",
capacity: null,
cover_url: null,
+ chat_url: null,
status: "cancelled",
});
@@ -72,7 +79,42 @@ describe("activity/mappers", () => {
starts_at: new Date("2026-05-30T12:30").toISOString(),
capacity: null,
cover_url: null,
+ chat_url: null,
status: "cancelled",
});
});
+
+ it("преобразует null chat_url в пустую строку для формы управления", () => {
+ const form = mapActivityToUpdateForm(buildActivity({ chat_url: null }));
+
+ expect(form.chat_url).toBe("");
+ });
+
+ it("сохраняет заполненный chat_url в форме управления", () => {
+ const form = mapActivityToUpdateForm(
+ buildActivity({ chat_url: "https://t.me/club" }),
+ );
+
+ expect(form.chat_url).toBe("https://t.me/club");
+ });
});
+
+function buildActivity(overrides: Partial): Activity {
+ return {
+ id: "1",
+ slug: "activity",
+ title: "Activity",
+ description: null,
+ city: "Москва",
+ location_text: null,
+ starts_at: "2026-05-30T12:30:00.000Z",
+ capacity: null,
+ cover_url: null,
+ chat_url: null,
+ edit_token: "token",
+ status: "active",
+ created_at: "2026-05-01T00:00:00.000Z",
+ updated_at: "2026-05-01T00:00:00.000Z",
+ ...overrides,
+ };
+}
diff --git a/src/entities/activity/mappers.ts b/src/entities/activity/mappers.ts
index d51bffa..4df73b8 100644
--- a/src/entities/activity/mappers.ts
+++ b/src/entities/activity/mappers.ts
@@ -1,3 +1,8 @@
+import {
+ toDateTimeLocalInputValue,
+ toIsoDate,
+} from "../../shared/lib/datetime";
+import { generateEditToken, generateSlug } from "../../shared/lib/publicLink";
import type {
CreateActivityForm,
UpdateActivityForm,
@@ -8,12 +13,6 @@ import type {
CreateActivityInput,
UpdateActivityInput,
} from "./types";
-import {
- generateActivitySlug,
- generateEditToken,
- toDateTimeLocalInputValue,
- toIsoDate,
-} from "./utils";
export function mapCreateActivityFormToInput(
form: CreateActivityForm,
@@ -21,7 +20,7 @@ export function mapCreateActivityFormToInput(
return {
...form,
starts_at: toIsoDate(form.starts_at),
- slug: generateActivitySlug(form.title),
+ slug: generateSlug(form.title),
edit_token: generateEditToken(),
status: "active",
};
@@ -47,6 +46,7 @@ export function mapActivityToUpdateForm(
starts_at: toDateTimeLocalInputValue(activity.starts_at),
capacity: activity.capacity,
cover_url: activity.cover_url ?? "",
+ chat_url: activity.chat_url ?? "",
status: activity.status,
};
}
diff --git a/src/entities/activity/schemas.test.ts b/src/entities/activity/schemas.test.ts
new file mode 100644
index 0000000..0faac8e
--- /dev/null
+++ b/src/entities/activity/schemas.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("expo-crypto", () => ({
+ randomUUID: () => "123e4567-e89b-12d3-a456-426614174000",
+}));
+
+import { createActivitySchema, updateActivitySchema } from "./schemas";
+
+const validForm = {
+ title: "Пробежка",
+ description: "",
+ city: "Алматы",
+ location_text: "",
+ starts_at: "2026-07-01T10:00",
+ capacity: "",
+ cover_url: "",
+ chat_url: "",
+};
+
+describe("activity/schemas", () => {
+ it("чистит пустые необязательные поля в null", () => {
+ const result = createActivitySchema.parse(validForm);
+
+ expect(result.description).toBeNull();
+ expect(result.location_text).toBeNull();
+ expect(result.capacity).toBeNull();
+ expect(result.cover_url).toBeNull();
+ expect(result.chat_url).toBeNull();
+ });
+
+ it("приводит capacity к положительному числу", () => {
+ const result = createActivitySchema.parse({ ...validForm, capacity: "12" });
+
+ expect(result.capacity).toBe(12);
+ });
+
+ it("требует название, город и корректную дату", () => {
+ expect(
+ createActivitySchema.safeParse({ ...validForm, title: " " }).success,
+ ).toBe(false);
+ expect(
+ createActivitySchema.safeParse({ ...validForm, city: "" }).success,
+ ).toBe(false);
+ const badDate = { ...validForm, starts_at: "12 июля" };
+ expect(createActivitySchema.safeParse(badDate).success).toBe(false);
+ });
+
+ it("отклоняет некорректную ссылку на обложку", () => {
+ const bad = { ...validForm, cover_url: "не-ссылка" };
+
+ expect(createActivitySchema.safeParse(bad).success).toBe(false);
+ });
+
+ it("отклоняет некорректную ссылку на чат", () => {
+ const bad = { ...validForm, chat_url: "не-ссылка" };
+
+ expect(createActivitySchema.safeParse(bad).success).toBe(false);
+ });
+
+ it("отклоняет ссылку на чат вне списка мессенджеров", () => {
+ const bad = { ...validForm, chat_url: "https://example.com/chat" };
+
+ expect(createActivitySchema.safeParse(bad).success).toBe(false);
+ });
+
+ it("принимает ссылку на известный мессенджер", () => {
+ const ok = { ...validForm, chat_url: "https://t.me/club" };
+
+ expect(createActivitySchema.safeParse(ok).success).toBe(true);
+ });
+
+ it("в форме обновления добавляет статус", () => {
+ const ok = updateActivitySchema.safeParse({
+ ...validForm,
+ status: "cancelled",
+ });
+ const bad = updateActivitySchema.safeParse({
+ ...validForm,
+ status: "deleted",
+ });
+
+ expect(ok.success).toBe(true);
+ expect(bad.success).toBe(false);
+ });
+});
diff --git a/src/entities/activity/schemas.ts b/src/entities/activity/schemas.ts
index 45fb291..64691ac 100644
--- a/src/entities/activity/schemas.ts
+++ b/src/entities/activity/schemas.ts
@@ -1,16 +1,11 @@
import { z } from "zod";
-import { isValidActivityDateTime } from "./utils";
-
-const emptyToNull = (v: unknown) => (v === "" || v === undefined ? null : v);
-const optionalText = z.preprocess(emptyToNull, z.string().nullable());
-const capacity = z.preprocess(
- emptyToNull,
- z.coerce.number().int().positive().nullable(),
-);
-const coverUrl = z.preprocess(
- emptyToNull,
- z.string().url("Введите корректную ссылку").nullable(),
-);
+import { isValidDateTime } from "../../shared/lib/datetime";
+import {
+ capacity,
+ chatUrl,
+ optionalText,
+ optionalUrl,
+} from "../../shared/lib/zodFields";
export const createActivitySchema = z.object({
title: z.string().trim().min(1, "Название обязательно"),
@@ -20,9 +15,10 @@ export const createActivitySchema = z.object({
starts_at: z
.string()
.min(1, "Дата обязательна")
- .refine(isValidActivityDateTime, "Введите корректные дату и время"),
+ .refine(isValidDateTime, "Введите корректные дату и время"),
capacity,
- cover_url: coverUrl,
+ cover_url: optionalUrl,
+ chat_url: chatUrl,
});
export const updateActivitySchema = createActivitySchema.extend({
diff --git a/src/entities/activity/types.ts b/src/entities/activity/types.ts
index 4db8b5e..dd9b7c5 100644
--- a/src/entities/activity/types.ts
+++ b/src/entities/activity/types.ts
@@ -10,6 +10,7 @@ export type Activity = {
starts_at: string;
capacity: number | null;
cover_url: string | null;
+ chat_url: string | null;
edit_token: string;
status: ActivityStatus;
created_at: string;
@@ -25,6 +26,7 @@ export type CreateActivityInput = {
starts_at: string;
capacity?: number | null;
cover_url?: string | null;
+ chat_url?: string | null;
edit_token: string;
status: ActivityStatus;
};
diff --git a/src/entities/activity/utils.test.ts b/src/entities/activity/utils.test.ts
index 67c646e..b3aa53f 100644
--- a/src/entities/activity/utils.test.ts
+++ b/src/entities/activity/utils.test.ts
@@ -1,63 +1,35 @@
-import { describe, expect, it, vi } from "vitest";
-
-vi.mock("expo-crypto", () => ({
- randomUUID: () => "123e4567-e89b-12d3-a456-426614174000",
-}));
-
+import { describe, expect, it } from "vitest";
import {
- generateActivitySlug,
- generateEditToken,
+ getCapacityState,
getNextActivityStatus,
- isValidActivityDateTime,
- slugifyTitle,
- toDateTimeLocalInputValue,
- toIsoDate,
+ telegramToChatUrl,
} from "./utils";
describe("activity/utils", () => {
- it("преобразует название в slug", () => {
- expect(slugifyTitle(" My Cool Activity! ")).toBe("my-cool-activity");
- expect(slugifyTitle(" Кириллица 123! ")).toBe("кириллица-123");
- expect(slugifyTitle("!!!")).toBe("activity");
- });
-
- it("генерирует slug с коротким случайным суффиксом", () => {
- const slug = generateActivitySlug("My Activity");
-
- expect(slug).toMatch(/^my-activity-[a-z0-9]{6}$/);
- });
-
- it("генерирует edit token из UUID без дефисов", () => {
- expect(generateEditToken()).toMatch(/^[a-f0-9]{32}$/);
- });
-
- it("проверяет корректность даты", () => {
- expect(isValidActivityDateTime("2026-05-30T12:30")).toBe(true);
- expect(isValidActivityDateTime("12 мая")).toBe(false);
- expect(isValidActivityDateTime("")).toBe(false);
- });
-
- it("преобразует дату в ISO, если дата корректна", () => {
- expect(toIsoDate("2026-05-30T12:30")).toBe(
- new Date("2026-05-30T12:30").toISOString(),
- );
- expect(toIsoDate("12 мая")).toBe("12 мая");
- });
-
- it("преобразует дату с бэка в значение для datetime-local", () => {
- const value = "2006-02-01T06:22:00+00:00";
- const date = new Date(value);
- const pad = (part: number) => String(part).padStart(2, "0");
- const expected = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
- date.getDate(),
- )}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
-
- expect(toDateTimeLocalInputValue(value)).toBe(expected);
- expect(toDateTimeLocalInputValue("не дата")).toBe("не дата");
+ it("нормализует Telegram-хэндл в t.me ссылку", () => {
+ expect(telegramToChatUrl("@anna")).toBe("https://t.me/anna");
+ expect(telegramToChatUrl("anna")).toBe("https://t.me/anna");
+ expect(telegramToChatUrl("t.me/anna")).toBe("https://t.me/anna");
+ expect(telegramToChatUrl("https://t.me/anna")).toBe("https://t.me/anna");
+ expect(telegramToChatUrl("")).toBe("");
+ expect(telegramToChatUrl(null)).toBe("");
});
it("возвращает следующий статус активности", () => {
expect(getNextActivityStatus("active")).toBe("cancelled");
expect(getNextActivityStatus("cancelled")).toBe("active");
});
+
+ it("считает остаток мест из capacity", () => {
+ expect(getCapacityState({ capacity: null, going: 3 })).toEqual({
+ kind: "unlimited",
+ });
+ expect(getCapacityState({ capacity: 10, going: 4 })).toEqual({
+ kind: "available",
+ left: 6,
+ });
+ expect(getCapacityState({ capacity: 5, going: 5 })).toEqual({
+ kind: "full",
+ });
+ });
});
diff --git a/src/entities/activity/utils.ts b/src/entities/activity/utils.ts
index 063d794..11d97f1 100644
--- a/src/entities/activity/utils.ts
+++ b/src/entities/activity/utils.ts
@@ -1,44 +1,5 @@
-import * as Crypto from "expo-crypto";
import type { ActivityStatus } from "./types";
-const nonAlphanumericRun = /[^\p{L}\p{N}]+/gu;
-const leadingTrailingHyphens = /^-+|-+$/g;
-
-export function slugifyTitle(title: string) {
- return (
- title
- .toLowerCase()
- .trim()
- .replace(nonAlphanumericRun, "-")
- .replace(leadingTrailingHyphens, "") || "activity"
- );
-}
-
-export function generateActivitySlug(title: string) {
- return `${slugifyTitle(title)}-${shortRandom()}`;
-}
-
-export function generateEditToken() {
- return Crypto.randomUUID().replaceAll("-", "");
-}
-
-export function isValidActivityDateTime(value: string) {
- return parseDate(value) != null;
-}
-
-export function toIsoDate(value: string) {
- return parseDate(value)?.toISOString() ?? value;
-}
-
-export function toDateTimeLocalInputValue(value: string) {
- const date = parseDate(value);
- if (!date) return value;
-
- return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(
- date.getDate(),
- )}T${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`;
-}
-
export const activityStatusLabels: Record = {
active: "активна",
cancelled: "отменена",
@@ -52,15 +13,34 @@ export function getNextActivityStatus(status: ActivityStatus): ActivityStatus {
return status === "active" ? "cancelled" : "active";
}
-function parseDate(value: string) {
- const date = new Date(value.trim());
- return Number.isNaN(date.getTime()) ? null : date;
-}
+type CapacityState =
+ | { kind: "unlimited" }
+ | { kind: "available"; left: number }
+ | { kind: "full" };
-function shortRandom() {
- return Math.random().toString(36).slice(2, 8);
-}
+type CapacityParams = {
+ capacity: number | null;
+ going: number;
+};
-function padDatePart(value: number) {
- return String(value).padStart(2, "0");
+export function getCapacityState({
+ capacity,
+ going,
+}: CapacityParams): CapacityState {
+ if (capacity == null) return { kind: "unlimited" };
+
+ const left = capacity - going;
+ return left > 0 ? { kind: "available", left } : { kind: "full" };
+}
+
+// Telegram в профиле хранится как `@username` / `username` / ссылка, а chat_url
+// требует полный URL — нормализуем хэндл в t.me-ссылку для префилла формы.
+export function telegramToChatUrl(value: string | null | undefined) {
+ const handle = value?.trim();
+ if (!handle) return "";
+ if (/^https?:\/\//i.test(handle)) return handle;
+ if (/^(t\.me|telegram\.me|telegram\.dog)\//i.test(handle)) {
+ return `https://${handle}`;
+ }
+ return `https://t.me/${handle.replace(/^@/, "")}`;
}
diff --git a/src/entities/participant/hooks.ts b/src/entities/participant/hooks.ts
index a4b1990..9fc4433 100644
--- a/src/entities/participant/hooks.ts
+++ b/src/entities/participant/hooks.ts
@@ -1,7 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createParticipant, listParticipants } from "./api";
+import { addMyResponse, readMyResponses } from "./local";
export const participantKeys = {
+ mine: ["participants", "mine"] as const,
list: (activityId: string) => ["participants", activityId] as const,
};
@@ -13,6 +15,19 @@ export function useParticipants(activityId?: string) {
});
}
+export function useMyResponses() {
+ return useQuery({ queryKey: participantKeys.mine, queryFn: readMyResponses });
+}
+
+export function useTrackMyResponse() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: addMyResponse,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: participantKeys.mine }),
+ });
+}
+
export function useCreateParticipant() {
const queryClient = useQueryClient();
return useMutation({
diff --git a/src/entities/participant/local.test.ts b/src/entities/participant/local.test.ts
new file mode 100644
index 0000000..a8476bf
--- /dev/null
+++ b/src/entities/participant/local.test.ts
@@ -0,0 +1,32 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// Хранилище устройства подменяем in-memory: тестируем чистую логику дедупа.
+const store = new Map();
+
+vi.mock("../../shared/lib/storage", () => ({
+ readDeviceJson: async (key: string) => store.get(key) ?? null,
+ writeDeviceJson: async (key: string, value: unknown) => {
+ store.set(key, value);
+ },
+}));
+
+import { addMyResponse, type MyResponse, readMyResponses } from "./local";
+
+const response: MyResponse = {
+ slug: "party",
+ title: "Вечеринка",
+ city: "Алматы",
+ startsAt: "2026-07-01T10:00:00Z",
+ status: "going",
+};
+
+describe("participant/local мои отклики", () => {
+ beforeEach(() => store.clear());
+
+ it("повторный отклик на тот же slug не дублирует запись", async () => {
+ await addMyResponse(response);
+ await addMyResponse(response);
+
+ expect(await readMyResponses()).toHaveLength(1);
+ });
+});
diff --git a/src/entities/participant/local.ts b/src/entities/participant/local.ts
new file mode 100644
index 0000000..c3b6214
--- /dev/null
+++ b/src/entities/participant/local.ts
@@ -0,0 +1,27 @@
+import { readDeviceJson, writeDeviceJson } from "../../shared/lib/storage";
+import type { ParticipantStatus } from "./types";
+
+// Локальный список активностей, где устройство откликнулось — данные для счётчика
+// «Посещено» в профиле. Серверного аккаунта нет, это device-local проекция отклика.
+export type MyResponse = {
+ slug: string;
+ title: string;
+ city: string;
+ startsAt: string;
+ status: ParticipantStatus;
+};
+
+const myResponsesKey = "venty.my-responses";
+
+export async function readMyResponses(): Promise {
+ const list = await readDeviceJson(myResponsesKey);
+ return list ?? [];
+}
+
+export async function addMyResponse(response: MyResponse) {
+ const list = await readMyResponses();
+ if (list.some((item) => item.slug === response.slug)) return list;
+ const next = [response, ...list];
+ await writeDeviceJson(myResponsesKey, next);
+ return next;
+}
diff --git a/src/entities/participant/mappers.test.ts b/src/entities/participant/mappers.test.ts
index 972e4b7..c032f57 100644
--- a/src/entities/participant/mappers.test.ts
+++ b/src/entities/participant/mappers.test.ts
@@ -8,14 +8,12 @@ describe("participant/mappers", () => {
name: "Анна",
telegram: "",
status: "going",
- comment: "",
}),
).toEqual({
activity_id: "activity-1",
name: "Анна",
telegram: null,
status: "going",
- comment: null,
});
});
@@ -24,15 +22,13 @@ describe("participant/mappers", () => {
mapParticipantFormToInput("activity-1", {
name: "Анна",
telegram: "@anna",
- status: "maybe",
- comment: "Буду позже",
+ status: "going",
}),
).toEqual({
activity_id: "activity-1",
name: "Анна",
telegram: "@anna",
- status: "maybe",
- comment: "Буду позже",
+ status: "going",
});
});
});
diff --git a/src/entities/participant/mappers.ts b/src/entities/participant/mappers.ts
index 561311a..216c3b2 100644
--- a/src/entities/participant/mappers.ts
+++ b/src/entities/participant/mappers.ts
@@ -9,6 +9,5 @@ export function mapParticipantFormToInput(
activity_id: activityId,
...form,
telegram: form.telegram || null,
- comment: form.comment || null,
};
}
diff --git a/src/entities/participant/schemas.test.ts b/src/entities/participant/schemas.test.ts
new file mode 100644
index 0000000..c2461de
--- /dev/null
+++ b/src/entities/participant/schemas.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { participantSchema } from "./schemas";
+
+describe("participant/schemas", () => {
+ it("принимает отклик с обязательным именем и статусом «иду»", () => {
+ const result = participantSchema.parse({ name: "Анна", status: "going" });
+
+ expect(result.status).toBe("going");
+ expect(result.telegram).toBeUndefined();
+ });
+
+ it("требует непустое имя", () => {
+ const bad = participantSchema.safeParse({ name: " ", status: "going" });
+
+ expect(bad.success).toBe(false);
+ });
+
+ it("разрешает только статус «иду»", () => {
+ const bad = participantSchema.safeParse({ name: "Анна", status: "maybe" });
+
+ expect(bad.success).toBe(false);
+ });
+});
diff --git a/src/entities/participant/schemas.ts b/src/entities/participant/schemas.ts
index d175473..3f4c6da 100644
--- a/src/entities/participant/schemas.ts
+++ b/src/entities/participant/schemas.ts
@@ -3,8 +3,7 @@ import { z } from "zod";
export const participantSchema = z.object({
name: z.string().trim().min(1, "Имя обязательно"),
telegram: z.string().optional(),
- status: z.enum(["going", "maybe", "cant"]),
- comment: z.string().optional(),
+ status: z.literal("going"),
});
export type ParticipantForm = z.infer;
diff --git a/src/entities/participant/types.ts b/src/entities/participant/types.ts
index 6fba2b1..9b9d364 100644
--- a/src/entities/participant/types.ts
+++ b/src/entities/participant/types.ts
@@ -16,5 +16,4 @@ export type CreateParticipantInput = {
name: string;
telegram?: string | null;
status: ParticipantStatus;
- comment?: string | null;
};
diff --git a/src/entities/participant/utils.ts b/src/entities/participant/utils.ts
index 0454f66..7f993c3 100644
--- a/src/entities/participant/utils.ts
+++ b/src/entities/participant/utils.ts
@@ -6,10 +6,6 @@ export const participantStatusLabels: Record = {
cant: "не могу",
};
-export const participantStatusOptions = (
- Object.keys(participantStatusLabels) as ParticipantStatus[]
-).map((value) => ({ value, label: participantStatusLabels[value] }));
-
export function getParticipantStatusText(status: string) {
return isParticipantStatus(status) ? participantStatusLabels[status] : status;
}
diff --git a/src/entities/profile/hooks.ts b/src/entities/profile/hooks.ts
new file mode 100644
index 0000000..214fb67
--- /dev/null
+++ b/src/entities/profile/hooks.ts
@@ -0,0 +1,28 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { readProfile, removeProfile, writeProfile } from "./storage";
+
+export const profileKeys = {
+ all: ["profile"] as const,
+};
+
+export function useProfile() {
+ return useQuery({ queryKey: profileKeys.all, queryFn: readProfile });
+}
+
+export function useSaveProfile() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: writeProfile,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: profileKeys.all }),
+ });
+}
+
+export function useResetProfile() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: removeProfile,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: profileKeys.all }),
+ });
+}
diff --git a/src/entities/profile/mappers.test.ts b/src/entities/profile/mappers.test.ts
new file mode 100644
index 0000000..20c3548
--- /dev/null
+++ b/src/entities/profile/mappers.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { mapProfileToForm } from "./mappers";
+import type { Profile } from "./types";
+
+describe("profile/mappers", () => {
+ it("null в полях профиля превращает в пустую строку для формы", () => {
+ const profile: Profile = {
+ fullName: "Анна",
+ city: null,
+ categories: [],
+ photoUrl: null,
+ socials: {
+ telegram: null,
+ instagram: null,
+ tiktok: null,
+ whatsapp: null,
+ discord: null,
+ website: null,
+ },
+ registeredAt: "2026-01-01T00:00:00.000Z",
+ };
+
+ expect(mapProfileToForm(profile)).toEqual({
+ fullName: "Анна",
+ city: "",
+ categories: [],
+ photoUrl: "",
+ socials: {
+ telegram: "",
+ instagram: "",
+ tiktok: "",
+ whatsapp: "",
+ discord: "",
+ website: "",
+ },
+ });
+ });
+
+ it("сохраняет заполненные поля и не тащит дату регистрации", () => {
+ const form = mapProfileToForm({
+ fullName: "Анна",
+ city: "Москва",
+ categories: ["Спорт"],
+ photoUrl: "https://example.com/p.jpg",
+ socials: {
+ telegram: "@anna",
+ instagram: null,
+ tiktok: null,
+ whatsapp: null,
+ discord: null,
+ website: null,
+ },
+ registeredAt: "2026-01-01T00:00:00.000Z",
+ });
+
+ expect(form.city).toBe("Москва");
+ expect(form.socials.telegram).toBe("@anna");
+ expect(form).not.toHaveProperty("registeredAt");
+ });
+});
diff --git a/src/entities/profile/mappers.ts b/src/entities/profile/mappers.ts
new file mode 100644
index 0000000..b8e0cbb
--- /dev/null
+++ b/src/entities/profile/mappers.ts
@@ -0,0 +1,25 @@
+import type { ProfileFormInput } from "./schemas";
+import type { Profile, ProfileSocials } from "./types";
+
+// Профиль из хранилища → значения формы: `null` в текстовых полях и соц.ссылках
+// превращаем в пустую строку, чтобы поля редактировались как обычный текст.
+export function mapProfileToForm(profile: Profile): ProfileFormInput {
+ return {
+ fullName: profile.fullName,
+ city: profile.city ?? "",
+ categories: profile.categories,
+ photoUrl: profile.photoUrl ?? "",
+ socials: mapSocialsToForm(profile.socials),
+ };
+}
+
+function mapSocialsToForm(socials: ProfileSocials) {
+ return {
+ telegram: socials.telegram ?? "",
+ instagram: socials.instagram ?? "",
+ tiktok: socials.tiktok ?? "",
+ whatsapp: socials.whatsapp ?? "",
+ discord: socials.discord ?? "",
+ website: socials.website ?? "",
+ };
+}
diff --git a/src/entities/profile/schemas.test.ts b/src/entities/profile/schemas.test.ts
new file mode 100644
index 0000000..648d369
--- /dev/null
+++ b/src/entities/profile/schemas.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { profileSchema } from "./schemas";
+
+const emptySocials = {
+ telegram: "",
+ instagram: "",
+ tiktok: "",
+ whatsapp: "",
+ discord: "",
+ website: "",
+};
+
+describe("profile/schemas", () => {
+ it("пустые текстовые поля и соц.ссылки превращает в null", () => {
+ const result = profileSchema.parse({
+ fullName: "Анна",
+ city: "",
+ photoUrl: "",
+ socials: emptySocials,
+ });
+
+ expect(result).toEqual({
+ fullName: "Анна",
+ city: null,
+ categories: [],
+ photoUrl: null,
+ socials: {
+ telegram: null,
+ instagram: null,
+ tiktok: null,
+ whatsapp: null,
+ discord: null,
+ website: null,
+ },
+ });
+ });
+
+ it("сохраняет заполненные поля и интересы", () => {
+ const result = profileSchema.parse({
+ fullName: "Анна",
+ city: "Москва",
+ categories: ["Спорт"],
+ photoUrl: "https://example.com/p.jpg",
+ socials: { ...emptySocials, telegram: "@anna" },
+ });
+
+ expect(result.city).toBe("Москва");
+ expect(result.categories).toEqual(["Спорт"]);
+ expect(result.photoUrl).toBe("https://example.com/p.jpg");
+ expect(result.socials.telegram).toBe("@anna");
+ expect(result.socials.instagram).toBeNull();
+ });
+
+ it("принимает локальный URI фото и отклоняет пустое имя", () => {
+ const base = { fullName: "Анна", city: "", socials: emptySocials };
+
+ expect(
+ profileSchema.safeParse({ ...base, photoUrl: "file:///photo.jpg" })
+ .success,
+ ).toBe(true);
+ expect(
+ profileSchema.safeParse({ ...base, fullName: " ", photoUrl: "" })
+ .success,
+ ).toBe(false);
+ });
+});
diff --git a/src/entities/profile/schemas.ts b/src/entities/profile/schemas.ts
new file mode 100644
index 0000000..48608e2
--- /dev/null
+++ b/src/entities/profile/schemas.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+import { optionalText } from "../../shared/lib/zodFields";
+
+const socialsSchema = z.object({
+ telegram: optionalText,
+ instagram: optionalText,
+ tiktok: optionalText,
+ whatsapp: optionalText,
+ discord: optionalText,
+ website: optionalText,
+});
+
+export const profileSchema = z.object({
+ fullName: z.string().trim().min(1, "Имя обязательно"),
+ city: optionalText,
+ categories: z.array(z.string()).default([]),
+ // Локальный URI выбранного фото (file://, content://, data:), а не web-ссылка —
+ // строгую URL-валидацию не применяем, чтобы не отклонять валидные device-URI.
+ photoUrl: optionalText,
+ socials: socialsSchema,
+});
+
+export type ProfileFormInput = z.input;
+export type ProfileForm = z.output;
diff --git a/src/entities/profile/storage.ts b/src/entities/profile/storage.ts
new file mode 100644
index 0000000..2d6b3ce
--- /dev/null
+++ b/src/entities/profile/storage.ts
@@ -0,0 +1,29 @@
+import {
+ readDeviceJson,
+ removeDeviceValue,
+ writeDeviceJson,
+} from "../../shared/lib/storage";
+import type { Profile, SaveProfileInput } from "./types";
+
+const profileKey = "venty.profile";
+
+export function readProfile() {
+ return readDeviceJson(profileKey);
+}
+
+export async function writeProfile(input: SaveProfileInput): Promise {
+ const registeredAt = await keepRegisteredAt();
+ const profile = { ...input, registeredAt };
+ await writeDeviceJson(profileKey, profile);
+ return profile;
+}
+
+export function removeProfile() {
+ return removeDeviceValue(profileKey);
+}
+
+// Дата регистрации = первое сохранение на устройстве, при правках не меняется.
+async function keepRegisteredAt() {
+ const existing = await readProfile();
+ return existing?.registeredAt ?? new Date().toISOString();
+}
diff --git a/src/entities/profile/types.ts b/src/entities/profile/types.ts
new file mode 100644
index 0000000..c9ea4d0
--- /dev/null
+++ b/src/entities/profile/types.ts
@@ -0,0 +1,22 @@
+export type Profile = {
+ fullName: string;
+ city: string | null;
+ categories: string[];
+ photoUrl: string | null;
+ socials: ProfileSocials;
+ registeredAt: string;
+};
+
+// Что уходит в хранилище при сохранении: профиль без даты регистрации —
+// её проставляет storage при первом сохранении на устройстве.
+export type SaveProfileInput = Omit;
+
+export type ProfileSocials = Record;
+
+export type SocialNetwork =
+ | "telegram"
+ | "instagram"
+ | "tiktok"
+ | "whatsapp"
+ | "discord"
+ | "website";
diff --git a/src/entities/profile/utils.test.ts b/src/entities/profile/utils.test.ts
new file mode 100644
index 0000000..93e2a0f
--- /dev/null
+++ b/src/entities/profile/utils.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import type { ProfileSocials } from "./types";
+import { formatRegisteredAt, getFilledSocials } from "./utils";
+
+describe("profile/utils", () => {
+ it("возвращает только заполненные соц.ссылки в порядке словаря", () => {
+ const socials: ProfileSocials = {
+ website: "https://anna.dev",
+ telegram: "@anna",
+ instagram: null,
+ tiktok: null,
+ whatsapp: null,
+ discord: null,
+ };
+
+ expect(getFilledSocials(socials)).toEqual([
+ { key: "telegram", label: "Telegram", url: "@anna" },
+ { key: "website", label: "Сайт", url: "https://anna.dev" },
+ ]);
+ });
+
+ it("форматирует дату регистрации, мусор отдаёт как есть", () => {
+ expect(formatRegisteredAt("2026-03-05T10:00")).toBe("05.03.2026");
+ expect(formatRegisteredAt("не дата")).toBe("не дата");
+ });
+});
diff --git a/src/entities/profile/utils.ts b/src/entities/profile/utils.ts
new file mode 100644
index 0000000..8b8a1b7
--- /dev/null
+++ b/src/entities/profile/utils.ts
@@ -0,0 +1,61 @@
+import type { ProfileSocials, SocialNetwork } from "./types";
+
+const categoryNames = [
+ "Спорт",
+ "Музыка",
+ "Образование",
+ "Нетворкинг",
+ "Игры",
+ "Искусство",
+ "Еда",
+ "Путешествия",
+ "Технологии",
+ "Волонтёрство",
+];
+
+export const categoryOptions = categoryNames.map((name) => ({
+ value: name,
+ label: name,
+}));
+
+type SocialOption = {
+ key: SocialNetwork;
+ label: string;
+ placeholder: string;
+};
+
+export const socialOptions: SocialOption[] = [
+ { key: "telegram", label: "Telegram", placeholder: "@username или ссылка" },
+ { key: "instagram", label: "Instagram", placeholder: "@username или ссылка" },
+ { key: "tiktok", label: "TikTok", placeholder: "@username или ссылка" },
+ { key: "whatsapp", label: "WhatsApp", placeholder: "номер или ссылка" },
+ {
+ key: "discord",
+ label: "Discord",
+ placeholder: "ник или ссылка-приглашение",
+ },
+ { key: "website", label: "Сайт", placeholder: "https://example.com" },
+];
+
+// Только заполненные соц.ссылки в порядке словаря — для рендера и перехода наружу.
+export function getFilledSocials(socials: ProfileSocials) {
+ const filled: { key: SocialNetwork; label: string; url: string }[] = [];
+ for (const { key, label } of socialOptions) {
+ const url = socials[key];
+ if (url != null) filled.push({ key, label, url });
+ }
+ return filled;
+}
+
+export function formatRegisteredAt(value: string) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+
+ return `${padDatePart(date.getDate())}.${padDatePart(
+ date.getMonth() + 1,
+ )}.${date.getFullYear()}`;
+}
+
+function padDatePart(value: number) {
+ return String(value).padStart(2, "0");
+}
diff --git a/src/entities/reminder/hooks.ts b/src/entities/reminder/hooks.ts
new file mode 100644
index 0000000..0233f18
--- /dev/null
+++ b/src/entities/reminder/hooks.ts
@@ -0,0 +1,22 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { readReminderSettings, writeReminderSettings } from "./settings";
+
+export const reminderKeys = {
+ settings: ["reminder-settings"] as const,
+};
+
+export function useReminderSettings() {
+ return useQuery({
+ queryKey: reminderKeys.settings,
+ queryFn: readReminderSettings,
+ });
+}
+
+export function useSaveReminderSettings() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: writeReminderSettings,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: reminderKeys.settings }),
+ });
+}
diff --git a/src/entities/reminder/local.test.ts b/src/entities/reminder/local.test.ts
new file mode 100644
index 0000000..4cf1eb0
--- /dev/null
+++ b/src/entities/reminder/local.test.ts
@@ -0,0 +1,50 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// Хранилище устройства подменяем in-memory: тестируем чистую логику дедупа.
+const store = new Map();
+
+vi.mock("../../shared/lib/storage", () => ({
+ readDeviceJson: async (key: string) => store.get(key) ?? null,
+ writeDeviceJson: async (key: string, value: unknown) => {
+ store.set(key, value);
+ },
+ updateDeviceJson: async (
+ key: string,
+ update: (current: unknown) => unknown,
+ ) => {
+ const next = update(store.get(key) ?? null);
+ store.set(key, next);
+ return next;
+ },
+}));
+
+import { addReminder, readReminders, removeReminder } from "./local";
+import type { Reminder } from "./types";
+
+const reminder = (over: Partial): Reminder => ({
+ slug: "party",
+ notificationId: "n1",
+ startsAt: "2026-07-01T10:00:00Z",
+ ...over,
+});
+
+describe("reminder/local", () => {
+ beforeEach(() => store.clear());
+
+ it("повторное напоминание того же slug не дублирует, берёт новый id", async () => {
+ await addReminder(reminder({ notificationId: "n1" }));
+ await addReminder(reminder({ notificationId: "n2" }));
+
+ const list = await readReminders();
+ expect(list).toHaveLength(1);
+ expect(list[0].notificationId).toBe("n2");
+ });
+
+ it("удаление по slug убирает запись", async () => {
+ await addReminder(reminder({ slug: "party" }));
+ await addReminder(reminder({ slug: "run" }));
+ await removeReminder("party");
+
+ expect(await readReminders()).toEqual([reminder({ slug: "run" })]);
+ });
+});
diff --git a/src/entities/reminder/local.ts b/src/entities/reminder/local.ts
new file mode 100644
index 0000000..6644b3b
--- /dev/null
+++ b/src/entities/reminder/local.ts
@@ -0,0 +1,25 @@
+import { readDeviceJson, updateDeviceJson } from "../../shared/lib/storage";
+import type { Reminder } from "./types";
+
+// Device-local список запланированных напоминаний: slug → notificationId.
+// Дедуп по slug — одна активность не держит два напоминания.
+const remindersKey = "venty.reminders";
+
+export async function readReminders(): Promise {
+ const list = await readDeviceJson(remindersKey);
+ return list ?? [];
+}
+
+export async function addReminder(reminder: Reminder) {
+ return updateDeviceJson(remindersKey, (current) => {
+ const list = current ?? [];
+ return [reminder, ...list.filter((item) => item.slug !== reminder.slug)];
+ });
+}
+
+export async function removeReminder(slug: string) {
+ return updateDeviceJson(remindersKey, (current) => {
+ const list = current ?? [];
+ return list.filter((item) => item.slug !== slug);
+ });
+}
diff --git a/src/entities/reminder/schedule.ts b/src/entities/reminder/schedule.ts
new file mode 100644
index 0000000..e0c9652
--- /dev/null
+++ b/src/entities/reminder/schedule.ts
@@ -0,0 +1,53 @@
+import {
+ cancelNotification,
+ ensureNotificationPermission,
+ scheduleNotificationAt,
+} from "../../shared/lib/notifications";
+import { addReminder, readReminders, removeReminder } from "./local";
+import { readReminderSettings } from "./settings";
+import { getReminderDate, hoursWord } from "./utils";
+
+// Композиция домена напоминаний с системным адаптером уведомлений. Домен-нейтрально:
+// цель — плоский `{slug, title, startsAt}`, слайс активности/серии сюда не течёт.
+type Target = {
+ slug: string;
+ title: string;
+ startsAt: string;
+};
+
+// Планирует напоминание на старт минус смещение из настройки. false — не
+// запланировано (напоминания выключены, web, момент в прошлом или отказ в
+// разрешении). Дедуп по slug — одна цель держит одно напоминание.
+export async function scheduleReminder(target: Target): Promise {
+ const { enabled, offsetHours } = await readReminderSettings();
+ if (!enabled) return false;
+
+ const remindAt = getReminderDate(target.startsAt, offsetHours);
+ if (remindAt == null) return false;
+
+ const granted = await ensureNotificationPermission();
+ if (!granted) return false;
+
+ const notificationId = await scheduleNotificationAt(
+ remindAt,
+ target.title,
+ `Начало через ${offsetHours} ${hoursWord(offsetHours)}`,
+ );
+ if (notificationId == null) return false;
+
+ await addReminder({
+ slug: target.slug,
+ notificationId,
+ startsAt: target.startsAt,
+ });
+ return true;
+}
+
+export async function cancelReminder(slug: string): Promise {
+ const reminders = await readReminders();
+ const reminder = reminders.find((item) => item.slug === slug);
+ if (reminder == null) return;
+
+ await cancelNotification(reminder.notificationId);
+ await removeReminder(slug);
+}
diff --git a/src/entities/reminder/settings.ts b/src/entities/reminder/settings.ts
new file mode 100644
index 0000000..0f70771
--- /dev/null
+++ b/src/entities/reminder/settings.ts
@@ -0,0 +1,21 @@
+import { readDeviceJson, writeDeviceJson } from "../../shared/lib/storage";
+import type { ReminderSettings } from "./types";
+import { defaultOffsetHours } from "./utils";
+
+// Device-local настройка напоминаний. По умолчанию включены со стартовым смещением.
+const settingsKey = "venty.reminder-settings";
+
+export const defaultReminderSettings: ReminderSettings = {
+ enabled: true,
+ offsetHours: defaultOffsetHours,
+};
+
+export async function readReminderSettings(): Promise {
+ const stored = await readDeviceJson(settingsKey);
+ return stored ?? defaultReminderSettings;
+}
+
+export async function writeReminderSettings(settings: ReminderSettings) {
+ await writeDeviceJson(settingsKey, settings);
+ return settings;
+}
diff --git a/src/entities/reminder/types.ts b/src/entities/reminder/types.ts
new file mode 100644
index 0000000..0069721
--- /dev/null
+++ b/src/entities/reminder/types.ts
@@ -0,0 +1,14 @@
+// Device-local запись о запланированном напоминании: связь публичной ссылки
+// активности с id уведомления в системе. Серверного владения нет.
+export type Reminder = {
+ slug: string;
+ notificationId: string;
+ startsAt: string;
+};
+
+// Device-local настройка напоминаний: выключатель и смещение до старта.
+// Уже запланированные напоминания при смене не пересчитываем (MVP-упрощение).
+export type ReminderSettings = {
+ enabled: boolean;
+ offsetHours: number;
+};
diff --git a/src/entities/reminder/utils.test.ts b/src/entities/reminder/utils.test.ts
new file mode 100644
index 0000000..f51f536
--- /dev/null
+++ b/src/entities/reminder/utils.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { getReminderDate, hoursWord } from "./utils";
+
+const hourInMs = 60 * 60 * 1000;
+const isoAfter = (hours: number) =>
+ new Date(Date.now() + hours * hourInMs).toISOString();
+
+describe("reminder/utils расчёт даты", () => {
+ it("возвращает старт минус смещение для будущей активности", () => {
+ const start = isoAfter(5);
+ const remindAt = getReminderDate(start, 2);
+
+ const expected = new Date(start).getTime() - 2 * hourInMs;
+ expect(remindAt?.getTime()).toBe(expected);
+ });
+
+ it("момент напоминания уже в прошлом → null", () => {
+ // Старт через час, смещение 2 часа — напомнить надо было час назад.
+ expect(getReminderDate(isoAfter(1), 2)).toBeNull();
+ });
+
+ it("мусорную дату старта отдаёт null", () => {
+ expect(getReminderDate("не дата", 2)).toBeNull();
+ });
+});
+
+describe("reminder/utils форма слова «час»", () => {
+ it("склоняет час по числу", () => {
+ expect(hoursWord(1)).toBe("час");
+ expect(hoursWord(2)).toBe("часа");
+ expect(hoursWord(11)).toBe("часов");
+ expect(hoursWord(24)).toBe("часа");
+ });
+});
diff --git a/src/entities/reminder/utils.ts b/src/entities/reminder/utils.ts
new file mode 100644
index 0000000..6ec791b
--- /dev/null
+++ b/src/entities/reminder/utils.ts
@@ -0,0 +1,33 @@
+// Стартовое смещение напоминания — за сколько часов до старта предупреждаем.
+export const defaultOffsetHours = 2;
+
+// Допустимые смещения в настройке профиля (часы до старта).
+export const offsetOptions = [1, 2, 24];
+
+// Русское слово «час» в нужной форме: 1 час, 2 часа, 24 часа, 11 часов.
+export function hoursWord(hours: number): string {
+ const mod100 = hours % 100;
+ if (mod100 >= 11 && mod100 <= 14) return "часов";
+
+ const mod10 = hours % 10;
+ if (mod10 === 1) return "час";
+ if (mod10 >= 2 && mod10 <= 4) return "часа";
+ return "часов";
+}
+
+// Момент срабатывания напоминания: старт минус смещение. Невалидная дата или
+// момент уже в прошлом → null (планировать нечего).
+export function getReminderDate(
+ startsAt: string,
+ offsetHours: number,
+): Date | null {
+ const start = new Date(startsAt);
+ if (Number.isNaN(start.getTime())) return null;
+
+ const remindAt = new Date(start.getTime() - offsetHours * hourInMs);
+ if (remindAt.getTime() <= Date.now()) return null;
+
+ return remindAt;
+}
+
+const hourInMs = 60 * 60 * 1000;
diff --git a/src/entities/series/api.ts b/src/entities/series/api.ts
new file mode 100644
index 0000000..cdc6eae
--- /dev/null
+++ b/src/entities/series/api.ts
@@ -0,0 +1,172 @@
+import { requireSupabase } from "../../shared/supabase/client";
+import type {
+ CreateMemberInput,
+ CreateSeriesInput,
+ Meeting,
+ MeetingMark,
+ Series,
+ SeriesMember,
+ SeriesWithMeetings,
+ UpdateSeriesInput,
+ UpsertMarkInput,
+} from "./types";
+import { listOccurrences } from "./utils";
+
+type Params = {
+ city?: string;
+ query?: string;
+};
+
+export async function listSeries(params: Params = {}) {
+ const { city, query } = params;
+ let request = requireSupabase()
+ .from("series")
+ .select("*, meetings(*)")
+ .eq("status", "active");
+ if (city) request = request.eq("city", city);
+ if (query) request = request.ilike("title", `%${query}%`);
+
+ const { data, error } = await request
+ .order("created_at", { ascending: false })
+ .limit(50);
+ if (error) throw error;
+ return (data ?? []) as SeriesWithMeetings[];
+}
+
+export async function getSeriesBySlug(slug: string) {
+ const { data, error } = await requireSupabase()
+ .from("series")
+ .select("*")
+ .eq("slug", slug)
+ .maybeSingle();
+ if (error) throw error;
+ return data as Series | null;
+}
+
+export async function getSeriesByEditToken(editToken: string) {
+ const { data, error } = await requireSupabase()
+ .from("series")
+ .select("*")
+ .eq("edit_token", editToken)
+ .maybeSingle();
+ if (error) throw error;
+ return data as Series | null;
+}
+
+export async function createSeries(input: CreateSeriesInput) {
+ const { data, error } = await requireSupabase()
+ .from("series")
+ .insert(input)
+ .select("*")
+ .single();
+ if (error) throw error;
+ return data as Series;
+}
+
+export async function updateSeries(id: string, input: UpdateSeriesInput) {
+ const { data, error } = await requireSupabase()
+ .from("series")
+ .update(input)
+ .eq("id", id)
+ .select("*")
+ .single();
+ if (error) throw error;
+ return data as Series;
+}
+
+// Досоздаёт недостающие слоты до горизонта. ignoreDuplicates не трогает уже
+// существующие строки — отменённые и перенесённые встречи не пересоздаются.
+export async function ensureUpcomingMeetings(series: Series) {
+ const slots = listOccurrences(series.recurrence, series.starts_at);
+ const rows = slots.map((scheduledAt) => ({
+ series_id: series.id,
+ scheduled_at: scheduledAt,
+ starts_at: scheduledAt,
+ }));
+ const { error } = await requireSupabase().from("meetings").upsert(rows, {
+ onConflict: "series_id,scheduled_at",
+ ignoreDuplicates: true,
+ });
+ if (error) throw error;
+ return listMeetings(series.id);
+}
+
+export async function listMeetings(seriesId: string) {
+ const { data, error } = await requireSupabase()
+ .from("meetings")
+ .select("*")
+ .eq("series_id", seriesId)
+ .order("scheduled_at", { ascending: true });
+ if (error) throw error;
+ return (data ?? []) as Meeting[];
+}
+
+export async function rescheduleMeeting(id: string, startsAt: string) {
+ const { data, error } = await requireSupabase()
+ .from("meetings")
+ .update({ starts_at: startsAt })
+ .eq("id", id)
+ .select("*")
+ .single();
+ if (error) throw error;
+ return data as Meeting;
+}
+
+export async function cancelMeeting(id: string) {
+ const { data, error } = await requireSupabase()
+ .from("meetings")
+ .update({ status: "cancelled" })
+ .eq("id", id)
+ .select("*")
+ .single();
+ if (error) throw error;
+ return data as Meeting;
+}
+
+export async function listMembers(seriesId: string) {
+ const { data, error } = await requireSupabase()
+ .from("series_members")
+ .select("*")
+ .eq("series_id", seriesId)
+ .order("created_at", { ascending: true });
+ if (error) throw error;
+ return (data ?? []) as SeriesMember[];
+}
+
+export async function joinSeries(input: CreateMemberInput) {
+ const { data, error } = await requireSupabase()
+ .from("series_members")
+ .insert(input)
+ .select("*")
+ .single();
+ if (error) throw error;
+ return data as SeriesMember;
+}
+
+export async function leaveSeries(memberId: string) {
+ const { error } = await requireSupabase()
+ .from("series_members")
+ .delete()
+ .eq("id", memberId);
+ if (error) throw error;
+}
+
+export async function listMarks(meetingIds: string[]) {
+ if (meetingIds.length === 0) return [];
+ const { data, error } = await requireSupabase()
+ .from("meeting_marks")
+ .select("*")
+ .in("meeting_id", meetingIds);
+ if (error) throw error;
+ return (data ?? []) as MeetingMark[];
+}
+
+export async function upsertMark(input: UpsertMarkInput) {
+ const { data, error } = await requireSupabase()
+ .from("meeting_marks")
+ .upsert(input, { onConflict: "meeting_id,member_id" })
+ .select("*")
+ .single();
+ if (error) throw error;
+ return data as MeetingMark;
+}
diff --git a/src/entities/series/hooks.ts b/src/entities/series/hooks.ts
new file mode 100644
index 0000000..def7cd3
--- /dev/null
+++ b/src/entities/series/hooks.ts
@@ -0,0 +1,178 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ cancelMeeting,
+ createSeries,
+ ensureUpcomingMeetings,
+ getSeriesByEditToken,
+ getSeriesBySlug,
+ joinSeries,
+ leaveSeries,
+ listMarks,
+ listMeetings,
+ listMembers,
+ listSeries,
+ rescheduleMeeting,
+ updateSeries,
+ upsertMark,
+} from "./api";
+import { addMySeries, readMemberships, readMySeries } from "./local";
+import type { Series, UpdateSeriesInput } from "./types";
+
+type SeriesFilter = {
+ city?: string;
+ query?: string;
+};
+
+export const seriesKeys = {
+ all: ["series"] as const,
+ list: (filter: SeriesFilter) => ["series", "list", filter] as const,
+ mine: ["series", "mine"] as const,
+ memberships: ["series", "memberships"] as const,
+ slug: (slug: string) => ["series", "slug", slug] as const,
+ edit: (editToken: string) => ["series", "edit", editToken] as const,
+ meetings: (seriesId: string) => ["series", "meetings", seriesId] as const,
+ members: (seriesId: string) => ["series", "members", seriesId] as const,
+ marks: (meetingIds: string[]) => ["series", "marks", meetingIds] as const,
+};
+
+export function useSeries(filter: SeriesFilter = {}) {
+ return useQuery({
+ queryKey: seriesKeys.list(filter),
+ queryFn: () => listSeries(filter),
+ });
+}
+export function useSeriesBySlug(slug?: string) {
+ return useQuery({
+ queryKey: seriesKeys.slug(slug ?? ""),
+ queryFn: () => getSeriesBySlug(slug ?? ""),
+ enabled: Boolean(slug),
+ });
+}
+export function useSeriesByEditToken(editToken?: string) {
+ return useQuery({
+ queryKey: seriesKeys.edit(editToken ?? ""),
+ queryFn: () => getSeriesByEditToken(editToken ?? ""),
+ enabled: Boolean(editToken),
+ });
+}
+export function useMySeries() {
+ return useQuery({ queryKey: seriesKeys.mine, queryFn: readMySeries });
+}
+export function useMyMemberships() {
+ return useQuery({
+ queryKey: seriesKeys.memberships,
+ queryFn: readMemberships,
+ });
+}
+export function useMeetings(seriesId?: string) {
+ return useQuery({
+ queryKey: seriesKeys.meetings(seriesId ?? ""),
+ queryFn: () => listMeetings(seriesId ?? ""),
+ enabled: Boolean(seriesId),
+ });
+}
+export function useMembers(seriesId?: string) {
+ return useQuery({
+ queryKey: seriesKeys.members(seriesId ?? ""),
+ queryFn: () => listMembers(seriesId ?? ""),
+ enabled: Boolean(seriesId),
+ });
+}
+export function useMarks(meetingIds: string[]) {
+ return useQuery({
+ queryKey: seriesKeys.marks(meetingIds),
+ queryFn: () => listMarks(meetingIds),
+ enabled: meetingIds.length > 0,
+ });
+}
+
+export function useCreateSeries() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: createSeries,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: seriesKeys.all }),
+ });
+}
+export function useTrackMySeries() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: addMySeries,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: seriesKeys.mine }),
+ });
+}
+export function useUpdateSeries() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, input }: { id: string; input: UpdateSeriesInput }) =>
+ updateSeries(id, input),
+ onSuccess: (series) => {
+ queryClient.invalidateQueries({ queryKey: seriesKeys.all });
+ queryClient.invalidateQueries({
+ queryKey: seriesKeys.edit(series.edit_token),
+ });
+ },
+ });
+}
+export function useEnsureUpcomingMeetings() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (series: Series) => ensureUpcomingMeetings(series),
+ onSuccess: (_meetings, series) =>
+ queryClient.invalidateQueries({
+ queryKey: seriesKeys.meetings(series.id),
+ }),
+ });
+}
+export function useRescheduleMeeting(seriesId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, startsAt }: { id: string; startsAt: string }) =>
+ rescheduleMeeting(id, startsAt),
+ onSuccess: () => invalidateMeetings(queryClient, seriesId),
+ });
+}
+export function useCancelMeeting(seriesId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: cancelMeeting,
+ onSuccess: () => invalidateMeetings(queryClient, seriesId),
+ });
+}
+export function useJoinSeries(seriesId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: joinSeries,
+ onSuccess: () =>
+ queryClient.invalidateQueries({
+ queryKey: seriesKeys.members(seriesId),
+ }),
+ });
+}
+export function useLeaveSeries(seriesId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: leaveSeries,
+ onSuccess: () =>
+ queryClient.invalidateQueries({
+ queryKey: seriesKeys.members(seriesId),
+ }),
+ });
+}
+export function useUpsertMark() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: upsertMark,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: ["series", "marks"] }),
+ });
+}
+
+function invalidateMeetings(
+ queryClient: ReturnType,
+ seriesId: string,
+) {
+ queryClient.invalidateQueries({ queryKey: seriesKeys.meetings(seriesId) });
+ queryClient.invalidateQueries({ queryKey: seriesKeys.all });
+}
diff --git a/src/entities/series/local.test.ts b/src/entities/series/local.test.ts
new file mode 100644
index 0000000..558d43a
--- /dev/null
+++ b/src/entities/series/local.test.ts
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// Хранилище устройства подменяем in-memory: тестируем чистую логику дедупа.
+const store = new Map();
+
+vi.mock("../../shared/lib/storage", () => ({
+ readDeviceJson: async (key: string) => store.get(key) ?? null,
+ writeDeviceJson: async (key: string, value: unknown) => {
+ store.set(key, value);
+ },
+}));
+
+import {
+ addMembership,
+ addMySeries,
+ findMembership,
+ readMemberships,
+ readMySeries,
+ removeMembership,
+} from "./local";
+import type { Series } from "./types";
+
+const series: Series = {
+ id: "1",
+ slug: "speaking-club",
+ title: "Speaking club",
+ description: null,
+ city: "Алматы",
+ location_text: null,
+ starts_at: "2026-07-01T19:00:00Z",
+ capacity: null,
+ cover_url: null,
+ chat_url: null,
+ recurrence: {
+ interval: 1,
+ unit: "week",
+ weekdays: [1],
+ end: { kind: "never" },
+ },
+ edit_token: "token",
+ status: "active",
+ created_at: "",
+ updated_at: "",
+};
+
+describe("series/local мои серии", () => {
+ beforeEach(() => store.clear());
+
+ it("повторное добавление того же slug не дублирует запись", async () => {
+ await addMySeries(series);
+ await addMySeries(series);
+
+ expect(await readMySeries()).toHaveLength(1);
+ });
+});
+
+describe("series/local хожу постоянно", () => {
+ beforeEach(() => store.clear());
+
+ it("повторная запись в ту же серию не дублирует membership", async () => {
+ await addMembership({ seriesSlug: "speaking-club", memberId: "m1" });
+ await addMembership({ seriesSlug: "speaking-club", memberId: "m2" });
+
+ expect(await readMemberships()).toHaveLength(1);
+ });
+
+ it("выход убирает membership и находит по slug", async () => {
+ await addMembership({ seriesSlug: "speaking-club", memberId: "m1" });
+
+ expect(await findMembership("speaking-club")).toMatchObject({
+ memberId: "m1",
+ });
+
+ await removeMembership("speaking-club");
+
+ expect(await findMembership("speaking-club")).toBeNull();
+ });
+});
diff --git a/src/entities/series/local.ts b/src/entities/series/local.ts
new file mode 100644
index 0000000..0e90da1
--- /dev/null
+++ b/src/entities/series/local.ts
@@ -0,0 +1,73 @@
+import { readDeviceJson, writeDeviceJson } from "../../shared/lib/storage";
+import type { Series } from "./types";
+
+// Локальный список созданных на устройстве серий — данные для «Мои ивенты».
+// Серверного владения нет, это device-local проекция серии.
+export type MySeries = {
+ slug: string;
+ editToken: string;
+ title: string;
+ city: string;
+ startsAt: string;
+};
+
+const mySeriesKey = "venty.my-series";
+
+export async function readMySeries(): Promise {
+ const list = await readDeviceJson(mySeriesKey);
+ return list ?? [];
+}
+
+export async function addMySeries(series: Series) {
+ const list = await readMySeries();
+ if (list.some((item) => item.slug === series.slug)) return list;
+ const next = [toMySeries(series), ...list];
+ await writeDeviceJson(mySeriesKey, next);
+ return next;
+}
+
+function toMySeries(series: Series): MySeries {
+ return {
+ slug: series.slug,
+ editToken: series.edit_token,
+ title: series.title,
+ city: series.city,
+ startsAt: series.starts_at,
+ };
+}
+
+// Записи «ты ходишь постоянно»: memberId идентифицирует участника на устройстве —
+// им же подписаны отметки иду/пропущу и выход из серии.
+export type Membership = {
+ seriesSlug: string;
+ memberId: string;
+};
+
+const membershipsKey = "venty.my-memberships";
+
+export async function readMemberships(): Promise {
+ const list = await readDeviceJson(membershipsKey);
+ return list ?? [];
+}
+
+export async function findMembership(seriesSlug: string) {
+ const list = await readMemberships();
+ return list.find((item) => item.seriesSlug === seriesSlug) ?? null;
+}
+
+export async function addMembership(membership: Membership) {
+ const list = await readMemberships();
+ if (list.some((item) => item.seriesSlug === membership.seriesSlug)) {
+ return list;
+ }
+ const next = [membership, ...list];
+ await writeDeviceJson(membershipsKey, next);
+ return next;
+}
+
+export async function removeMembership(seriesSlug: string) {
+ const list = await readMemberships();
+ const next = list.filter((item) => item.seriesSlug !== seriesSlug);
+ await writeDeviceJson(membershipsKey, next);
+ return next;
+}
diff --git a/src/entities/series/mappers.test.ts b/src/entities/series/mappers.test.ts
new file mode 100644
index 0000000..599b0ce
--- /dev/null
+++ b/src/entities/series/mappers.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("expo-crypto", () => ({
+ randomUUID: () => "123e4567-e89b-12d3-a456-426614174000",
+}));
+
+import {
+ mapCreateSeriesFormToInput,
+ mapJoinFormToMemberInput,
+ mapSeriesToUpdateForm,
+} from "./mappers";
+import type { Recurrence, Series } from "./types";
+
+const recurrence: Recurrence = {
+ interval: 1,
+ unit: "week",
+ weekdays: [1],
+ end: { kind: "never" },
+};
+
+describe("series/mappers", () => {
+ it("преобразует форму создания в payload для API", () => {
+ const input = mapCreateSeriesFormToInput({
+ title: "Speaking club",
+ description: null,
+ city: "Алматы",
+ location_text: null,
+ starts_at: "2026-07-01T19:00",
+ capacity: null,
+ cover_url: null,
+ chat_url: null,
+ recurrence,
+ });
+
+ expect(input).toMatchObject({
+ title: "Speaking club",
+ description: null,
+ city: "Алматы",
+ recurrence,
+ status: "active",
+ });
+ expect(input.starts_at).toBe(new Date("2026-07-01T19:00").toISOString());
+ expect(input.slug).toMatch(/^speaking-club-[a-z0-9]{6}$/);
+ expect(input.edit_token).toMatch(/^[a-f0-9]{32}$/);
+ });
+
+ it("преобразует форму записи в payload участника", () => {
+ const withoutTelegram = mapJoinFormToMemberInput("s1", {
+ name: "Анна",
+ telegram: "",
+ });
+ const withTelegram = mapJoinFormToMemberInput("s1", {
+ name: "Анна",
+ telegram: "@anna",
+ });
+
+ expect(withoutTelegram).toEqual({
+ series_id: "s1",
+ name: "Анна",
+ telegram: null,
+ });
+ expect(withTelegram.telegram).toBe("@anna");
+ });
+
+ it("преобразует null поля серии в пустые строки для формы управления", () => {
+ const form = mapSeriesToUpdateForm(buildSeries({ chat_url: null }));
+
+ expect(form.description).toBe("");
+ expect(form.location_text).toBe("");
+ expect(form.cover_url).toBe("");
+ expect(form.chat_url).toBe("");
+ });
+
+ it("сохраняет заполненные поля серии в форме управления", () => {
+ const form = mapSeriesToUpdateForm(
+ buildSeries({ chat_url: "https://t.me/club", status: "finished" }),
+ );
+
+ expect(form.chat_url).toBe("https://t.me/club");
+ expect(form.status).toBe("finished");
+ expect(form.recurrence).toEqual(recurrence);
+ });
+});
+
+function buildSeries(overrides: Partial): Series {
+ return {
+ id: "1",
+ slug: "series",
+ title: "Speaking club",
+ description: null,
+ city: "Алматы",
+ location_text: null,
+ starts_at: "2026-07-01T19:00:00.000Z",
+ capacity: null,
+ cover_url: null,
+ chat_url: null,
+ recurrence,
+ edit_token: "token",
+ status: "active",
+ created_at: "2026-05-01T00:00:00.000Z",
+ updated_at: "2026-05-01T00:00:00.000Z",
+ ...overrides,
+ };
+}
diff --git a/src/entities/series/mappers.ts b/src/entities/series/mappers.ts
new file mode 100644
index 0000000..8e7293e
--- /dev/null
+++ b/src/entities/series/mappers.ts
@@ -0,0 +1,64 @@
+import {
+ toDateTimeLocalInputValue,
+ toIsoDate,
+} from "../../shared/lib/datetime";
+import { generateEditToken, generateSlug } from "../../shared/lib/publicLink";
+import type {
+ CreateSeriesForm,
+ JoinSeriesForm,
+ UpdateSeriesForm,
+ UpdateSeriesFormInput,
+} from "./schemas";
+import type {
+ CreateMemberInput,
+ CreateSeriesInput,
+ Series,
+ UpdateSeriesInput,
+} from "./types";
+
+export function mapCreateSeriesFormToInput(
+ form: CreateSeriesForm,
+): CreateSeriesInput {
+ return {
+ ...form,
+ starts_at: toIsoDate(form.starts_at),
+ slug: generateSlug(form.title, "series"),
+ edit_token: generateEditToken(),
+ status: "active",
+ };
+}
+
+export function mapUpdateSeriesFormToInput(
+ form: UpdateSeriesForm,
+): UpdateSeriesInput {
+ return {
+ ...form,
+ starts_at: toIsoDate(form.starts_at),
+ };
+}
+
+export function mapJoinFormToMemberInput(
+ seriesId: string,
+ form: JoinSeriesForm,
+): CreateMemberInput {
+ return {
+ series_id: seriesId,
+ name: form.name,
+ telegram: form.telegram || null,
+ };
+}
+
+export function mapSeriesToUpdateForm(series: Series): UpdateSeriesFormInput {
+ return {
+ title: series.title,
+ description: series.description ?? "",
+ city: series.city,
+ location_text: series.location_text ?? "",
+ starts_at: toDateTimeLocalInputValue(series.starts_at),
+ capacity: series.capacity,
+ cover_url: series.cover_url ?? "",
+ chat_url: series.chat_url ?? "",
+ recurrence: series.recurrence,
+ status: series.status,
+ };
+}
diff --git a/src/entities/series/schemas.test.ts b/src/entities/series/schemas.test.ts
new file mode 100644
index 0000000..1f21c29
--- /dev/null
+++ b/src/entities/series/schemas.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import { createSeriesSchema, updateSeriesSchema } from "./schemas";
+
+const validForm = {
+ title: "Speaking club",
+ description: "",
+ city: "Алматы",
+ location_text: "",
+ starts_at: "2026-07-01T19:00",
+ capacity: "",
+ cover_url: "",
+ chat_url: "",
+ recurrence: {
+ interval: 1,
+ unit: "week",
+ weekdays: [1],
+ end: { kind: "never" },
+ },
+};
+
+describe("series/schemas", () => {
+ it("чистит пустые необязательные поля в null", () => {
+ const result = createSeriesSchema.parse(validForm);
+
+ expect(result.description).toBeNull();
+ expect(result.location_text).toBeNull();
+ expect(result.capacity).toBeNull();
+ expect(result.cover_url).toBeNull();
+ expect(result.chat_url).toBeNull();
+ });
+
+ it("требует название, город и корректную дату", () => {
+ expect(
+ createSeriesSchema.safeParse({ ...validForm, title: " " }).success,
+ ).toBe(false);
+ expect(
+ createSeriesSchema.safeParse({ ...validForm, city: "" }).success,
+ ).toBe(false);
+ const badDate = { ...validForm, starts_at: "1 июля" };
+ expect(createSeriesSchema.safeParse(badDate).success).toBe(false);
+ });
+
+ it("недельное правило требует хотя бы один день недели", () => {
+ const recurrence = { ...validForm.recurrence, weekdays: [] };
+
+ expect(
+ createSeriesSchema.safeParse({ ...validForm, recurrence }).success,
+ ).toBe(false);
+ });
+
+ it("отклоняет неположительный интервал правила", () => {
+ const recurrence = { ...validForm.recurrence, interval: 0 };
+
+ expect(
+ createSeriesSchema.safeParse({ ...validForm, recurrence }).success,
+ ).toBe(false);
+ });
+
+ it("отклоняет неположительное число встреч в окончании", () => {
+ const recurrence = {
+ ...validForm.recurrence,
+ end: { kind: "count", count: 0 },
+ };
+
+ expect(
+ createSeriesSchema.safeParse({ ...validForm, recurrence }).success,
+ ).toBe(false);
+ });
+
+ it("в форме обновления добавляет статус", () => {
+ const ok = updateSeriesSchema.safeParse({
+ ...validForm,
+ status: "finished",
+ });
+ const bad = updateSeriesSchema.safeParse({
+ ...validForm,
+ status: "paused",
+ });
+
+ expect(ok.success).toBe(true);
+ expect(bad.success).toBe(false);
+ });
+});
diff --git a/src/entities/series/schemas.ts b/src/entities/series/schemas.ts
new file mode 100644
index 0000000..77b4cfd
--- /dev/null
+++ b/src/entities/series/schemas.ts
@@ -0,0 +1,72 @@
+import { z } from "zod";
+import { isValidDateTime } from "../../shared/lib/datetime";
+import {
+ capacity,
+ chatUrl,
+ optionalText,
+ optionalUrl,
+} from "../../shared/lib/zodFields";
+
+const recurrenceEnd = z.discriminatedUnion("kind", [
+ z.object({ kind: z.literal("never") }),
+ z.object({
+ kind: z.literal("until"),
+ date: z
+ .string()
+ .min(1, "Укажите дату окончания")
+ .refine(isValidDateTime, "Введите корректную дату"),
+ }),
+ z.object({
+ kind: z.literal("count"),
+ count: z.coerce
+ .number()
+ .int()
+ .positive("Число встреч должно быть больше нуля")
+ .max(200, "Слишком много встреч"),
+ }),
+]);
+
+const recurrence = z
+ .object({
+ interval: z.coerce
+ .number()
+ .int()
+ .positive("Интервал должен быть больше нуля"),
+ unit: z.enum(["day", "week", "month"]),
+ weekdays: z.array(z.number().int().min(0).max(6)).default([]),
+ end: recurrenceEnd,
+ })
+ .refine((rule) => rule.unit !== "week" || rule.weekdays.length > 0, {
+ message: "Выберите хотя бы один день недели",
+ path: ["weekdays"],
+ });
+
+export const createSeriesSchema = z.object({
+ title: z.string().trim().min(1, "Название обязательно"),
+ description: optionalText,
+ city: z.string().trim().min(1, "Город обязателен"),
+ location_text: optionalText,
+ starts_at: z
+ .string()
+ .min(1, "Дата обязательна")
+ .refine(isValidDateTime, "Введите корректные дату и время"),
+ capacity,
+ cover_url: optionalUrl,
+ chat_url: chatUrl,
+ recurrence,
+});
+
+export const updateSeriesSchema = createSeriesSchema.extend({
+ status: z.enum(["active", "finished"]),
+});
+
+export const joinSeriesSchema = z.object({
+ name: z.string().trim().min(1, "Имя обязательно"),
+ telegram: z.string().optional(),
+});
+
+export type CreateSeriesFormInput = z.input;
+export type CreateSeriesForm = z.output;
+export type UpdateSeriesFormInput = z.input;
+export type UpdateSeriesForm = z.output;
+export type JoinSeriesForm = z.infer;
diff --git a/src/entities/series/types.ts b/src/entities/series/types.ts
new file mode 100644
index 0000000..cc94091
--- /dev/null
+++ b/src/entities/series/types.ts
@@ -0,0 +1,96 @@
+export type RecurrenceUnit = "day" | "week" | "month";
+
+export type RecurrenceEnd =
+ | { kind: "never" }
+ | { kind: "until"; date: string }
+ | { kind: "count"; count: number };
+
+export type Recurrence = {
+ interval: number;
+ unit: RecurrenceUnit;
+ weekdays: number[]; // 0 = понедельник … 6 = воскресенье
+ end: RecurrenceEnd;
+};
+
+export type SeriesStatus = "active" | "finished";
+
+export type Series = {
+ id: string;
+ slug: string;
+ title: string;
+ description: string | null;
+ city: string;
+ location_text: string | null;
+ starts_at: string; // первая встреча; задаёт время серии
+ capacity: number | null;
+ cover_url: string | null;
+ chat_url: string | null;
+ recurrence: Recurrence;
+ edit_token: string;
+ status: SeriesStatus;
+ created_at: string;
+ updated_at: string;
+};
+
+export type MeetingStatus = "active" | "cancelled";
+
+export type Meeting = {
+ id: string;
+ series_id: string;
+ scheduled_at: string; // слот по правилу, неизменен
+ starts_at: string; // факт: слот или перенос
+ status: MeetingStatus;
+ created_at: string;
+};
+
+// Строка ленты: серия с уже сгенерированными встречами (ближайшую считаем на клиенте).
+export type SeriesWithMeetings = Series & { meetings: Meeting[] };
+
+export type SeriesMember = {
+ id: string;
+ series_id: string;
+ name: string;
+ telegram: string | null;
+ created_at: string;
+};
+
+export type MarkStatus = "going" | "skip";
+
+export type MeetingMark = {
+ id: string;
+ meeting_id: string;
+ member_id: string;
+ status: MarkStatus;
+ created_at: string;
+};
+
+export type CreateSeriesInput = {
+ slug: string;
+ title: string;
+ description?: string | null;
+ city: string;
+ location_text?: string | null;
+ starts_at: string;
+ capacity?: number | null;
+ cover_url?: string | null;
+ chat_url?: string | null;
+ recurrence: Recurrence;
+ edit_token: string;
+ status: SeriesStatus;
+};
+
+export type UpdateSeriesInput = Partial<
+ Omit
+>;
+
+export type CreateMemberInput = {
+ series_id: string;
+ name: string;
+ telegram?: string | null;
+};
+
+export type UpsertMarkInput = {
+ meeting_id: string;
+ member_id: string;
+ status: MarkStatus;
+};
diff --git a/src/entities/series/utils.test.ts b/src/entities/series/utils.test.ts
new file mode 100644
index 0000000..9d51356
--- /dev/null
+++ b/src/entities/series/utils.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+import type { MeetingMark, Recurrence, SeriesMember } from "./types";
+import { formatRecurrenceText, getGoingCount, listOccurrences } from "./utils";
+
+const anchor = "2026-03-02T19:00"; // понедельник
+const DAY = 86_400_000;
+
+const rule = (over: Partial): Recurrence => ({
+ interval: 1,
+ unit: "day",
+ weekdays: [],
+ end: { kind: "never" },
+ ...over,
+});
+
+const ourWeekday = (iso: string) => (new Date(iso).getDay() + 6) % 7;
+const spacing = (dates: string[], i: number) =>
+ new Date(dates[i + 1]).getTime() - new Date(dates[i]).getTime();
+
+describe("series/utils генерация слотов", () => {
+ it("ежедневное правило даёт подряд идущие дни", () => {
+ const dates = listOccurrences(rule({ unit: "day" }), anchor, {
+ horizon: 3,
+ });
+
+ expect(dates).toHaveLength(3);
+ expect(dates[0]).toBe(new Date(anchor).toISOString());
+ expect(spacing(dates, 0)).toBe(DAY);
+ expect(spacing(dates, 1)).toBe(DAY);
+ });
+
+ it("недельное правило по одному дню шагает через неделю", () => {
+ const dates = listOccurrences(
+ rule({ unit: "week", weekdays: [0] }),
+ anchor,
+ { horizon: 3 },
+ );
+
+ expect(dates.map(ourWeekday)).toEqual([0, 0, 0]);
+ expect(spacing(dates, 0)).toBe(7 * DAY);
+ });
+
+ it("недельное правило по нескольким дням чередует дни недели", () => {
+ const dates = listOccurrences(
+ rule({ unit: "week", weekdays: [0, 2] }),
+ anchor,
+ { horizon: 4 },
+ );
+
+ expect(dates.map(ourWeekday)).toEqual([0, 2, 0, 2]);
+ });
+
+ it("интервал в 2 недели удваивает шаг", () => {
+ const dates = listOccurrences(
+ rule({ unit: "week", weekdays: [0], interval: 2 }),
+ anchor,
+ { horizon: 2 },
+ );
+
+ expect(spacing(dates, 0)).toBe(14 * DAY);
+ });
+
+ it("месячное правило сохраняет день месяца", () => {
+ const dates = listOccurrences(rule({ unit: "month" }), anchor, {
+ horizon: 3,
+ });
+
+ expect(dates.map((iso) => new Date(iso).getDate())).toEqual([2, 2, 2]);
+ });
+
+ it("окончание по числу встреч ограничивает количество", () => {
+ const dates = listOccurrences(
+ rule({ unit: "day", end: { kind: "count", count: 2 } }),
+ anchor,
+ { horizon: 5 },
+ );
+
+ expect(dates).toHaveLength(2);
+ });
+
+ it("окончание по дате обрезает по горизонту", () => {
+ const until = new Date(new Date(anchor).getTime() + 2 * DAY).toISOString();
+ const dates = listOccurrences(
+ rule({ unit: "day", end: { kind: "until", date: until } }),
+ anchor,
+ { horizon: 10 },
+ );
+
+ expect(dates).toHaveLength(3);
+ });
+
+ it("мусорную дату старта отдаёт пустым списком", () => {
+ expect(listOccurrences(rule({}), "не дата")).toEqual([]);
+ });
+});
+
+describe("series/utils текст правила", () => {
+ it("описывает правило текстом для бейджа", () => {
+ expect(formatRecurrenceText(rule({ unit: "day" }), anchor)).toBe(
+ "каждый день · 19:00",
+ );
+ expect(
+ formatRecurrenceText(rule({ unit: "week", weekdays: [1] }), anchor),
+ ).toBe("каждый вт · 19:00");
+ expect(
+ formatRecurrenceText(rule({ unit: "week", weekdays: [0, 2] }), anchor),
+ ).toBe("по пн, ср · 19:00");
+ });
+});
+
+describe("series/utils кто идёт", () => {
+ it("считает идущих: без отметки идёт, пропуск вычитается", () => {
+ const members = [{ id: "a" }, { id: "b" }, { id: "c" }] as SeriesMember[];
+ const marks = [
+ { member_id: "b", status: "skip" },
+ { member_id: "a", status: "going" },
+ ] as MeetingMark[];
+
+ expect(getGoingCount(members, marks)).toBe(2);
+ });
+});
diff --git a/src/entities/series/utils.ts b/src/entities/series/utils.ts
new file mode 100644
index 0000000..f14a16e
--- /dev/null
+++ b/src/entities/series/utils.ts
@@ -0,0 +1,188 @@
+import type { Meeting, MeetingMark, Recurrence, SeriesMember } from "./types";
+
+type ListOptions = { horizon?: number };
+
+// Даты слотов серии по правилу повторения, начиная от starts_at.
+// Возвращаем ближайшие `horizon` дат (окно генерации встреч и превью конструктора),
+// не выходя за окончание правила. Последовательность детерминирована — повторный
+// прогон даёт те же слоты, поэтому upsert по scheduled_at идемпотентен.
+export function listOccurrences(
+ recurrence: Recurrence,
+ startsAt: string,
+ { horizon = 5 }: ListOptions = {},
+): string[] {
+ const anchor = new Date(startsAt);
+ if (Number.isNaN(anchor.getTime())) return [];
+
+ const limit = occurrenceLimit(recurrence, horizon);
+ const until = untilDate(recurrence);
+
+ const dates: string[] = [];
+ for (const date of occurrenceSequence(recurrence, anchor)) {
+ if (until && date > until) break;
+ dates.push(date.toISOString());
+ if (dates.length >= limit) break;
+ }
+ return dates;
+}
+
+const weekdayLabels = ["пн", "вт", "ср", "чт", "пт", "сб", "вс"];
+
+// Человекочитаемое правило для бейджа ленты и превью конструктора: «каждый вт · 19:00».
+export function formatRecurrenceText(
+ recurrence: Recurrence,
+ startsAt: string,
+): string {
+ const time = formatTime(startsAt);
+ return time
+ ? `${formatSchedule(recurrence)} · ${time}`
+ : formatSchedule(recurrence);
+}
+
+// «Кто идёт» на встречу: постоянные участники минус отметившие «пропущу».
+// Отсутствие отметки трактуем как «идёт» (дефолт после записи — ходит на все).
+export function getGoingCount(
+ members: SeriesMember[],
+ marks: MeetingMark[],
+): number {
+ const skipped = new Set(
+ marks
+ .filter((mark) => mark.status === "skip")
+ .map((mark) => mark.member_id),
+ );
+ return members.filter((member) => !skipped.has(member.id)).length;
+}
+
+// Ближайшая активная встреча позже указанного момента (по умолчанию — сейчас).
+// Цель напоминания серии: отменённые и уже прошедшие встречи отбрасываем.
+export function nextMeeting(
+ meetings: Meeting[],
+ after: string = new Date().toISOString(),
+): Meeting | null {
+ const afterMs = new Date(after).getTime();
+ const upcoming = meetings
+ .filter(
+ (meeting) =>
+ meeting.status === "active" &&
+ new Date(meeting.starts_at).getTime() > afterMs,
+ )
+ .sort(
+ (a, b) =>
+ new Date(a.starts_at).getTime() - new Date(b.starts_at).getTime(),
+ );
+ return upcoming[0] ?? null;
+}
+
+function occurrenceLimit(recurrence: Recurrence, horizon: number) {
+ const { end } = recurrence;
+ return end.kind === "count" ? Math.min(horizon, end.count) : horizon;
+}
+
+function untilDate(recurrence: Recurrence) {
+ const { end } = recurrence;
+ return end.kind === "until" ? new Date(end.date) : null;
+}
+
+function* occurrenceSequence(
+ recurrence: Recurrence,
+ anchor: Date,
+): Generator {
+ if (recurrence.unit === "week") {
+ yield* weeklySequence(recurrence, anchor);
+ return;
+ }
+ yield* steppedSequence(recurrence, anchor);
+}
+
+function* steppedSequence(
+ recurrence: Recurrence,
+ anchor: Date,
+): Generator {
+ let current = new Date(anchor);
+ while (true) {
+ yield new Date(current);
+ current = advance(current, recurrence.unit, recurrence.interval);
+ }
+}
+
+function* weeklySequence(
+ recurrence: Recurrence,
+ anchor: Date,
+): Generator {
+ const weekdays = selectedWeekdays(recurrence, anchor);
+ const weekStart = startOfWeek(anchor);
+ let weekOffset = 0;
+ while (true) {
+ for (const weekday of weekdays) {
+ const date = addDays(weekStart, weekOffset + weekday);
+ date.setHours(anchor.getHours(), anchor.getMinutes(), 0, 0);
+ if (date >= anchor) yield date;
+ }
+ weekOffset += 7 * recurrence.interval;
+ }
+}
+
+function selectedWeekdays(recurrence: Recurrence, anchor: Date) {
+ if (recurrence.weekdays.length > 0) {
+ return [...recurrence.weekdays].sort((a, b) => a - b);
+ }
+ return [toWeekday(anchor)];
+}
+
+function advance(date: Date, unit: Recurrence["unit"], interval: number) {
+ const next = new Date(date);
+ if (unit === "month") {
+ next.setMonth(next.getMonth() + interval);
+ } else {
+ next.setDate(next.getDate() + interval);
+ }
+ return next;
+}
+
+function startOfWeek(date: Date) {
+ const monday = new Date(date);
+ monday.setDate(date.getDate() - toWeekday(date));
+ monday.setHours(0, 0, 0, 0);
+ return monday;
+}
+
+function addDays(date: Date, days: number) {
+ const next = new Date(date);
+ next.setDate(next.getDate() + days);
+ return next;
+}
+
+function toWeekday(date: Date) {
+ return (date.getDay() + 6) % 7; // JS: 0 = вс … в наш формат 0 = пн
+}
+
+function formatSchedule(recurrence: Recurrence): string {
+ const { interval, unit } = recurrence;
+ if (unit === "day") {
+ return interval === 1 ? "каждый день" : `каждые ${interval} дн.`;
+ }
+ if (unit === "month") {
+ return interval === 1 ? "каждый месяц" : `каждые ${interval} мес.`;
+ }
+ return formatWeeklySchedule(recurrence);
+}
+
+function formatWeeklySchedule(recurrence: Recurrence): string {
+ const labels = [...recurrence.weekdays]
+ .sort((a, b) => a - b)
+ .map((weekday) => weekdayLabels[weekday]);
+ if (recurrence.interval === 1) {
+ if (labels.length === 0) return "каждую неделю";
+ if (labels.length === 1) return `каждый ${labels[0]}`;
+ return `по ${labels.join(", ")}`;
+ }
+ const weeks = `каждые ${recurrence.interval} нед.`;
+ return labels.length > 0 ? `${weeks} · ${labels.join(", ")}` : weeks;
+}
+
+function formatTime(startsAt: string) {
+ const date = new Date(startsAt);
+ if (Number.isNaN(date.getTime())) return "";
+ const pad = (part: number) => String(part).padStart(2, "0");
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
diff --git a/src/features/create-activity/CreateActivityForm.tsx b/src/features/create-activity/CreateActivityForm.tsx
new file mode 100644
index 0000000..9b06ff7
--- /dev/null
+++ b/src/features/create-activity/CreateActivityForm.tsx
@@ -0,0 +1,73 @@
+import { View } from "react-native";
+import { Button } from "../../shared/ui/Button";
+import { DateTimeInput } from "../../shared/ui/DateTimeInput";
+import { ErrorText } from "../../shared/ui/ErrorText";
+import { FormField } from "../../shared/ui/FormField";
+import { Textarea } from "../../shared/ui/Textarea";
+import { useCreateActivityForm } from "./useCreateActivityForm";
+
+export function CreateActivityForm() {
+ const { form, submit, isSaving } = useCreateActivityForm();
+ const { control, handleSubmit } = form;
+ const { errors } = form.formState;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {errors.root?.server?.message}
+
+ );
+}
diff --git a/src/features/create-activity/useCreateActivityForm.ts b/src/features/create-activity/useCreateActivityForm.ts
index 94bc469..d440421 100644
--- a/src/features/create-activity/useCreateActivityForm.ts
+++ b/src/features/create-activity/useCreateActivityForm.ts
@@ -1,29 +1,48 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "expo-router";
import { useForm } from "react-hook-form";
-import { useCreateActivity } from "../../entities/activity/hooks";
+import {
+ useCreateActivity,
+ useTrackMyActivity,
+} from "../../entities/activity/hooks";
import { mapCreateActivityFormToInput } from "../../entities/activity/mappers";
import {
type CreateActivityForm,
type CreateActivityFormInput,
createActivitySchema,
} from "../../entities/activity/schemas";
+import type { Activity } from "../../entities/activity/types";
+import { telegramToChatUrl } from "../../entities/activity/utils";
+import { useProfile } from "../../entities/profile/hooks";
import { setFormServerError } from "../../shared/lib/setFormServerError";
+const emptyForm: CreateActivityFormInput = {
+ title: "",
+ description: "",
+ city: "",
+ location_text: "",
+ starts_at: "",
+ capacity: null,
+ cover_url: "",
+ chat_url: "",
+};
+
export function useCreateActivityForm() {
const router = useRouter();
const mutation = useCreateActivity();
+ const track = useTrackMyActivity();
+ const { data: profile } = useProfile();
+ // Тир 1: чат у организатора обычно один на все встречи — дефолтим ссылку
+ // из Telegram профиля. Профиль грузится асинхронно, поэтому через `values`;
+ // keepDirtyValues не затирает то, что организатор уже ввёл вручную.
+ const prefill = profile
+ ? { ...emptyForm, chat_url: telegramToChatUrl(profile.socials.telegram) }
+ : undefined;
const form = useForm({
resolver: zodResolver(createActivitySchema),
- defaultValues: {
- title: "",
- description: "",
- city: "",
- location_text: "",
- starts_at: "",
- capacity: null,
- cover_url: "",
- },
+ defaultValues: emptyForm,
+ values: prefill,
+ resetOptions: { keepDirtyValues: true },
});
async function submit(values: CreateActivityForm) {
@@ -32,12 +51,21 @@ export function useCreateActivityForm() {
const activity = await mutation.mutateAsync(
mapCreateActivityFormToInput(values),
);
+ await rememberCreated(activity);
router.replace(`/manage/${activity.edit_token}`);
} catch (error) {
setFormServerError(form.setError, "Не удалось создать активность", error);
}
}
+ // Локальный список «Мои ивенты» не критичен: сбой записи не должен ронять
+ // успешное создание активности.
+ async function rememberCreated(activity: Activity) {
+ try {
+ await track.mutateAsync(activity);
+ } catch {}
+ }
+
return {
form,
submit,
diff --git a/src/features/create-series/CreateSeriesForm.tsx b/src/features/create-series/CreateSeriesForm.tsx
new file mode 100644
index 0000000..8666c66
--- /dev/null
+++ b/src/features/create-series/CreateSeriesForm.tsx
@@ -0,0 +1,104 @@
+import { View } from "react-native";
+import { Button } from "../../shared/ui/Button";
+import { DateTimeInput } from "../../shared/ui/DateTimeInput";
+import { ErrorText } from "../../shared/ui/ErrorText";
+import { FormField } from "../../shared/ui/FormField";
+import { Textarea } from "../../shared/ui/Textarea";
+import { RecurrenceFields } from "./RecurrenceFields";
+import { useCreateSeriesForm } from "./useCreateSeriesForm";
+
+export function CreateSeriesForm() {
+ const { form, submit, isSaving } = useCreateSeriesForm();
+ const { control, handleSubmit } = form;
+ const { errors } = form.formState;
+ const recurrence = form.watch("recurrence");
+ const startsAt = form.watch("starts_at");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {errors.root?.server?.message}
+
+ );
+}
+
+// Ошибка окончания правила лежит в дискриминированном union (`end.date`/`end.count`),
+// достаём сообщение по конкретному ключу варианта.
+function endMessage(error: unknown, key: "date" | "count"): string | undefined {
+ if (error == null || typeof error !== "object") return undefined;
+ if (key === "date" && "date" in error) return messageOf(error.date);
+ if (key === "count" && "count" in error) return messageOf(error.count);
+ return undefined;
+}
+
+function messageOf(field: unknown): string | undefined {
+ if (field != null && typeof field === "object" && "message" in field) {
+ const { message } = field;
+ if (typeof message === "string") return message;
+ }
+ return undefined;
+}
diff --git a/src/features/create-series/RecurrenceFields.tsx b/src/features/create-series/RecurrenceFields.tsx
new file mode 100644
index 0000000..d1430c3
--- /dev/null
+++ b/src/features/create-series/RecurrenceFields.tsx
@@ -0,0 +1,193 @@
+import { type Control, Controller } from "react-hook-form";
+import { Text, View } from "react-native";
+import type { CreateSeriesFormInput } from "../../entities/series/schemas";
+import type {
+ Recurrence,
+ RecurrenceEnd,
+ RecurrenceUnit,
+} from "../../entities/series/types";
+import { listOccurrences } from "../../entities/series/utils";
+import { DateTimeInput } from "../../shared/ui/DateTimeInput";
+import { FormField } from "../../shared/ui/FormField";
+import { Input } from "../../shared/ui/Input";
+import { MultiSelect } from "../../shared/ui/MultiSelect";
+import { Select } from "../../shared/ui/Select";
+
+type RecurrenceDraft = CreateSeriesFormInput["recurrence"];
+type EndDraft = RecurrenceDraft["end"];
+
+type Props = {
+ control: Control;
+ recurrence: RecurrenceDraft;
+ startsAt: string;
+ errors: {
+ interval?: string;
+ weekdays?: string;
+ endDate?: string;
+ endCount?: string;
+ };
+};
+
+const unitOptions: { value: RecurrenceUnit; label: string }[] = [
+ { value: "day", label: "день" },
+ { value: "week", label: "неделя" },
+ { value: "month", label: "месяц" },
+];
+
+const weekdayOptions = ["пн", "вт", "ср", "чт", "пт", "сб", "вс"].map(
+ (label, index) => ({ value: String(index), label }),
+);
+
+const endOptions: { value: RecurrenceEnd["kind"]; label: string }[] = [
+ { value: "never", label: "бессрочно" },
+ { value: "until", label: "до даты" },
+ { value: "count", label: "N встреч" },
+];
+
+export function RecurrenceFields({
+ control,
+ recurrence,
+ startsAt,
+ errors,
+}: Props) {
+ return (
+
+ Правило повторения
+
+ (
+
+ )}
+ />
+ (
+ field.onChange(values.map(Number))}
+ options={weekdayOptions}
+ error={errors.weekdays}
+ />
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+ );
+}
+
+type EndEditorProps = {
+ value: EndDraft;
+ onChange: (end: RecurrenceEnd) => void;
+ dateError?: string;
+ countError?: string;
+};
+
+function EndEditor({ value, onChange, dateError, countError }: EndEditorProps) {
+ return (
+
+
+ );
+}
+
+function SchedulePreview({
+ recurrence,
+ startsAt,
+}: {
+ recurrence: RecurrenceDraft;
+ startsAt: string;
+}) {
+ const dates = listOccurrences(toRecurrence(recurrence), startsAt);
+ if (dates.length === 0) {
+ return (
+ Ближайшие даты появятся здесь.
+ );
+ }
+
+ return (
+
+ Ближайшие встречи:
+ {dates.map((date) => (
+ {new Date(date).toLocaleString()}
+ ))}
+
+ );
+}
+
+function defaultEnd(kind: RecurrenceEnd["kind"]): RecurrenceEnd {
+ if (kind === "until") return { kind: "until", date: "" };
+ if (kind === "count") return { kind: "count", count: 1 };
+ return { kind: "never" };
+}
+
+// Черновик формы держит coerce-поля нестрого (interval/count как ввод) — нормализуем
+// к доменному правилу, чтобы посчитать превью дат.
+function toRecurrence(draft: RecurrenceDraft): Recurrence {
+ return {
+ interval: positiveInterval(draft.interval),
+ unit: draft.unit,
+ weekdays: draft.weekdays ?? [],
+ end: toEnd(draft.end),
+ };
+}
+
+// Нецифру, ноль и отрицательные значения превью считает шагом в 1, чтобы не
+// рисовать вырожденное расписание (реальную валидацию делает схема на сабмите).
+function positiveInterval(value: RecurrenceDraft["interval"]): number {
+ const parsed = Math.trunc(Number(value));
+ return parsed >= 1 ? parsed : 1;
+}
+
+function toEnd(end: EndDraft): RecurrenceEnd {
+ if (end.kind === "count") return { kind: "count", count: Number(end.count) };
+ return end;
+}
diff --git a/src/features/create-series/useCreateSeriesForm.ts b/src/features/create-series/useCreateSeriesForm.ts
new file mode 100644
index 0000000..fb5fd88
--- /dev/null
+++ b/src/features/create-series/useCreateSeriesForm.ts
@@ -0,0 +1,76 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useRouter } from "expo-router";
+import { useForm } from "react-hook-form";
+import { telegramToChatUrl } from "../../entities/activity/utils";
+import { useProfile } from "../../entities/profile/hooks";
+import { useCreateSeries, useTrackMySeries } from "../../entities/series/hooks";
+import { mapCreateSeriesFormToInput } from "../../entities/series/mappers";
+import {
+ type CreateSeriesForm,
+ type CreateSeriesFormInput,
+ createSeriesSchema,
+} from "../../entities/series/schemas";
+import type { Series } from "../../entities/series/types";
+import { setFormServerError } from "../../shared/lib/setFormServerError";
+
+const emptyForm: CreateSeriesFormInput = {
+ title: "",
+ description: "",
+ city: "",
+ location_text: "",
+ starts_at: "",
+ capacity: null,
+ cover_url: "",
+ chat_url: "",
+ recurrence: {
+ interval: 1,
+ unit: "week",
+ weekdays: [],
+ end: { kind: "never" },
+ },
+};
+
+export function useCreateSeriesForm() {
+ const router = useRouter();
+ const mutation = useCreateSeries();
+ const track = useTrackMySeries();
+ const { data: profile } = useProfile();
+ // Тир 1: чат обычно один на всю серию — дефолтим ссылку из Telegram профиля.
+ // Профиль грузится асинхронно (через `values`), keepDirtyValues не затирает ввод.
+ const prefill = profile
+ ? { ...emptyForm, chat_url: telegramToChatUrl(profile.socials.telegram) }
+ : undefined;
+ const form = useForm({
+ resolver: zodResolver(createSeriesSchema),
+ defaultValues: emptyForm,
+ values: prefill,
+ resetOptions: { keepDirtyValues: true },
+ });
+
+ async function submit(values: CreateSeriesForm) {
+ try {
+ form.clearErrors("root.server");
+ const series = await mutation.mutateAsync(
+ mapCreateSeriesFormToInput(values),
+ );
+ await rememberCreated(series);
+ router.replace(`/manage/series/${series.edit_token}`);
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось создать серию", error);
+ }
+ }
+
+ // Локальный список «Мои ивенты» не критичен: сбой записи не должен ронять
+ // успешное создание серии.
+ async function rememberCreated(series: Series) {
+ try {
+ await track.mutateAsync(series);
+ } catch {}
+ }
+
+ return {
+ form,
+ submit,
+ isSaving: mutation.isPending,
+ };
+}
diff --git a/src/features/edit-profile/EditProfileForm.tsx b/src/features/edit-profile/EditProfileForm.tsx
new file mode 100644
index 0000000..f437a64
--- /dev/null
+++ b/src/features/edit-profile/EditProfileForm.tsx
@@ -0,0 +1,81 @@
+import { Controller, type UseFormReturn } from "react-hook-form";
+import { Text, View } from "react-native";
+import type {
+ ProfileForm,
+ ProfileFormInput,
+} from "../../entities/profile/schemas";
+import { categoryOptions, socialOptions } from "../../entities/profile/utils";
+import { Button } from "../../shared/ui/Button";
+import { ErrorText } from "../../shared/ui/ErrorText";
+import { FormField } from "../../shared/ui/FormField";
+import { MultiSelect } from "../../shared/ui/MultiSelect";
+import { PhotoPicker } from "../../shared/ui/PhotoPicker";
+
+type Props = {
+ form: UseFormReturn;
+ isSaving: boolean;
+ onSubmit: (values: ProfileForm) => void;
+};
+
+export function EditProfileForm({ form, isSaving, onSubmit }: Props) {
+ const { control, handleSubmit } = form;
+ const { errors } = form.formState;
+
+ return (
+
+
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ Соцсети
+ {socialOptions.map(({ key, label }) => (
+
+ ))}
+
+ {errors.root?.server?.message}
+
+ );
+}
diff --git a/src/features/edit-profile/useEditProfileForm.ts b/src/features/edit-profile/useEditProfileForm.ts
new file mode 100644
index 0000000..227f46a
--- /dev/null
+++ b/src/features/edit-profile/useEditProfileForm.ts
@@ -0,0 +1,58 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useRouter } from "expo-router";
+import { useForm } from "react-hook-form";
+import { useProfile, useSaveProfile } from "../../entities/profile/hooks";
+import { mapProfileToForm } from "../../entities/profile/mappers";
+import {
+ type ProfileForm,
+ type ProfileFormInput,
+ profileSchema,
+} from "../../entities/profile/schemas";
+import { setFormServerError } from "../../shared/lib/setFormServerError";
+
+const emptyProfile: ProfileFormInput = {
+ fullName: "",
+ city: "",
+ categories: [],
+ photoUrl: "",
+ socials: {
+ telegram: "",
+ instagram: "",
+ tiktok: "",
+ whatsapp: "",
+ discord: "",
+ website: "",
+ },
+};
+
+export function useEditProfileForm() {
+ const router = useRouter();
+ const profileQuery = useProfile();
+ const profile = profileQuery.data;
+ const mutation = useSaveProfile();
+ const form = useForm({
+ resolver: zodResolver(profileSchema),
+ defaultValues: emptyProfile,
+ values: profile ? mapProfileToForm(profile) : undefined,
+ resetOptions: { keepDirtyValues: true },
+ });
+
+ // Провалидированная форма по структуре совпадает с payload сохранения —
+ // отдельный маппер не нужен, отдаём значения напрямую в хранилище.
+ async function submit(values: ProfileForm) {
+ try {
+ form.clearErrors("root.server");
+ await mutation.mutateAsync(values);
+ router.replace("/profile");
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось сохранить профиль", error);
+ }
+ }
+
+ return {
+ form,
+ submit,
+ isLoading: profileQuery.isLoading,
+ isSaving: mutation.isPending,
+ };
+}
diff --git a/src/features/join-series/JoinSeriesForm.tsx b/src/features/join-series/JoinSeriesForm.tsx
new file mode 100644
index 0000000..0c5e4de
--- /dev/null
+++ b/src/features/join-series/JoinSeriesForm.tsx
@@ -0,0 +1,41 @@
+import type { UseFormReturn } from "react-hook-form";
+import { Text, View } from "react-native";
+import type { JoinSeriesForm as JoinForm } from "../../entities/series/schemas";
+import { Button } from "../../shared/ui/Button";
+import { ErrorText } from "../../shared/ui/ErrorText";
+import { FormField } from "../../shared/ui/FormField";
+
+type Props = {
+ form: UseFormReturn;
+ isJoining: boolean;
+ onSubmit: (values: JoinForm) => void;
+};
+
+export function JoinSeriesForm({ form, isJoining, onSubmit }: Props) {
+ const { control, handleSubmit } = form;
+ const { errors } = form.formState;
+
+ return (
+
+ Хожу постоянно
+
+
+
+ {errors.root?.server?.message}
+
+ );
+}
diff --git a/src/features/join-series/useJoinSeries.ts b/src/features/join-series/useJoinSeries.ts
new file mode 100644
index 0000000..0f69a4b
--- /dev/null
+++ b/src/features/join-series/useJoinSeries.ts
@@ -0,0 +1,118 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import {
+ cancelReminder,
+ scheduleReminder,
+} from "../../entities/reminder/schedule";
+import {
+ useJoinSeries as useJoinSeriesMutation,
+ useLeaveSeries,
+} from "../../entities/series/hooks";
+import {
+ addMembership,
+ findMembership,
+ type Membership,
+ removeMembership,
+} from "../../entities/series/local";
+import { mapJoinFormToMemberInput } from "../../entities/series/mappers";
+import {
+ type JoinSeriesForm,
+ joinSeriesSchema,
+} from "../../entities/series/schemas";
+import type { Meeting, Series } from "../../entities/series/types";
+import { nextMeeting } from "../../entities/series/utils";
+import { setFormServerError } from "../../shared/lib/setFormServerError";
+
+const emptyForm: JoinSeriesForm = { name: "", telegram: "" };
+
+export function useJoinSeries(series?: Series, meetings: Meeting[] = []) {
+ const seriesId = series?.id ?? "";
+ const slug = series?.slug ?? "";
+ const joinMutation = useJoinSeriesMutation(seriesId);
+ const leaveMutation = useLeaveSeries(seriesId);
+ const { membership, setMembership } = useMembership(slug);
+ const form = useForm({
+ resolver: zodResolver(joinSeriesSchema),
+ defaultValues: emptyForm,
+ });
+
+ async function join(values: JoinSeriesForm) {
+ if (!series) return;
+
+ try {
+ form.clearErrors("root.server");
+ const member = await joinMutation.mutateAsync(
+ mapJoinFormToMemberInput(series.id, values),
+ );
+ const next = { seriesSlug: series.slug, memberId: member.id };
+ setMembership(next);
+ form.reset(emptyForm);
+ // Запись на сервере уже прошла — сбой device-local хранилища не показываем
+ // как ошибку записи, иначе ретрай создаст дубль участника.
+ await addMembership(next).catch(() => {});
+ // Напоминание некритично: сбой не роняет успешную запись в серию.
+ await planReminder(series, meetings).catch(() => {});
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось записаться в серию", error);
+ }
+ }
+
+ async function leave() {
+ if (!membership) return;
+
+ try {
+ form.clearErrors("root.server");
+ await leaveMutation.mutateAsync(membership.memberId);
+ await removeMembership(membership.seriesSlug);
+ await cancelReminder(membership.seriesSlug).catch(() => {});
+ setMembership(null);
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось выйти из серии", error);
+ }
+ }
+
+ return {
+ membership,
+ form,
+ join,
+ leave,
+ isJoining: joinMutation.isPending,
+ isLeaving: leaveMutation.isPending,
+ };
+}
+
+// Напоминание серии — на ближайшую будущую встречу. Нет встреч впереди — молча
+// пропускаем (запланировать нечего).
+async function planReminder(series: Series, meetings: Meeting[]) {
+ const meeting = nextMeeting(meetings);
+ if (meeting == null) return;
+
+ await scheduleReminder({
+ slug: series.slug,
+ title: series.title,
+ startsAt: meeting.starts_at,
+ });
+}
+
+// «Ты ходишь постоянно» — это наличие device-local записи по slug серии.
+function useMembership(slug: string) {
+ const [membership, setMembership] = useState(null);
+
+ useEffect(() => {
+ if (slug === "") return;
+ let active = true;
+ findMembership(slug)
+ .then((found) => {
+ if (active) setMembership(found);
+ })
+ .catch(() => {
+ if (active) setMembership(null);
+ });
+ return () => {
+ active = false;
+ };
+ }, [slug]);
+
+ return { membership, setMembership };
+}
diff --git a/src/features/like-activity/useLikeActivity.ts b/src/features/like-activity/useLikeActivity.ts
new file mode 100644
index 0000000..e81eda8
--- /dev/null
+++ b/src/features/like-activity/useLikeActivity.ts
@@ -0,0 +1,41 @@
+import { useEffect, useState } from "react";
+import { readLikedSlugs, toggleLike } from "../../entities/activity/local";
+
+// Device-local лайк активности по slug. Серверной агрегации нет — храним только
+// состояние «лайкнуто/нет» на устройстве (по образцу useRespondedFlag).
+export function useLikeActivity(slug?: string) {
+ const [isLiked, setIsLiked] = useState(false);
+ const [isToggling, setIsToggling] = useState(false);
+
+ useEffect(() => {
+ if (slug == null) return;
+ let active = true;
+ readLikedSlugs()
+ .then((slugs) => {
+ if (active) setIsLiked(slugs.includes(slug));
+ })
+ // Лайк не критичен: ошибка чтения = считаем, что лайка не было.
+ .catch(() => {
+ if (active) setIsLiked(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [slug]);
+
+ async function toggle() {
+ if (slug == null || isToggling) return;
+ setIsToggling(true);
+ setIsLiked((liked) => !liked);
+ try {
+ await toggleLike(slug);
+ } catch {
+ // Запись не удалась — откатываем оптимистичное состояние к хранилищу.
+ setIsLiked((liked) => !liked);
+ } finally {
+ setIsToggling(false);
+ }
+ }
+
+ return { isLiked, toggle, isToggling };
+}
diff --git a/src/features/manage-activity/ManageActivityForm.tsx b/src/features/manage-activity/ManageActivityForm.tsx
index fd6178b..e81cbe6 100644
--- a/src/features/manage-activity/ManageActivityForm.tsx
+++ b/src/features/manage-activity/ManageActivityForm.tsx
@@ -78,6 +78,12 @@ export function ManageActivityForm({
label="ссылка на обложку"
error={errors.cover_url?.message}
/>
+
;
+ recurrenceText: string;
+ isSaving: boolean;
+ onSubmit: (values: UpdateSeriesForm) => void;
+ onFinish: () => void;
+};
+
+const statusOptions: { value: SeriesStatus; label: string }[] = [
+ { value: "active", label: "активна" },
+ { value: "finished", label: "завершена" },
+];
+
+export function ManageSeriesForm({
+ form,
+ recurrenceText,
+ isSaving,
+ onSubmit,
+ onFinish,
+}: Props) {
+ const { control, handleSubmit } = form;
+ const { errors } = form.formState;
+
+ return (
+
+ Шаблон серии
+
+ Изменения применятся ко всем будущим встречам.
+
+ Правило: {recurrenceText}
+
+
+
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+ {errors.root?.server?.message}
+
+ );
+}
diff --git a/src/features/manage-series/useManageSeriesForm.ts b/src/features/manage-series/useManageSeriesForm.ts
new file mode 100644
index 0000000..206f608
--- /dev/null
+++ b/src/features/manage-series/useManageSeriesForm.ts
@@ -0,0 +1,119 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useLocalSearchParams } from "expo-router";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import {
+ useCancelMeeting,
+ useEnsureUpcomingMeetings,
+ useMeetings,
+ useMembers,
+ useRescheduleMeeting,
+ useSeriesByEditToken,
+ useUpdateSeries,
+} from "../../entities/series/hooks";
+import {
+ mapSeriesToUpdateForm,
+ mapUpdateSeriesFormToInput,
+} from "../../entities/series/mappers";
+import {
+ type UpdateSeriesForm,
+ type UpdateSeriesFormInput,
+ updateSeriesSchema,
+} from "../../entities/series/schemas";
+import { toIsoDate } from "../../shared/lib/datetime";
+import { buildSeriesPublicUrl } from "../../shared/lib/publicUrl";
+import { setFormServerError } from "../../shared/lib/setFormServerError";
+
+export function useManageSeriesForm() {
+ const { editToken } = useLocalSearchParams<{ editToken: string }>();
+ const seriesQuery = useSeriesByEditToken(editToken);
+ const series = seriesQuery.data;
+ const seriesId = series?.id ?? "";
+ const meetingsQuery = useMeetings(series?.id);
+ const membersQuery = useMembers(series?.id);
+ const updateMutation = useUpdateSeries();
+ const rescheduleMutation = useRescheduleMeeting(seriesId);
+ const cancelMutation = useCancelMeeting(seriesId);
+ const { mutate: ensureMeetings } = useEnsureUpcomingMeetings();
+ const form = useForm({
+ resolver: zodResolver(updateSeriesSchema),
+ values: series ? mapSeriesToUpdateForm(series) : undefined,
+ resetOptions: { keepDirtyValues: true },
+ });
+
+ // Слоты создаются лениво при открытии управления: досоздаём недостающие
+ // встречи до горизонта, повторный прогон ничего не дублирует.
+ useEffect(() => {
+ if (series) ensureMeetings(series);
+ }, [series, ensureMeetings]);
+
+ const publicUrl = series ? buildSeriesPublicUrl(series.slug) : "";
+
+ async function submit(values: UpdateSeriesForm) {
+ if (!series) return;
+
+ try {
+ form.clearErrors("root.server");
+ await updateMutation.mutateAsync({
+ id: series.id,
+ input: mapUpdateSeriesFormToInput(values),
+ });
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось сохранить серию", error);
+ }
+ }
+
+ async function rescheduleMeeting(id: string, startsAt: string) {
+ try {
+ form.clearErrors("root.server");
+ await rescheduleMutation.mutateAsync({
+ id,
+ startsAt: toIsoDate(startsAt),
+ });
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось перенести встречу", error);
+ }
+ }
+
+ async function cancelMeeting(id: string) {
+ try {
+ form.clearErrors("root.server");
+ await cancelMutation.mutateAsync(id);
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось отменить встречу", error);
+ }
+ }
+
+ async function finishSeries() {
+ if (!series) return;
+
+ try {
+ form.clearErrors("root.server");
+ await updateMutation.mutateAsync({
+ id: series.id,
+ input: { status: "finished" },
+ });
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось завершить серию", error);
+ }
+ }
+
+ return {
+ series,
+ isLoading: seriesQuery.isLoading,
+ loadError: seriesQuery.error,
+ meetings: meetingsQuery.data ?? [],
+ meetingsLoading: meetingsQuery.isLoading,
+ members: membersQuery.data ?? [],
+ membersLoading: membersQuery.isLoading,
+ publicUrl,
+ form,
+ submit,
+ rescheduleMeeting,
+ cancelMeeting,
+ finishSeries,
+ isSaving: updateMutation.isPending,
+ isRescheduling: rescheduleMutation.isPending,
+ isCancelling: cancelMutation.isPending,
+ };
+}
diff --git a/src/features/mark-meeting/useMarkMeeting.ts b/src/features/mark-meeting/useMarkMeeting.ts
new file mode 100644
index 0000000..7a217cf
--- /dev/null
+++ b/src/features/mark-meeting/useMarkMeeting.ts
@@ -0,0 +1,61 @@
+import { readReminders } from "../../entities/reminder/local";
+import {
+ cancelReminder,
+ scheduleReminder,
+} from "../../entities/reminder/schedule";
+import { useUpsertMark } from "../../entities/series/hooks";
+import type { MarkStatus, Meeting, Series } from "../../entities/series/types";
+import { nextMeeting } from "../../entities/series/utils";
+
+type Params = {
+ memberId?: string;
+ series?: Series;
+ meetings: Meeting[];
+};
+
+// Тогл отметки постоянного участника по конкретной встрече. Дефолт — «иду»
+// (участник идёт на все), поэтому отсутствие отметки трактуем как «иду».
+export function useMarkMeeting({ memberId, series, meetings }: Params) {
+ const mutation = useUpsertMark();
+
+ async function toggle(meetingId: string, current?: MarkStatus) {
+ if (memberId == null) return;
+
+ const status = nextStatus(current);
+ await mutation.mutateAsync({
+ meeting_id: meetingId,
+ member_id: memberId,
+ status,
+ });
+ if (status === "skip")
+ await moveReminderPastSkip(meetingId).catch(() => {});
+ }
+
+ // Пропуск встречи с напоминанием переносит его на следующую встречу. Пропущена
+ // не та встреча, что напоминаем, — ничего не делаем.
+ async function moveReminderPastSkip(meetingId: string) {
+ if (series == null) return;
+ const skipped = meetings.find((meeting) => meeting.id === meetingId);
+ if (skipped == null) return;
+
+ const reminders = await readReminders();
+ const reminder = reminders.find((item) => item.slug === series.slug);
+ if (reminder == null || reminder.startsAt !== skipped.starts_at) return;
+
+ await cancelReminder(series.slug);
+ const next = nextMeeting(meetings, skipped.starts_at);
+ if (next == null) return;
+
+ await scheduleReminder({
+ slug: series.slug,
+ title: series.title,
+ startsAt: next.starts_at,
+ });
+ }
+
+ return { toggle, isMarking: mutation.isPending };
+}
+
+function nextStatus(current?: MarkStatus): MarkStatus {
+ return current === "skip" ? "going" : "skip";
+}
diff --git a/src/features/respond-to-activity/ResponseForm.tsx b/src/features/respond-to-activity/ResponseForm.tsx
index f029743..a0fb1c5 100644
--- a/src/features/respond-to-activity/ResponseForm.tsx
+++ b/src/features/respond-to-activity/ResponseForm.tsx
@@ -1,12 +1,9 @@
-import { Controller, type UseFormReturn } from "react-hook-form";
+import type { UseFormReturn } from "react-hook-form";
import { Text, View } from "react-native";
import type { ParticipantForm } from "../../entities/participant/schemas";
-import { participantStatusOptions } from "../../entities/participant/utils";
import { Button } from "../../shared/ui/Button";
import { ErrorText } from "../../shared/ui/ErrorText";
import { FormField } from "../../shared/ui/FormField";
-import { Select } from "../../shared/ui/Select";
-import { Textarea } from "../../shared/ui/Textarea";
type Props = {
form: UseFormReturn;
@@ -33,26 +30,6 @@ export function ResponseForm({ form, isSaving, onSubmit }: Props) {
label="телеграм"
error={errors.telegram?.message}
/>
- (
-
- )}
- />
-
({
resolver: zodResolver(participantSchema),
defaultValues: emptyResponse,
});
const { hasResponded, markResponded } = useRespondedFlag(activity?.id);
+ const [reminderScheduled, setReminderScheduled] = useState(false);
const participants = participantsQuery.data ?? [];
const stats = getParticipantStats(participants);
+ useEffect(() => {
+ if (activity?.status !== "cancelled") return;
+ cancelReminder(activity.slug).catch(() => {});
+ }, [activity?.status, activity?.slug]);
+
async function submit(values: ParticipantForm) {
if (!activity) return;
@@ -48,11 +61,42 @@ export function useRespondToActivityForm() {
);
form.reset(emptyResponse);
await markResponded();
+ await rememberResponse(activity, values.status);
+ setReminderScheduled(await planReminder(activity));
} catch (error) {
setFormServerError(form.setError, "Не удалось отправить отклик", error);
}
}
+ async function planReminder(activity: Activity) {
+ try {
+ return await scheduleReminder({
+ slug: activity.slug,
+ title: activity.title,
+ startsAt: activity.starts_at,
+ });
+ } catch {
+ return false;
+ }
+ }
+
+ // Локальный список «Посещено» не критичен: сбой записи не должен ронять
+ // успешный отклик.
+ async function rememberResponse(
+ activity: Activity,
+ status: ParticipantForm["status"],
+ ) {
+ try {
+ await track.mutateAsync({
+ slug: activity.slug,
+ title: activity.title,
+ city: activity.city,
+ startsAt: activity.starts_at,
+ status,
+ });
+ } catch {}
+ }
+
return {
activity,
isLoading: activityQuery.isLoading,
@@ -64,5 +108,6 @@ export function useRespondToActivityForm() {
submit,
isSaving: mutation.isPending,
hasResponded,
+ reminderScheduled,
};
}
diff --git a/src/shared/lib/datetime.test.ts b/src/shared/lib/datetime.test.ts
new file mode 100644
index 0000000..b1bd59e
--- /dev/null
+++ b/src/shared/lib/datetime.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import {
+ isValidDateTime,
+ toDateTimeLocalInputValue,
+ toIsoDate,
+} from "./datetime";
+
+describe("shared/datetime", () => {
+ it("проверяет корректность даты", () => {
+ expect(isValidDateTime("2026-05-30T12:30")).toBe(true);
+ expect(isValidDateTime("12 мая")).toBe(false);
+ expect(isValidDateTime("")).toBe(false);
+ });
+
+ it("преобразует дату в ISO, если дата корректна", () => {
+ expect(toIsoDate("2026-05-30T12:30")).toBe(
+ new Date("2026-05-30T12:30").toISOString(),
+ );
+ expect(toIsoDate("12 мая")).toBe("12 мая");
+ });
+
+ it("преобразует дату с бэка в значение для datetime-local", () => {
+ const value = "2006-02-01T06:22:00+00:00";
+ const date = new Date(value);
+ const pad = (part: number) => String(part).padStart(2, "0");
+ const expected = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
+ date.getDate(),
+ )}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+
+ expect(toDateTimeLocalInputValue(value)).toBe(expected);
+ expect(toDateTimeLocalInputValue("не дата")).toBe("не дата");
+ });
+});
diff --git a/src/shared/lib/datetime.ts b/src/shared/lib/datetime.ts
new file mode 100644
index 0000000..ba0686f
--- /dev/null
+++ b/src/shared/lib/datetime.ts
@@ -0,0 +1,25 @@
+export function isValidDateTime(value: string) {
+ return parseDate(value) != null;
+}
+
+export function toIsoDate(value: string) {
+ return parseDate(value)?.toISOString() ?? value;
+}
+
+export function toDateTimeLocalInputValue(value: string) {
+ const date = parseDate(value);
+ if (!date) return value;
+
+ return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(
+ date.getDate(),
+ )}T${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`;
+}
+
+function parseDate(value: string) {
+ const date = new Date(value.trim());
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function padDatePart(value: number) {
+ return String(value).padStart(2, "0");
+}
diff --git a/src/shared/lib/notifications.ts b/src/shared/lib/notifications.ts
new file mode 100644
index 0000000..b8d407e
--- /dev/null
+++ b/src/shared/lib/notifications.ts
@@ -0,0 +1,56 @@
+import * as Notifications from "expo-notifications";
+import { Platform } from "react-native";
+
+// Техническая обёртка над expo-notifications без домена: разрешение,
+// планирование по дате, отмена. На web уведомлений нет — везде тихий no-op.
+
+const reminderChannelId = "reminders";
+
+export async function ensureNotificationPermission() {
+ if (Platform.OS === "web") return false;
+ await ensureAndroidChannel();
+
+ return await requestPermissionIfNeeded();
+}
+
+// Планирует уведомление на конкретный момент. Возврат null — «не запланировано»
+// (web или момент уже в прошлом). Разрешение вызывающий проверяет отдельно.
+export async function scheduleNotificationAt(
+ date: Date,
+ title: string,
+ body: string,
+): Promise {
+ if (Platform.OS === "web") return null;
+ if (date.getTime() <= Date.now()) return null;
+
+ return await Notifications.scheduleNotificationAsync({
+ content: { title, body },
+ trigger: {
+ type: Notifications.SchedulableTriggerInputTypes.DATE,
+ date,
+ channelId: reminderChannelId,
+ },
+ });
+}
+
+export async function cancelNotification(id: string) {
+ if (Platform.OS === "web") return;
+ await Notifications.cancelScheduledNotificationAsync(id);
+}
+
+// Android 13+ показывает запрос разрешения только после создания канала.
+async function ensureAndroidChannel() {
+ if (Platform.OS !== "android") return;
+ await Notifications.setNotificationChannelAsync(reminderChannelId, {
+ name: "Напоминания",
+ importance: Notifications.AndroidImportance.DEFAULT,
+ });
+}
+
+async function requestPermissionIfNeeded() {
+ const { status } = await Notifications.getPermissionsAsync();
+ if (status === Notifications.PermissionStatus.GRANTED) return true;
+
+ const requested = await Notifications.requestPermissionsAsync();
+ return requested.status === Notifications.PermissionStatus.GRANTED;
+}
diff --git a/src/shared/lib/publicLink.test.ts b/src/shared/lib/publicLink.test.ts
new file mode 100644
index 0000000..7ad59ee
--- /dev/null
+++ b/src/shared/lib/publicLink.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("expo-crypto", () => ({
+ randomUUID: () => "123e4567-e89b-12d3-a456-426614174000",
+}));
+
+import { generateEditToken, generateSlug, slugifyTitle } from "./publicLink";
+
+describe("shared/publicLink", () => {
+ it("преобразует название в slug", () => {
+ expect(slugifyTitle(" My Cool Activity! ")).toBe("my-cool-activity");
+ expect(slugifyTitle(" Кириллица 123! ")).toBe("кириллица-123");
+ expect(slugifyTitle("!!!")).toBe("activity");
+ expect(slugifyTitle("!!!", "series")).toBe("series");
+ });
+
+ it("генерирует slug с коротким случайным суффиксом", () => {
+ expect(generateSlug("My Activity")).toMatch(/^my-activity-[a-z0-9]{6}$/);
+ });
+
+ it("генерирует edit token из UUID без дефисов", () => {
+ expect(generateEditToken()).toMatch(/^[a-f0-9]{32}$/);
+ });
+});
diff --git a/src/shared/lib/publicLink.ts b/src/shared/lib/publicLink.ts
new file mode 100644
index 0000000..94501ca
--- /dev/null
+++ b/src/shared/lib/publicLink.ts
@@ -0,0 +1,26 @@
+import * as Crypto from "expo-crypto";
+
+const nonAlphanumericRun = /[^\p{L}\p{N}]+/gu;
+const leadingTrailingHyphens = /^-+|-+$/g;
+
+export function slugifyTitle(title: string, fallback = "activity") {
+ return (
+ title
+ .toLowerCase()
+ .trim()
+ .replace(nonAlphanumericRun, "-")
+ .replace(leadingTrailingHyphens, "") || fallback
+ );
+}
+
+export function generateSlug(title: string, fallback = "activity") {
+ return `${slugifyTitle(title, fallback)}-${shortRandom()}`;
+}
+
+export function generateEditToken() {
+ return Crypto.randomUUID().replaceAll("-", "");
+}
+
+function shortRandom() {
+ return Math.random().toString(36).slice(2, 8);
+}
diff --git a/src/shared/lib/buildActivityPublicUrl.ts b/src/shared/lib/publicUrl.ts
similarity index 55%
rename from src/shared/lib/buildActivityPublicUrl.ts
rename to src/shared/lib/publicUrl.ts
index e8ade23..a082d3f 100644
--- a/src/shared/lib/buildActivityPublicUrl.ts
+++ b/src/shared/lib/publicUrl.ts
@@ -1,7 +1,14 @@
import { Platform } from "react-native";
export function buildActivityPublicUrl(slug: string) {
- const path = `/a/${slug}`;
+ return buildPublicUrl(`/a/${slug}`);
+}
+
+export function buildSeriesPublicUrl(slug: string) {
+ return buildPublicUrl(`/s/${slug}`);
+}
+
+function buildPublicUrl(path: string) {
if (Platform.OS !== "web" || typeof window === "undefined") return path;
return `${window.location.origin}${path}`;
diff --git a/src/shared/lib/shareActivity.test.ts b/src/shared/lib/shareActivity.test.ts
new file mode 100644
index 0000000..889831e
--- /dev/null
+++ b/src/shared/lib/shareActivity.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it, vi } from "vitest";
+
+// Системный лист шеринга подменяем: проверяем ветвление результата на нативе.
+const { share } = vi.hoisted(() => ({ share: vi.fn() }));
+
+vi.mock("react-native", () => ({
+ Platform: { OS: "ios" },
+ Share: { share },
+}));
+
+import { shareActivity } from "./shareActivity";
+
+const params = { slug: "party", title: "Вечеринка" };
+
+describe("shareActivity на нативе", () => {
+ it("успешный шеринг возвращает kind «shared»", async () => {
+ share.mockImplementation(async () => ({ action: "sharedAction" }));
+
+ expect(await shareActivity(params)).toEqual({ kind: "shared" });
+ });
+
+ it("отмену листа (AbortError) не считает ошибкой", async () => {
+ share.mockImplementation(async () => {
+ const abort = new Error("dismissed");
+ abort.name = "AbortError";
+ throw abort;
+ });
+
+ expect(await shareActivity(params)).toEqual({ kind: "shared" });
+ });
+
+ it("прочую ошибку отдаёт с русским сообщением", async () => {
+ share.mockImplementation(async () => {
+ throw new Error("boom");
+ });
+
+ const result = await shareActivity(params);
+
+ expect(result).toEqual({
+ kind: "error",
+ message: "Не удалось поделиться ссылкой",
+ });
+ });
+});
diff --git a/src/shared/lib/shareActivity.ts b/src/shared/lib/shareActivity.ts
new file mode 100644
index 0000000..30de17b
--- /dev/null
+++ b/src/shared/lib/shareActivity.ts
@@ -0,0 +1,75 @@
+import { Platform, Share } from "react-native";
+import { getRussianErrorMessage } from "../errors/getRussianErrorMessage";
+import { buildActivityPublicUrl } from "./publicUrl";
+
+// Результат шеринга для UI: открыли системный лист, скопировали в буфер
+// (веб-фолбэк) или ошибка с уже русским сообщением.
+export type ShareResult =
+ | { kind: "shared" }
+ | { kind: "copied" }
+ | { kind: "error"; message: string };
+
+type Params = {
+ slug: string;
+ title: string;
+};
+
+export async function shareActivity({
+ slug,
+ title,
+}: Params): Promise {
+ const url = buildActivityPublicUrl(slug);
+ if (shouldCopyInstead()) return copyLink(url);
+
+ return openShareSheet({ title, url });
+}
+
+// На вебе без системного шеринга падать нельзя — копируем ссылку в буфер.
+function shouldCopyInstead() {
+ if (Platform.OS !== "web") return false;
+ return typeof navigator === "undefined" || navigator.share === undefined;
+}
+
+async function openShareSheet({
+ title,
+ url,
+}: {
+ title: string;
+ url: string;
+}): Promise {
+ try {
+ await Share.share({ title, message: url, url });
+ return { kind: "shared" };
+ } catch (error) {
+ if (isDismiss(error)) return { kind: "shared" };
+
+ return {
+ kind: "error",
+ message: getRussianErrorMessage(error, {
+ fallback: "Не удалось поделиться ссылкой",
+ }),
+ };
+ }
+}
+
+async function copyLink(url: string): Promise {
+ if (typeof navigator === "undefined" || navigator.clipboard === undefined) {
+ return { kind: "error", message: "Копирование недоступно в этом браузере" };
+ }
+ try {
+ await navigator.clipboard.writeText(url);
+ return { kind: "copied" };
+ } catch (error) {
+ return {
+ kind: "error",
+ message: getRussianErrorMessage(error, {
+ fallback: "Не удалось скопировать ссылку",
+ }),
+ };
+ }
+}
+
+// Отмена системного листа на вебе — это AbortError, не ошибка для пользователя.
+function isDismiss(error: unknown) {
+ return error instanceof Error && error.name === "AbortError";
+}
diff --git a/src/shared/lib/storage.ts b/src/shared/lib/storage.ts
index 3938a4f..00b61d7 100644
--- a/src/shared/lib/storage.ts
+++ b/src/shared/lib/storage.ts
@@ -10,3 +10,46 @@ export async function readDeviceFlag(key: string) {
export async function writeDeviceFlag(key: string) {
await AsyncStorage.setItem(key, "1");
}
+
+// Произвольное JSON-значение устройства (поверх булевых флагов).
+// Битый JSON трактуем как «значения нет», не роняем чтение.
+export async function readDeviceJson(key: string): Promise {
+ const raw = await AsyncStorage.getItem(key);
+ if (raw == null) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+export async function writeDeviceJson(key: string, value: T) {
+ await AsyncStorage.setItem(key, JSON.stringify(value));
+}
+
+// Read-modify-write по ключу, сериализованный в очередь: параллельные апдейты
+// не затирают друг друга, а выполняются по одному. Возвращает новое значение.
+export async function updateDeviceJson(
+ key: string,
+ update: (current: T | null) => T,
+): Promise {
+ const run = async () => {
+ const next = update(await readDeviceJson(key));
+ await writeDeviceJson(key, next);
+ return next;
+ };
+ const result = (writeQueues.get(key) ?? Promise.resolve())
+ .catch(() => {})
+ .then(run);
+ writeQueues.set(
+ key,
+ result.catch(() => {}),
+ );
+ return result;
+}
+
+const writeQueues = new Map>();
+
+export async function removeDeviceValue(key: string) {
+ await AsyncStorage.removeItem(key);
+}
diff --git a/src/shared/lib/zodFields.ts b/src/shared/lib/zodFields.ts
new file mode 100644
index 0000000..c03c486
--- /dev/null
+++ b/src/shared/lib/zodFields.ts
@@ -0,0 +1,51 @@
+import { z } from "zod";
+
+const emptyToNull = (v: unknown) => (v === "" || v === undefined ? null : v);
+
+export const optionalText = z.preprocess(emptyToNull, z.string().nullable());
+
+export const capacity = z.preprocess(
+ emptyToNull,
+ z.coerce.number().int().positive().nullable(),
+);
+
+export const optionalUrl = z.preprocess(
+ emptyToNull,
+ z.string().url("Введите корректную ссылку").nullable(),
+);
+
+const chatHosts = [
+ "t.me",
+ "telegram.me",
+ "telegram.dog",
+ "wa.me",
+ "chat.whatsapp.com",
+ "whatsapp.com",
+ "discord.gg",
+ "discord.com",
+ "discordapp.com",
+ "signal.group",
+ "signal.me",
+];
+
+export const chatUrl = z.preprocess(
+ emptyToNull,
+ z
+ .string()
+ .url("Введите корректную ссылку")
+ .refine(
+ isKnownChatHost,
+ "Ссылка должна вести в Telegram, WhatsApp, Discord или Signal",
+ )
+ .nullable(),
+);
+
+function isKnownChatHost(value: string) {
+ let host: string;
+ try {
+ host = new URL(value).hostname.replace(/^www\./, "").toLowerCase();
+ } catch {
+ return false;
+ }
+ return chatHosts.includes(host);
+}
diff --git a/src/shared/ui/Avatar.tsx b/src/shared/ui/Avatar.tsx
new file mode 100644
index 0000000..ffe45e1
--- /dev/null
+++ b/src/shared/ui/Avatar.tsx
@@ -0,0 +1,27 @@
+import { Image, Text, View } from "react-native";
+
+type Props = {
+ photoUrl: string | null;
+ name: string;
+};
+
+export function Avatar({ photoUrl, name }: Props) {
+ if (photoUrl != null) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {firstLetter(name)}
+
+ );
+}
+
+function firstLetter(name: string) {
+ return name.trim().charAt(0).toUpperCase() || "?";
+}
diff --git a/src/shared/ui/ChatLinkButton.tsx b/src/shared/ui/ChatLinkButton.tsx
new file mode 100644
index 0000000..4dddc3b
--- /dev/null
+++ b/src/shared/ui/ChatLinkButton.tsx
@@ -0,0 +1,14 @@
+import { Linking } from "react-native";
+import { Button } from "./Button";
+
+type Props = {
+ url: string;
+};
+
+export function ChatLinkButton({ url }: Props) {
+ function openChat() {
+ Linking.openURL(url).catch(() => {});
+ }
+
+ return ;
+}
diff --git a/src/shared/ui/MultiSelect.tsx b/src/shared/ui/MultiSelect.tsx
new file mode 100644
index 0000000..505a3e1
--- /dev/null
+++ b/src/shared/ui/MultiSelect.tsx
@@ -0,0 +1,45 @@
+import type { ReactNode } from "react";
+import { View } from "react-native";
+import { Button } from "./Button";
+import { FieldShell } from "./FieldShell";
+
+type Option = {
+ value: Value;
+ label: string;
+};
+
+type Props = {
+ label: ReactNode;
+ values: Value[];
+ onChange: (values: Value[]) => void;
+ options: Option[];
+ error?: string;
+};
+
+export function MultiSelect({
+ label,
+ values,
+ onChange,
+ options,
+ error,
+}: Props) {
+ return (
+
+
+ {options.map((option) => (
+ onChange(toggle(values, option.value))}
+ />
+ ))}
+
+
+ );
+}
+
+function toggle(values: Value[], value: Value) {
+ return values.includes(value)
+ ? values.filter((item) => item !== value)
+ : [...values, value];
+}
diff --git a/src/shared/ui/ParticipantList.tsx b/src/shared/ui/ParticipantList.tsx
index e986f66..c158b60 100644
--- a/src/shared/ui/ParticipantList.tsx
+++ b/src/shared/ui/ParticipantList.tsx
@@ -6,7 +6,6 @@ type ParticipantRow = {
name: string;
status: string;
telegram: string | null;
- comment: string | null;
};
type Props = {
@@ -24,7 +23,7 @@ export function ParticipantList({ participants, loading, statusText }: Props) {
{participants.map((participant) => (
- {participant.name} / {statusText(participant.status)} /{" "}
- {participant.telegram || ""} / {participant.comment || ""}
+ {participant.telegram || ""}
))}
>
diff --git a/src/shared/ui/PhotoPicker.tsx b/src/shared/ui/PhotoPicker.tsx
new file mode 100644
index 0000000..78d9c1e
--- /dev/null
+++ b/src/shared/ui/PhotoPicker.tsx
@@ -0,0 +1,43 @@
+import * as ImagePicker from "expo-image-picker";
+import type { ReactNode } from "react";
+import { Image, Text, View } from "react-native";
+import { Button } from "./Button";
+import { FieldShell } from "./FieldShell";
+
+type Props = {
+ label: ReactNode;
+ value: string;
+ onChange: (uri: string) => void;
+ error?: string;
+};
+
+export function PhotoPicker({ label, value, onChange, error }: Props) {
+ return (
+
+ {value ? (
+
+ ) : (
+ Фото не выбрано
+ )}
+
+ pickPhoto(onChange)} />
+ {value ? onChange("")} /> : null}
+
+
+ );
+}
+
+// Открывает галерею и кладёт локальный URI выбранного фото. Отмену и сбой
+// (отказ в доступе) проглатываем: для профиля фото необязательно.
+async function pickPhoto(onChange: (uri: string) => void) {
+ try {
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ["images"],
+ quality: 1,
+ });
+ if (!result.canceled) onChange(result.assets[0].uri);
+ } catch {}
+}
diff --git a/src/shared/ui/ShareButton.tsx b/src/shared/ui/ShareButton.tsx
new file mode 100644
index 0000000..d65b414
--- /dev/null
+++ b/src/shared/ui/ShareButton.tsx
@@ -0,0 +1,39 @@
+import { useState } from "react";
+import { Text, View } from "react-native";
+import { type ShareResult, shareActivity } from "../lib/shareActivity";
+import { Button } from "./Button";
+
+type Props = {
+ slug: string;
+ title: string;
+};
+
+type Notice = { text: string; isError: boolean };
+
+export function ShareButton({ slug, title }: Props) {
+ const [notice, setNotice] = useState(null);
+
+ async function share() {
+ const result = await shareActivity({ slug, title });
+ setNotice(noticeFor(result));
+ }
+
+ return (
+
+
+ {notice ? (
+
+ {notice.text}
+
+ ) : null}
+
+ );
+}
+
+function noticeFor(result: ShareResult): Notice | null {
+ if (result.kind === "copied")
+ return { text: "Ссылка скопирована", isError: false };
+ if (result.kind === "error") return { text: result.message, isError: true };
+
+ return null;
+}