diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs
new file mode 100644
index 0000000..e471450
--- /dev/null
+++ b/.dependency-cruiser.cjs
@@ -0,0 +1,55 @@
+/**
+ * Проверка направления зависимостей по слоям FSD (core.md §2, §5).
+ * Цепочка: app → features → entities → shared. Импорт только вправо.
+ * Между slice'ами одного слоя прямые зависимости запрещены.
+ */
+module.exports = {
+ forbidden: [
+ {
+ name: "shared-не-импортит-выше",
+ comment:
+ "shared — крайний правый слой, не зависит от app/features/entities (core.md §2)",
+ severity: "error",
+ from: { path: "^src/shared" },
+ to: { path: "^(app|src/(features|entities))" },
+ },
+ {
+ name: "entities-не-импортит-выше",
+ comment: "entities зависит только от shared (core.md §2)",
+ severity: "error",
+ from: { path: "^src/entities" },
+ to: { path: "^(app|src/features)" },
+ },
+ {
+ name: "features-не-импортит-app",
+ comment: "features зависит от entities/shared, но не от app (core.md §2)",
+ severity: "error",
+ from: { path: "^src/features" },
+ to: { path: "^app" },
+ },
+ {
+ name: "entities-без-кросс-slice",
+ comment:
+ "slice в entities не импортит соседний slice — склейка слоем выше (core.md §5)",
+ severity: "error",
+ from: { path: "^src/entities/([^/]+)/" },
+ to: { path: "^src/entities/([^/]+)/", pathNot: "^src/entities/$1/" },
+ },
+ {
+ name: "features-без-кросс-slice",
+ comment:
+ "slice в features не импортит соседний slice — склейка слоем выше (core.md §5)",
+ severity: "error",
+ from: { path: "^src/features/([^/]+)/" },
+ to: { path: "^src/features/([^/]+)/", pathNot: "^src/features/$1/" },
+ },
+ ],
+ options: {
+ doNotFollow: { path: "node_modules" },
+ tsConfig: { fileName: "tsconfig.json" },
+ tsPreCompilationDeps: true,
+ enhancedResolveOptions: {
+ extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
+ },
+ },
+};
diff --git a/.env.example b/.env.example
index c900474..efed8b1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,4 @@
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_KEY=
+
+# COPY INTO .env.local
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
new file mode 100644
index 0000000..7ca05f4
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,28 @@
+name: quality
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ quality:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Забрать код
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+
+ - name: Настроить Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Установить зависимости
+ run: npm ci
+
+ - name: Проверить Biome и TypeScript
+ run: npm run ci
diff --git a/.gitignore b/.gitignore
index d914c32..f529c99 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,7 @@ yarn-error.*
# generated native folders
/ios
/android
+
+# Obsidian vault
+/venty/
+.obsidian/
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 0000000..000043e
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,3 @@
+npx biome check --write --staged --files-ignore-unknown=true
+git update-index --again
+bash scripts/check-conventions.sh
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100755
index 0000000..320512b
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1 @@
+npm run ci
diff --git a/AGENTS.md b/AGENTS.md
index a26b4bb..435416e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,7 @@
-# Expo HAS CHANGED
+# Expo ИЗМЕНИЛСЯ
-Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
+Перед написанием кода читай точную документацию по версии: https://docs.expo.dev/versions/v56.0.0/
+
+# Язык проекта
+
+Весь контент, комментарии и документация должны быть на русском языке.
diff --git a/CLAUDE.md b/CLAUDE.md
index 43c994c..71e225a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1 +1,27 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+@docs/core.md
+
+
+
@AGENTS.md
+
+
+
+@docs/commands.md
+
+
+
+@docs/git_workflow.md
+
+
+
+@docs/about_the_project.md
+
+
+
+@TESTS.md
+
+
diff --git a/README.md b/README.md
index 5b978a8..6ae16ea 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,24 @@
-# project-e
-Лента локальных событий происходящих вокруг тебя
+# Venty
+
+Лента локальных событий вокруг тебя.
# Стек
-- React Native - web, ios, android. Единый UI фреймворк во избежании поддержки разных версий компонентов
-- Supabase - BaaS чтобы не делать бэкенд, со временем добавим бэкенд сервис который будет общаться с supabase или постгресом напрямую, но пока сэкономит нам время
-- Nativewind - настроил :)
-- Zustand - UI стейт
-- React Query - server стейт
-- React Hook Form / Zod
-
-Мб понадобится storybook
+
+- React Native — веб, iOS и Android. Единый UI-фреймворк, чтобы не поддерживать разные версии компонентов.
+- Supabase — сервис базы данных, чтобы пока не писать отдельный бэкенд.
+- NativeWind — стили через классы.
+- Zustand — состояние интерфейса, если понадобится.
+- React Query — серверное состояние.
+- React Hook Form / Zod — формы и валидация.
+
+Возможно, позже понадобится Storybook.
+
+# Документация
+
+- [Быстрый старт](docs/quickstart.md) — установка, env и запуск проекта локально.
+- [Команды](docs/commands.md) — справка по npm-скриптам.
+- [О проекте](docs/about_the_project.md) — суть продукта и scope MVP.
+- [Архитектура и правила кода](docs/core.md) — слои, стиль, терминология.
+- [Git workflow](docs/git_workflow.md) — ветки, формат коммитов, PR.
+- [AI-флоу разработки](docs/ai_workflow.md) — как использовать ИИ на каждом этапе.
+- [Карта тестов](TESTS.md) — что покрыто unit-тестами.
diff --git a/TESTS.md b/TESTS.md
new file mode 100644
index 0000000..48e6854
--- /dev/null
+++ b/TESTS.md
@@ -0,0 +1,34 @@
+# Карта unit-тестов
+
+Файл поддерживается вместе с unit-тестами. На один тест — не больше двух строк: что подаём, что ожидаем, какую проблему ловим.
+
+## `src/entities/activity/utils.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` или исходная строка. Проверяет отображение даты на странице управления.
+- `возвращает следующий статус активности`: ввод — `active`/`cancelled`; вывод — обратный статус. Проверяет логику отмены и возврата активности.
+
+## `src/entities/activity/mappers.test.ts`
+
+- `преобразует форму создания в payload для API`: ввод — провалидированная форма с `null` в необязательных полях; вывод — `null`, ISO-дата, `slug`, `edit_token`, `active`. Проверяет payload создания (пустые строки в `null` чистит схема).
+- `сохраняет заполненные необязательные поля при создании`: ввод — форма с описанием, местом, лимитом и обложкой; вывод — те же значения. Проверяет, что маппер не затирает введённые данные.
+- `преобразует форму обновления в payload для API`: ввод — провалидированная форма редактирования с `null` в необязательных полях; вывод — `null`, ISO-дата и статус. Проверяет payload сохранения активности.
+
+## `src/entities/participant/utils.test.ts`
+
+- `возвращает русский текст статуса`: ввод — `going`, `maybe`, `cant`, неизвестный статус; вывод — русские подписи или исходная строка. Проверяет понятное отображение статусов в UI.
+- `возвращает статистику участников`: ввод — список участников; вывод — объект `{ going, maybe, cant }`. Проверяет агрегированную статистику откликов одним проходом.
+
+## `src/entities/participant/mappers.test.ts`
+
+- `преобразует форму отклика в payload для API`: ввод — форма с пустыми Telegram/comment; вывод — `null` и `activity_id`. Проверяет payload отклика без пустых строк.
+- `сохраняет заполненные необязательные поля`: ввод — форма с Telegram/comment; вывод — те же значения. Проверяет, что данные участника не теряются.
+
+## `src/shared/errors/getRussianErrorMessage.test.ts`
+
+- `переводит известные технические ошибки`: ввод — timestamp/network ошибки; вывод — русские сообщения. Проверяет, что технические backend/browser ошибки не попадают в UI.
+- `возвращает fallback для неизвестной ошибки`: ввод — неизвестная Error и не-Error значение; вывод — fallback. Проверяет безопасное русское сообщение для любых неожиданных ошибок.
diff --git a/app.json b/app.json
index fae87e4..6c05b2c 100644
--- a/app.json
+++ b/app.json
@@ -2,6 +2,7 @@
"expo": {
"name": "venty",
"slug": "venty",
+ "scheme": "venty",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
@@ -21,9 +22,6 @@
"web": {
"favicon": "./assets/favicon.png"
},
- "plugins": [
- "expo-router",
- "expo-status-bar"
- ]
+ "plugins": ["expo-router", "expo-status-bar"]
}
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index a229637..c2a5ab7 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,45 +1,22 @@
import "../global.css";
-
-import { Link, Stack } from "expo-router";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { Stack } from "expo-router";
+import { useState } from "react";
import { Text, View } from "react-native";
-
-export const SITE_PAGES = [
- { href: "/", label: "Home" },
- { href: "/create", label: "Create" },
- { href: "/a/welcome", label: "Welcome article" },
- { href: "/a/supabase", label: "Supabase article" },
-] as const;
-
-const linkClassName = "text-base text-blue-600 underline";
-
-export function PageLinks() {
- return (
-
- {SITE_PAGES.map((page) => (
-
- {page.label}
-
- ))}
-
- );
-}
-
-export function BackLink() {
- return (
-
- ← Back to all pages
-
- );
-}
+import { TextLink } from "../src/shared/ui/TextLink";
export default function RootLayout() {
+ const [client] = useState(() => new QueryClient());
return (
- <>
-
- Venty
-
+
+
+ Venty
+
+ Главная
+ Создать
+
- >
+
);
}
diff --git a/app/a/[slug].tsx b/app/a/[slug].tsx
index 307d569..176898c 100644
--- a/app/a/[slug].tsx
+++ b/app/a/[slug].tsx
@@ -1,30 +1,69 @@
-import { Link, useLocalSearchParams } from "expo-router";
-import { ScrollView, Text, View } from "react-native";
-import { BackLink, SITE_PAGES } from "../_layout";
+import { Image, Text } from "react-native";
+import { getParticipantStatusText } from "../../src/entities/participant/utils";
+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 { LoadingPage } from "../../src/shared/ui/LoadingPage";
+import { Page } from "../../src/shared/ui/Page";
+import { ParticipantList } from "../../src/shared/ui/ParticipantList";
-export default function ArticlePage() {
- const { slug } = useLocalSearchParams<{ slug: string }>();
- const title = slug ? slug.replaceAll("-", " ") : "article";
+export default function ActivityPage() {
+ const {
+ activity,
+ isLoading,
+ loadError,
+ participants,
+ participantsLoading,
+ stats,
+ form,
+ submit,
+ isSaving,
+ hasResponded,
+ } = useRespondToActivityForm();
+
+ if (isLoading) return ;
+ if (!activity) return ;
return (
-
-
-
- {title}
-
- Dynamic route: /a/{slug}. This page proves nested pages render on web and have a backlink
- to the all-pages index.
+
+
+ {activity.cover_url ? (
+
+ ) : null}
+ {activity.title}
+ {activity.city}
+ {activity.location_text}
+ {new Date(activity.starts_at).toLocaleString()}
+ {activity.description}
+ {activity.status === "cancelled" ? (
+ ОТМЕНЕНО
+ ) : null}
+
+ Идут: {stats.going} Может быть: {stats.maybe} Не могут: {stats.cant}
+
+ {activity.capacity ? (
+
+ {stats.going} / {activity.capacity} идут
-
-
- Other pages
- {SITE_PAGES.filter((page) => page.href !== `/a/${slug}`).map((page) => (
-
- {page.label}
-
- ))}
-
-
-
+ ) : null}
+ {activity.status !== "cancelled" && !hasResponded ? (
+
+ ) : null}
+ {activity.status !== "cancelled" && hasResponded ? (
+
+ Ты уже откликнулся с этого устройства.
+
+ ) : null}
+
+
);
}
diff --git a/app/create.tsx b/app/create.tsx
index 689e74e..52d792b 100644
--- a/app/create.tsx
+++ b/app/create.tsx
@@ -1,26 +1,71 @@
-import { Link } from "expo-router";
-import { ScrollView, Text, View } from "react-native";
-import { BackLink } from "./_layout";
+import { Text } from "react-native";
+import { useCreateActivityForm } from "../src/features/create-activity/useCreateActivityForm";
+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";
export default function CreatePage() {
+ const { form, submit, isSaving } = useCreateActivityForm();
+ const { control, handleSubmit } = form;
+ const { errors } = form.formState;
+
return (
-
-
-
- Create
-
- This page is wired into Expo Router and linked from the global navigation and home page.
-
-
- Next step
-
- Add your form here, then insert rows into Supabase from this route.
-
-
- Open an article page
-
-
-
-
+
+
+ Создать активность
+
+
+
+
+
+
+
+
+ {errors.root?.server?.message}
+
);
}
diff --git a/app/index.tsx b/app/index.tsx
index bace8fa..7411745 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,107 +1,37 @@
-import { Link } from "expo-router";
-import { useEffect, useState } from "react";
-import { ScrollView, Text, View } from "react-native";
-import { SITE_PAGES } from "./_layout";
-import { checkSupabaseConnection, supabase } from "../src/utils/supabase";
-
-type LinkRow = {
- id: string | number;
- title?: string | null;
- label?: string | null;
- url?: string | null;
- href?: string | null;
- path?: string | null;
-};
-
-const linkClassName = "text-base text-blue-600 underline";
+import { Text, View } from "react-native";
+import { useActivities } from "../src/entities/activity/hooks";
+import { ErrorText } from "../src/shared/ui/ErrorText";
+import { LoadingText } from "../src/shared/ui/LoadingText";
+import { Page } from "../src/shared/ui/Page";
+import { TextLink } from "../src/shared/ui/TextLink";
export default function HomePage() {
- const [connection, setConnection] = useState("Checking Supabase connection…");
- const [connectionOk, setConnectionOk] = useState(false);
- const [databaseLinks, setDatabaseLinks] = useState([]);
- const [databaseMessage, setDatabaseMessage] = useState("Loading links table…");
-
- useEffect(() => {
- let mounted = true;
-
- async function load() {
- const result = await checkSupabaseConnection();
- if (!mounted) return;
-
- setConnection(result.message);
- setConnectionOk(result.ok);
-
- if (!supabase) {
- setDatabaseMessage("Supabase client is not configured yet.");
- return;
- }
-
- const { data, error } = await supabase.from("links").select("*").limit(50);
- if (!mounted) return;
-
- if (error) {
- setDatabaseMessage(
- `Connected, but could not read a public links table: ${error.message}`,
- );
- return;
- }
-
- setDatabaseLinks((data ?? []) as LinkRow[]);
- setDatabaseMessage(
- data && data.length > 0
- ? `Loaded ${data.length} link${data.length === 1 ? "" : "s"} from Supabase.`
- : "Connected. Create a public links table to show database rows here.",
- );
- }
-
- load();
- return () => {
- mounted = false;
- };
- }, []);
-
+ const activities = useActivities();
return (
-
-
- All pages
-
- Every route is linked here and every detail page has a backlink to this page.
-
-
-
- {SITE_PAGES.map((page) => (
-
-
- {page.label} → {page.href}
-
-
- ))}
-
-
- Supabase status
-
-
- {connectionOk ? "✓" : "!"} {connection}
-
- {databaseMessage}
-
-
- {databaseLinks.length > 0 ? (
-
- {databaseLinks.map((row) => {
- const href = row.href ?? row.path ?? row.url ?? "/";
- const label = row.title ?? row.label ?? String(href);
- return (
-
- {label}
-
- );
- })}
+
+ Venty
+ Уродливые ссылки на активности, чтобы делать что-то с людьми
+ [Создать активность]
+ Последние активности:
+ {activities.isLoading ? : null}
+
+ {activities.data?.length === 0 ? (
+ Активностей пока нет.
+ ) : null}
+
+ {activities.data?.map((activity) => (
+
+
+ {activity.title} — {activity.city} —{" "}
+ {new Date(activity.starts_at).toLocaleString()}
+
+ открыть
- ) : null}
+ ))}
-
+
);
}
diff --git a/app/manage/[editToken].tsx b/app/manage/[editToken].tsx
new file mode 100644
index 0000000..173d45d
--- /dev/null
+++ b/app/manage/[editToken].tsx
@@ -0,0 +1,48 @@
+import { Text } from "react-native";
+import { getParticipantStatusText } from "../../src/entities/participant/utils";
+import { ManageActivityForm } from "../../src/features/manage-activity/ManageActivityForm";
+import { useManageActivityForm } from "../../src/features/manage-activity/useManageActivityForm";
+import { ActivityNotFound } from "../../src/shared/ui/ActivityNotFound";
+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";
+
+export default function ManagePage() {
+ const {
+ activity,
+ isLoading,
+ loadError,
+ participants,
+ participantsLoading,
+ publicUrl,
+ form,
+ submit,
+ toggleStatus,
+ isSaving,
+ } = useManageActivityForm();
+
+ if (isLoading) return ;
+ if (!activity) return ;
+
+ return (
+
+
+ Управление активностью
+ Публичная ссылка: {publicUrl}
+ Копируй и отправляй вручную
+
+
+
+ );
+}
diff --git a/babel.config.js b/babel.config.js
index 338549a..ddebb0e 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,7 +1,10 @@
-module.exports = function (api) {
+module.exports = (api) => {
api.cache(true);
return {
- presets: ["babel-preset-expo", "nativewind/babel"],
+ presets: [
+ ["babel-preset-expo", { jsxImportSource: "nativewind" }],
+ "nativewind/babel",
+ ],
};
};
diff --git a/biome.jsonc b/biome.jsonc
new file mode 100644
index 0000000..9102a08
--- /dev/null
+++ b/biome.jsonc
@@ -0,0 +1,55 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "ignoreUnknown": true,
+ "includes": [
+ "**",
+ "!**/node_modules",
+ "!**/.expo",
+ "!**/.husky/_",
+ "!**/dist",
+ "!**/build",
+ "!**/web-build",
+ "!**/coverage",
+ "!**/ios",
+ "!**/android",
+ "!venty"
+ ]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "suspicious": {
+ "noExplicitAny": "error",
+ "noUnknownAtRules": "off"
+ },
+ "style": {
+ "noNonNullAssertion": "error"
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double"
+ }
+ },
+ "assist": {
+ "enabled": true,
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ }
+}
diff --git a/docs/about_the_project.md b/docs/about_the_project.md
new file mode 100644
index 0000000..640faf3
--- /dev/null
+++ b/docs/about_the_project.md
@@ -0,0 +1,91 @@
+# Venty — О проекте
+
+> Все мероприятия города — в одной ленте
+
+---
+
+## Суть продукта
+
+Venty — мобильное приложение для организации и отслеживания мероприятий. Помогает жителям города создавать и развивать локальные комьюнити, быть в курсе событий и активно участвовать в жизни своего города.
+
+Продукт строится вокруг ленты событий (а не скучного каталога) и интерактивной карты — по аналогии с привычным форматом современных социальных сетей.
+
+---
+
+## Проблема
+
+Чтобы найти или организовать мероприятие в городе, сегодня нужно одновременно мониторить 3–5 разных платформ: Instagram, Telegram, 2ГИС, личные знакомства. Специализированные приложения для ивентов оформлены как скучные доски и не удерживают аудиторию.
+
+Итог: чтобы организовать мероприятие с широким охватом, нужен крупный медиаканал. Чтобы найти интересное событие — просмотреть множество источников.
+
+---
+
+## Целевая аудитория
+
+**Организаторы**
+
+- Speaking clubs, спортивные сообщества, образовательные проекты
+- Городские инициативы и локальные НКО
+
+**Участники**
+
+- Студенты и молодёжь 18–35 лет
+- Люди, ищущие нетворкинг и досуг
+
+---
+
+## Ключевой функционал
+
+- Лента событий — трендовые и рекомендуемые ивенты
+- Интерактивная карта мероприятий
+- Чат для каждого ивента
+- Создание ивентов доступно любому пользователю — без специального статуса
+- Публичная веб-страница каждого ивента с превью (название, дата, место, описание, кнопка участия) — без необходимости скачивать приложение
+
+---
+
+## Веб-страница ивента
+
+Каждый ивент получает публичный URL. Когда пользователь делится ссылкой, получатель попадает на читаемую страницу события, а не на экран скачивания приложения. Это снижает барьер входа для новых пользователей и делает каждый ивент самостоятельной единицей контента для распространения в мессенджерах и соцсетях.
+
+---
+
+## Монетизация
+
+Монетизация планируется после 3 000–5 000 MAU в одном городе. До этого — фокус на рост и удержание.
+
+Планируемые модели:
+
+- Реклама внутри приложения
+- Подписка для организаторов с расширенными возможностями (аналитика, продвижение ивента, кастомизация) — ориентировочно $10–20/мес.
+- Featured placements для городских организаций и брендов
+
+---
+
+## Модерация и открытое создание ивентов
+
+На раннем этапе создавать мероприятия может любой пользователь. Риск спама решается поэтапно:
+
+- **На старте:** лимиты на количество ивентов, блокировки по IP, жалобы пользователей, ручная проверка
+- **При росте (3k+ MAU):** рейтинг доверия, верификация через номер телефона, система репутации
+
+Платная подписка как барьер для создания ивентов — крайняя мера, убивает органический рост контента.
+
+---
+
+## Scope MVP
+
+**Входит:**
+
+- Создание ивентов любым пользователем
+- Интерактивная карта событий
+- Упрощённая лента мероприятий
+- Флоу: нашёл ивент → лайкнул → вступил в чат
+- Веб-страница каждого ивента с превью
+- Базовая модерация: лимиты на создание + блокировки по IP
+
+**Не входит:**
+
+- Алгоритмы рекомендаций в ленте
+- Чат со сложными настройками (роли, модерация, боты)
+- Продвинутая модерация (актуальна при 3k+ MAU)
diff --git a/docs/ai_workflow.md b/docs/ai_workflow.md
new file mode 100644
index 0000000..17748fd
--- /dev/null
+++ b/docs/ai_workflow.md
@@ -0,0 +1,94 @@
+# AI-Assisted Development Workflow
+
+Практический флоу для разработчиков, которые хотят эффективно использовать ИИ на каждом этапе.
+
+---
+
+## 1. 🔍 Ресерч
+
+**Суть:** Собрать контекст перед тем, как что-то делать.
+
+**Ключевые действия:**
+
+- Изучить документацию, существующие решения, похожие реализации
+- Спросить у ИИ: _"Какие подходы существуют для X? Какие у них trade-offs?"_
+- Найти edge cases и ограничения выбранной технологии
+
+---
+
+## 2. 💬 Обсуждение вариантов реализации с ИИ
+
+**Суть:** Не принимать решения в одиночку — использовать ИИ как думающего собеседника.
+
+**Ключевые действия:**
+
+- Описать задачу и контекст: стек, ограничения, требования
+- Попросить предложить несколько вариантов реализации с плюсами и минусами
+- Уточнить детали и задать вопросы: _"Что если...", "Как это масштабируется?"_
+
+---
+
+## 3. 📋 Генерация плана + ручное ревью
+
+**Суть:** ИИ строит скелет — человек проверяет логику.
+
+**Ключевые действия:**
+
+- Попросить ИИ сгенерировать пошаговый план реализации
+- Самостоятельно пройтись по плану:
+ - Найти пропущенные edge cases
+ - Оценить оптимальность выбранного подхода
+ - Проверить соответствие архитектуре проекта
+- При необходимости скорректировать план и повторить итерацию
+
+---
+
+## 4. 🛠️ Реализация плана
+
+**Суть:** Писать код по согласованному плану, используя ИИ как помощника.
+
+**Ключевые действия:**
+
+- Реализовывать шаг за шагом, не отклоняясь от плана
+- Использовать ИИ для написания boilerplate, утилит, типовых конструкций
+- При блокерах — объяснять проблему ИИ и разбирать варианты выхода
+
+---
+
+## 5. 👁️ Ручное ревью
+
+**Суть:** Человеческий взгляд — первый и обязательный.
+
+**Ключевые действия:**
+
+- Перечитать код с точки зрения читаемости и поддерживаемости
+- Проверить бизнес-логику и соответствие требованиям
+- Убедиться в корректной обработке ошибок и граничных случаев
+
+---
+
+## 6. 🤖 Ревью с ИИ
+
+**Суть:** ИИ замечает то, что легко пропустить глазом.
+
+**Ключевые действия:**
+
+- Передать код и попросить: _"Найди потенциальные баги, уязвимости и места для улучшения"_
+- Спросить про производительность, читаемость, соответствие best practices
+- Пройтись по замечаниям и принять/отклонить каждое осознанно
+
+---
+
+## 7. ✅ Генерация тестов
+
+**Суть:** Покрыть реализацию тестами — ИИ ускоряет этот процесс кратно.
+
+**Ключевые действия:**
+
+- Попросить ИИ сгенерировать unit-тесты для ключевой логики
+- Отдельно попросить покрыть edge cases и негативные сценарии
+- Проверить, что тесты действительно тестируют поведение, а не реализацию
+
+---
+
+> **Принцип:** ИИ — это ускоритель, а не замена мышлению. Человек принимает решения, ИИ снижает стоимость их проверки и реализации.
diff --git a/docs/commands.md b/docs/commands.md
new file mode 100644
index 0000000..d1b5220
--- /dev/null
+++ b/docs/commands.md
@@ -0,0 +1,10 @@
+# Команды
+
+Операционная справка по npm-скриптам проекта.
+
+- `npm start` — запустить Expo (`--android` / `--ios` / `--web` для конкретной платформы).
+- `npm run lint` — Biome CI + `tsc --noEmit`. То же запускают pre-push и CI.
+- `npm run format` — Biome с автоисправлением.
+- `npm test` — Vitest (один прогон). Один файл: `npx vitest run путь/к/файлу.test.ts`,
+ в watch-режиме: `npx vitest путь` (без `run`).
+- `npm run ci` — lint + тесты, полная проверка перед пушем.
diff --git a/docs/core.md b/docs/core.md
new file mode 100644
index 0000000..ebbc575
--- /dev/null
+++ b/docs/core.md
@@ -0,0 +1,167 @@
+
+
+ Читаемость — главный критерий качества. Код должен читаться сверху вниз
+ за один проход, даже без комментариев.
+
+ Все правила в этом документе — следствия этого принципа:
+ - code-style — читаемость на уровне файла и функции.
+ - app-architecture — читаемость на уровне проекта (слои, направление зависимостей).
+ - terminology-policy — читаемость через единый словарь проекта (один термин — одно понятие).
+
+ Базовый контекст проекта (стек, команды, цепочка слоёв) — в `AGENTS.md`.
+ Проект — Expo (React Native) приложение Venty: лента локальных активностей.
+
+
+
+
+ - Экспортируемая функция — сценарий: только именованные шаги, без inline-деталей.
+ Детали реализации — приватные функции ниже в том же файле.
+ - Приватная функция оправдана если (любое из):
+ - Она делает сценарий читаемее через точное имя
+ - Она прячет нетривиальную сложность (>5 строк, ветвления, циклы)
+ - Она убирает дублирование
+ - Имена лаконичные и однозначные, понятные даже человеку вне контекста.
+ - В начале функции/блока объяви алиасы для длинных цепочек доступа (>2 уровней):
+ `form.formState.errors.root` → `const { root } = form.formState.errors`.
+ - Алиасы оправданы не только для упрощения логики, но и для компактности, когда
+ inline-выражение размазывается форматтером на 4+ строки из-за лимита длины строки.
+ - Утилиты-библиотеки общего назначения в проекте нет. Чистые хелперы пишем сами
+ и складываем в `entities/*/utils.ts` (доменные) или `shared/lib/` (общие).
+ Не плоди дубли — повторно используемый хелпер выноси в `shared/`.
+ - Проверку на `null`/`undefined` делай явно (`value == null` ловит оба, либо
+ `value === null` / `value === undefined` по смыслу). Помни про Biome `noNonNullAssertion`.
+ - Функции и модули небольшие; файл длиннее 120 строк — сигнал к декомпозиции.
+ Исключение: декларативные данные (словари, схемы, метаданные), где декомпозиция
+ ухудшит читаемость целого.
+
+
+ Ошибки в проекте текут по одному маршруту, не изобретай новый:
+ - Слой `entities/*/api.ts` бросает ошибку Supabase (`if (error) throw error`),
+ ничего не глотает и не логирует.
+ - Слой `features/*/use*Form.ts` ловит её в `try/catch` вокруг `mutateAsync`
+ и кладёт текст в `root.server` через `getRussianErrorMessage`.
+ - Технические сообщения backend/браузера в UI не попадают: всё проходит через
+ `getRussianErrorMessage` с русским fallback. Новый паттерн ошибки — новая
+ строка в `knownErrorMessages`.
+
+
+
+ TypeScript — основной инструмент контроля качества (`tsconfig` strict). Соблюдаем
+ естественную типизацию:
+
+ - По умолчанию — inference. Явный тип объявляем только когда он добавляет ограничение.
+ - Тип добавляет ограничение, если (любое из):
+ - Структура отличается от источника (скрытие полей, переименование, вычисляемые поля);
+ - Это форма строки БД или payload API (`Activity`, `CreateActivityInput`);
+ - Это дискриминирующий union для результатов/статусов.
+ - Тип не добавляет ограничение, если он 1:1 повторяет вывод другого выражения —
+ используй inference, чтобы не дублировать источник правды.
+ - При объявлении составного типа размещаем его сверху; типы, на которые он
+ ссылается, — ниже. Читатель сначала видит целое, затем детали.
+
+ - Запрещены `any` (Biome `noExplicitAny` = error) и non-null `!` (`noNonNullAssertion` = error).
+ - `as Foo` запрещён везде, КРОМЕ границы Supabase: клиент отдаёт нетипизированные
+ строки, поэтому в `entities/*/api.ts` результат приводим к доменному типу
+ (`data as Activity[]`). Это единственное допустимое место для `as`.
+ - `as const` — только для литералов, когда без него типы хуже.
+ - `satisfies` — только если без него ломается типизация или хуже читабельность.
+ - `type`, не `interface` (в проекте уже только `type`).
+ - Не экспортировать неиспользуемое. Локальные типы лаконичны: `Params`, не `FunctionNameParams`.
+ - Параметры >2–3 или нетривиальной формы — отдельный `type Params` над функцией
+ (`type Props` для React-компонентов).
+ - Формы валидируем Zod: `z.input` — это форма (что вводит пользователь),
+ `z.output` — провалидированные данные (что уходит в маппер). Не путай их в сигнатурах.
+ - Статусы — строковые union'ы (`ActivityStatus = "active" | "cancelled"`).
+
+
+
+
+
+ 1. Архитектура — Feature-Sliced Design, но не канонический: взят лишь принцип
+ разделения на layers / slices / segments.
+ 2. Цепочка слоёв проекта: `app` → `features` → `entities` → `shared`.
+ Зависимости строго однонаправленны — импортируем только из слоёв правее по цепочке,
+ обратные импорты запрещены.
+ - `app/` — роуты Expo Router (file-based), только композиция UI, без бизнес-логики
+ и прямых вызовов Supabase.
+ - `features/*` — пользовательские сценарии (хуки форм: RHF + zodResolver + мутация).
+ - `entities/{activity,participant}` — доменное ядро: `types`, `api`, `hooks`,
+ `schemas`, `mappers`, `utils`.
+ - `shared/` — переиспользуемое: `ui/`, `supabase/client.ts`, `errors/`, `lib/`.
+ 3. Barrel-файлов (`index.ts`) в проекте нет: импортируем напрямую по пути к модулю.
+ Public API slice'а — это набор его осмысленно названных файлов; не лезь во внутренности
+ чужого slice в обход его API-файлов (`api.ts`, `hooks.ts`, `mappers.ts`).
+ 4. Имена файлов внутри slice — по смыслу, без префикса slice'а: контекст задаёт путь.
+ Пример: `entities/activity/api.ts`, не `entities/activity/activity-api.ts`.
+ 5. Между slices одного слоя прямые зависимости запрещены: `activity` не импортирует
+ `participant` и наоборот. Нужно склеить два slice — делай композицию в слое выше
+ (`features` или `app`).
+ 6. Доступ к Supabase — только через `requireSupabase()` из `shared/supabase/client.ts`.
+ Никаких прямых `createClient` в slice'ах. Env: `EXPO_PUBLIC_SUPABASE_URL`,
+ `EXPO_PUBLIC_SUPABASE_KEY` (см. `.env.example`).
+ 7. Общий для разных частей проекта код не складываем в безымянные `utils/helpers`
+ на верхнем уровне — выносим в `shared/lib/{name}` или `shared/ui/` с единой
+ ответственностью и понятным неймингом.
+ 8. Стили — только классами NativeWind (`className`). Без `StyleSheet` и инлайновых стилей.
+
+
+
+ - Один термин на одно понятие (без дублей и синонимов).
+ - Один выбранный вариант используется везде: типы, переменные, функции, файлы.
+ - Для составных названий фиксируем паттерн и порядок слов (и не мешаем варианты).
+
+
+ Примеры ниже — закреплённый словарь проекта, его и держимся:
+
+ - Сущность события: везде «активность» / `activity` (не «событие» / `event`).
+ - Откликнувшийся: везде «участник» / `participant` (не «гость» / `attendee`).
+ - Публичная часть ссылки: везде `slug`; секретный ключ управления: везде `edit_token`
+ (в формах/типах — `editToken`, в БД — `edit_token`, не смешивать в одном контексте).
+ - Преобразование формы в payload API: везде `mapXxxFormToInput` (не `toInput`/`serialize`).
+ - Хуки React Query: ключи держим в объекте `xxxKeys`, не пишем массивы ключей инлайн.
+
+ Если подходящего слова ещё нет — выбери один вариант и закрепи его в коде/доках.
+
+
+
+
+
+ Один язык интерфейса — русский, отдельной i18n-инфраструктуры нет. Весь видимый
+ пользователю текст пишем по-русски прямо в месте использования. Ошибки для UI
+ всегда прогоняем через `getRussianErrorMessage` с осмысленным русским `fallback` —
+ сырые технические сообщения наружу не показываем.
+
+
+
+
+ Важно генерировать код, который компилируется и соответствует актуальному API зависимостей.
+
+ - Expo в этом проекте отличается от привычного: сверяйся с документацией версии v56 —
+ https://docs.expo.dev/versions/v56.0.0/
+ - Перед использованием любой внешней библиотеки/SDK/фреймворка запрашивай актуальную
+ документацию через context7 mcp, даже если «кажется, что помнишь сигнатуру».
+ Не выдумывай API и не полагайся на знания из обучения.
+
+
+
+ Если пользователь просит вывести файлы в чат:
+ - Сначала коротко перечисли что будет сделано (2–6 пунктов).
+ - Затем выведи файлы по одному, начиная с верхнего уровня.
+ - Выдавай рабочий связанный код всего файла, а не патч-версию, инструкции по
+ изменению или код с комментариями вида «// existing code ...».
+
+
+
+ Если пользователь явно просит применить изменения в репозиторий:
+ - Внеси изменения в репозиторий. НЕ дублируй полный код файлов в чате.
+ - Проверь код командами из корня репо: `npm run lint` (Biome CI + `tsc --noEmit`)
+ и `npm test`. Форматирование при необходимости — `npm run format`.
+ - Дай краткое резюме (2–5 пунктов) на уровне смысла.
+
+
+
+ Отвечай лаконично, но простыми словами.
+
+
+
+
diff --git a/docs/git_workflow.md b/docs/git_workflow.md
new file mode 100644
index 0000000..244e49d
--- /dev/null
+++ b/docs/git_workflow.md
@@ -0,0 +1,89 @@
+# Git Workflow
+
+**Одна задача — одна ветка — один PR.** Промежуточного `stage` нет, PR идёт прямо в `main`.
+
+```
+main
+ ↑ PR (issue-28/add-map → main)
+issue-28/add-map
+```
+
+## Формат коммитов
+
+Тип коммита (Conventional Commits):
+
+| Тип | Когда |
+| ---------- | --------------------------------- |
+| `feat` | новая функциональность |
+| `fix` | исправление бага |
+| `refactor` | переработка без смены поведения |
+| `test` | тесты |
+| `docs` | документация |
+| `style` | форматирование, NativeWind-классы |
+| `chore` | конфиг, зависимости, CI |
+
+**Scope** — слой или slice проекта, который затронул коммит (см. цепочку слоёв в `AGENTS.md`):
+
+| Scope | Когда |
+| ------------- | ----------------------------------------- |
+| `activity` | slice `entities/activity` |
+| `participant` | slice `entities/participant` |
+| `features` | пользовательские сценарии `features/*` |
+| `shared` | `shared/ui`, `shared/lib`, supabase и пр. |
+| `app` | роуты Expo Router в `app/` |
+| `cfg` | конфиг, env, зависимости |
+| `docs` | документация |
+| `ci` | CI/CD |
+
+**Примеры:**
+
+- `feat(activity): добавлен маппер формы создания в payload`
+- `fix(participant): исправлен подсчёт участников по статусу`
+- `refactor(shared): вынесен requireSupabase в отдельный модуль`
+- `test(activity): добавлены тесты генерации slug`
+- `docs: обновлён git workflow под текущий проект`
+
+## Рабочий цикл
+
+### Начало задачи
+
+```bash
+git fetch origin
+git checkout -b issue-{id}/{говорящее-имя} origin/main
+```
+
+### В процессе работы
+
+Перед началом дня и перед созданием PR влей свежий `main`:
+
+```bash
+git fetch origin
+git merge origin/main
+```
+
+Конфликт из-за своего кода — правишь сам. Конфликт из-за чужого — разбираешься по `git blame`.
+
+### Завершение задачи
+
+1. Убедись что ветка свежая (влит актуальный `main`).
+2. Прогони полную проверку: `npm run ci` (lint + тесты).
+3. Создай PR: `issue-{id}/{говорящее-имя}` → `main`.
+4. После мержа удали feature-ветку.
+
+```bash
+git branch -d issue-{id}/{говорящее-имя}
+git push origin --delete issue-{id}/{говорящее-имя}
+```
+
+## Атомарность коммитов
+
+Один коммит — одно логическое изменение. Примеры правильного дробления:
+
+```
+test(activity): добавлены тесты маппера создания
+feat(activity): добавлен mapCreateActivityFormToInput
+feat(features): добавлен хук useCreateActivityForm
+feat(app): добавлен экран создания активности
+```
+
+Не смешивай рефакторинг и фичу в одном коммите.
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 0000000..b06ad2f
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,59 @@
+# Быстрый старт
+
+Запуск проекта Venty локально за пять шагов. Стек — Expo (React Native) v56,
+сверяйся с документацией версии: https://docs.expo.dev/versions/v56.0.0/
+
+## Требования
+
+- Node.js 20+ и npm.
+- Аккаунт Supabase с проектом (нужны URL и anon-ключ).
+- Для нативного запуска — Expo Go на телефоне либо симулятор iOS / эмулятор Android.
+
+## 1. Установка зависимостей
+
+```bash
+npm install
+```
+
+## 2. Переменные окружения
+
+Скопируй `.env.example` в `.env.local` и заполни ключи проекта Supabase:
+
+```bash
+cp .env.example .env.local
+```
+
+```
+EXPO_PUBLIC_SUPABASE_URL=https://<твой-проект>.supabase.co
+EXPO_PUBLIC_SUPABASE_KEY=
+```
+
+Без этих переменных клиент Supabase не поднимется, и любой запрос упадёт с
+понятной русской ошибкой (см. `requireSupabase()` в `src/shared/supabase/client.ts`).
+
+## 3. Запуск
+
+```bash
+npm start # Expo dev-сервер, выбор платформы в меню
+npm run web # сразу в браузере
+npm run ios # симулятор iOS
+npm run android # эмулятор Android
+```
+
+## 4. Проверки перед коммитом
+
+```bash
+npm run lint # Biome CI + tsc --noEmit
+npm run arch # проверка направления зависимостей по слоям
+npm test # Vitest, один прогон
+npm run ci # всё вместе — гоняй перед пушем
+```
+
+Полный список скриптов — в [commands.md](./commands.md).
+
+## 5. Куда смотреть дальше
+
+- Архитектура, слои и правила кода — [core.md](./core.md).
+- Что за продукт и какой scope MVP — [about_the_project.md](./about_the_project.md).
+- Ветки, коммиты, PR — [git_workflow.md](./git_workflow.md).
+- Карта unit-тестов — [../TESTS.md](../TESTS.md).
diff --git a/nativewind-env.d.ts b/nativewind-env.d.ts
index c0d8380..9583462 100644
--- a/nativewind-env.d.ts
+++ b/nativewind-env.d.ts
@@ -1,3 +1,3 @@
///
-// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
\ No newline at end of file
+// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
diff --git a/package-lock.json b/package-lock.json
index b6b21b4..bddbfa1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@tanstack/react-query": "^5.100.14",
"expo": "~56.0.8",
"expo-constants": "~56.0.16",
+ "expo-crypto": "~56.0.4",
"expo-linking": "~56.0.13",
"expo-router": "~56.2.8",
"expo-status-bar": "~56.0.4",
@@ -30,9 +31,13 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@biomejs/biome": "2.4.16",
"@types/react": "~19.2.2",
"babel-preset-expo": "~56.0.14",
- "typescript": "~6.0.3"
+ "dependency-cruiser": "^17.4.3",
+ "husky": "9.1.7",
+ "typescript": "~6.0.3",
+ "vitest": "^4.1.7"
}
},
"node_modules/@adobe/css-tools": {
@@ -1194,6 +1199,203 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@biomejs/biome": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz",
+ "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "bin": {
+ "biome": "bin/biome"
+ },
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/biome"
+ },
+ "optionalDependencies": {
+ "@biomejs/cli-darwin-arm64": "2.4.16",
+ "@biomejs/cli-darwin-x64": "2.4.16",
+ "@biomejs/cli-linux-arm64": "2.4.16",
+ "@biomejs/cli-linux-arm64-musl": "2.4.16",
+ "@biomejs/cli-linux-x64": "2.4.16",
+ "@biomejs/cli-linux-x64-musl": "2.4.16",
+ "@biomejs/cli-win32-arm64": "2.4.16",
+ "@biomejs/cli-win32-x64": "2.4.16"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-arm64": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz",
+ "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-x64": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz",
+ "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz",
+ "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz",
+ "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz",
+ "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64-musl": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz",
+ "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-arm64": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz",
+ "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-x64": {
+ "version": "2.4.16",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz",
+ "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@expo-google-fonts/material-symbols": {
"version": "0.4.38",
"resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.38.tgz",
@@ -1809,6 +2011,25 @@
"win32"
]
},
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1844,6 +2065,16 @@
"node": ">= 8"
}
},
+ "node_modules/@oxc-project/types": {
+ "version": "0.132.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
+ "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -2635,12 +2866,283 @@
}
}
},
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
+ "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
+ "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
+ "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
+ "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
+ "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
+ "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
+ "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
+ "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
+ "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
+ "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
+ "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
+ "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
+ "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
+ "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
+ "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -2850,6 +3352,17 @@
"@testing-library/dom": ">=7.21.4"
}
},
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -2857,6 +3370,31 @@
"license": "MIT",
"peer": true
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2930,6 +3468,119 @@
"integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
"license": "ISC"
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
+ "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
+ "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
+ "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
+ "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.7",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
+ "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
+ "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
+ "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -2976,6 +3627,49 @@
"node": ">=0.4.0"
}
},
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/acorn-jsx-walk": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz",
+ "integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/acorn-loose": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz",
+ "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.15.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -3106,6 +3800,16 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
@@ -3474,6 +4178,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3894,6 +4608,77 @@
"node": ">= 0.8"
}
},
+ "node_modules/dependency-cruiser": {
+ "version": "17.4.3",
+ "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.4.3.tgz",
+ "integrity": "sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "8.16.0",
+ "acorn-jsx": "5.3.2",
+ "acorn-jsx-walk": "2.0.0",
+ "acorn-loose": "8.5.2",
+ "acorn-walk": "8.3.5",
+ "commander": "14.0.3",
+ "enhanced-resolve": "5.22.1",
+ "ignore": "7.0.5",
+ "interpret": "3.1.1",
+ "is-installed-globally": "1.0.0",
+ "json5": "2.2.3",
+ "picomatch": "4.0.4",
+ "prompts": "2.4.2",
+ "rechoir": "0.8.0",
+ "safe-regex": "2.1.1",
+ "semver": "7.8.1",
+ "tsconfig-paths-webpack-plugin": "4.2.0",
+ "watskeburt": "5.0.3"
+ },
+ "bin": {
+ "depcruise": "bin/dependency-cruise.mjs",
+ "depcruise-baseline": "bin/depcruise-baseline.mjs",
+ "depcruise-fmt": "bin/depcruise-fmt.mjs",
+ "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs",
+ "dependency-cruise": "bin/dependency-cruise.mjs",
+ "dependency-cruiser": "bin/dependency-cruise.mjs"
+ },
+ "engines": {
+ "node": "^20.12||^22||>=24"
+ }
+ },
+ "node_modules/dependency-cruiser/node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/dependency-cruiser/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/dependency-cruiser/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -3980,6 +4765,20 @@
"node": ">= 0.8"
}
},
+ "node_modules/enhanced-resolve": {
+ "version": "5.22.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
+ "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
@@ -3998,6 +4797,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4038,6 +4844,16 @@
"node": ">=4"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -4056,6 +4872,16 @@
"node": ">=6"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/expo": {
"version": "56.0.8",
"resolved": "https://registry.npmjs.org/expo/-/expo-56.0.8.tgz",
@@ -4131,6 +4957,15 @@
"react-native": "*"
}
},
+ "node_modules/expo-crypto": {
+ "version": "56.0.4",
+ "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz",
+ "integrity": "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-font": {
"version": "56.0.5",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.5.tgz",
@@ -4904,6 +5739,22 @@
"node": ">=10.13.0"
}
},
+ "node_modules/global-directory": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz",
+ "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ini": "4.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -5009,7 +5860,23 @@
"debug": "4"
},
"engines": {
- "node": ">= 14"
+ "node": ">= 14"
+ }
+ },
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
}
},
"node_modules/hyphenate-style-name": {
@@ -5072,6 +5939,16 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ini": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz",
+ "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/inline-style-prefixer": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
@@ -5081,6 +5958,16 @@
"css-in-js-utils": "^3.1.0"
}
},
+ "node_modules/interpret": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
+ "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -5168,6 +6055,23 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-installed-globally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz",
+ "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "global-directory": "^4.0.1",
+ "is-path-inside": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -5177,6 +6081,19 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-path-inside": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
+ "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -5791,6 +6708,16 @@
"lz-string": "bin/bin.js"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -6178,6 +7105,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@@ -6416,6 +7353,17 @@
"node": ">= 6"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -6635,6 +7583,13 @@
"node": "20 || >=22"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7630,6 +8585,19 @@
"node": ">=8.10.0"
}
},
+ "node_modules/rechoir": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
+ "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve": "^1.20.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -7667,6 +8635,16 @@
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
+ "node_modules/regexp-tree": {
+ "version": "0.1.27",
+ "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
+ "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "regexp-tree": "bin/regexp-tree"
+ }
+ },
"node_modules/regexpu-core": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz",
@@ -7770,6 +8748,40 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rolldown": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
+ "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.132.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.2",
+ "@rolldown/binding-darwin-arm64": "1.0.2",
+ "@rolldown/binding-darwin-x64": "1.0.2",
+ "@rolldown/binding-freebsd-x64": "1.0.2",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.2",
+ "@rolldown/binding-linux-arm64-musl": "1.0.2",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.2",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.2",
+ "@rolldown/binding-linux-x64-gnu": "1.0.2",
+ "@rolldown/binding-linux-x64-musl": "1.0.2",
+ "@rolldown/binding-openharmony-arm64": "1.0.2",
+ "@rolldown/binding-wasm32-wasi": "1.0.2",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.2",
+ "@rolldown/binding-win32-x64-msvc": "1.0.2"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7813,6 +8825,16 @@
],
"license": "MIT"
},
+ "node_modules/safe-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz",
+ "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regexp-tree": "~0.1.1"
+ }
+ },
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
@@ -8008,6 +9030,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -8095,6 +9124,13 @@
"node": ">=6"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stackframe": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
@@ -8122,6 +9158,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stream-buffers": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
@@ -8166,6 +9209,16 @@
"node": ">=8"
}
},
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -8295,6 +9348,20 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/terminal-link": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@@ -8362,6 +9429,23 @@
"integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz",
+ "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -8407,6 +9491,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -8452,6 +9546,37 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0"
},
+ "node_modules/tsconfig-paths": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
+ "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json5": "^2.2.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tsconfig-paths-webpack-plugin": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz",
+ "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.0",
+ "enhanced-resolve": "^5.7.0",
+ "tapable": "^2.2.1",
+ "tsconfig-paths": "^4.1.2"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -8700,6 +9825,200 @@
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
+ "node_modules/vite": {
+ "version": "8.0.14",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
+ "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.15",
+ "rolldown": "1.0.2",
+ "tinyglobby": "^0.2.16"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.18",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
+ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.7",
+ "@vitest/mocker": "4.1.7",
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/runner": "4.1.7",
+ "@vitest/snapshot": "4.1.7",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.7",
+ "@vitest/browser-preview": "4.1.7",
+ "@vitest/browser-webdriverio": "4.1.7",
+ "@vitest/coverage-istanbul": "4.1.7",
+ "@vitest/coverage-v8": "4.1.7",
+ "@vitest/ui": "4.1.7",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/vlq": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz",
@@ -8721,6 +10040,19 @@
"integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==",
"license": "MIT"
},
+ "node_modules/watskeburt": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-5.0.3.tgz",
+ "integrity": "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "watskeburt": "dist/run-cli.js"
+ },
+ "engines": {
+ "node": "^20.12||^22.13||>=24.0"
+ }
+ },
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -8773,6 +10105,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/package.json b/package.json
index b9f7624..fac3127 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"@tanstack/react-query": "^5.100.14",
"expo": "~56.0.8",
"expo-constants": "~56.0.16",
+ "expo-crypto": "~56.0.4",
"expo-linking": "~56.0.13",
"expo-router": "~56.2.8",
"expo-status-bar": "~56.0.4",
@@ -25,15 +26,25 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@biomejs/biome": "2.4.16",
"@types/react": "~19.2.2",
"babel-preset-expo": "~56.0.14",
- "typescript": "~6.0.3"
+ "dependency-cruiser": "^17.4.3",
+ "husky": "9.1.7",
+ "typescript": "~6.0.3",
+ "vitest": "^4.1.7"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
- "web": "expo start --web"
+ "web": "expo start --web",
+ "lint": "biome ci . && tsc --noEmit",
+ "format": "biome check --write .",
+ "arch": "depcruise src app --config .dependency-cruiser.cjs",
+ "test": "vitest run",
+ "ci": "npm run lint && npm run arch && npm run test",
+ "prepare": "husky"
},
"private": true
}
diff --git a/scripts/check-conventions.sh b/scripts/check-conventions.sh
new file mode 100755
index 0000000..6f6b24c
--- /dev/null
+++ b/scripts/check-conventions.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+#
+# Быстрые grep-проверки правил core.md по застейдженным файлам src.
+# Запускается из pre-commit. Никаких зависимостей, только git + grep.
+set -euo pipefail
+
+staged=$(git diff --cached --name-only --diff-filter=ACMR)
+[ -z "$staged" ] && exit 0
+
+fail=0
+report() {
+ echo " ✗ $1"
+ fail=1
+}
+
+while IFS= read -r file; do
+ case "$file" in
+ src/*.ts | src/*.tsx) ;;
+ *) continue ;;
+ esac
+
+ # Содержимое именно из индекса — то, что реально уйдёт в коммит.
+ content=$(git show ":$file")
+
+ # §3: barrel-файлов нет, импортируем напрямую по пути к модулю.
+ case "$file" in
+ src/index.ts | src/*/index.ts)
+ report "$file: barrel index.ts запрещён, импортируй по пути (core.md §3)"
+ ;;
+ esac
+
+ # §8: стили только классами NativeWind, без StyleSheet и инлайна.
+ if printf '%s' "$content" | grep -qE 'StyleSheet|style=\{\{'; then
+ report "$file: StyleSheet/инлайн-стиль запрещён, только className NativeWind (core.md §8)"
+ fi
+
+ # §6: createClient только в shared/supabase, остальные — через requireSupabase().
+ case "$file" in
+ src/shared/supabase/*) ;;
+ *)
+ if printf '%s' "$content" | grep -qE 'createClient\('; then
+ report "$file: createClient только в shared/supabase, используй requireSupabase() (core.md §6)"
+ fi
+ ;;
+ esac
+
+ # typescript: в проекте только type, не interface (кроме деклараций *.d.ts).
+ case "$file" in
+ *.d.ts) ;;
+ *)
+ if printf '%s' "$content" | grep -qE '^[[:space:]]*(export[[:space:]]+)?interface[[:space:]]'; then
+ report "$file: используй type вместо interface (core.md typescript)"
+ fi
+ ;;
+ esac
+done < ["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 useActivityBySlug(slug?: string) {
+ return useQuery({
+ queryKey: activityKeys.slug(slug ?? ""),
+ queryFn: () => getActivityBySlug(slug ?? ""),
+ enabled: Boolean(slug),
+ });
+}
+export function useActivityByEditToken(editToken?: string) {
+ return useQuery({
+ queryKey: activityKeys.edit(editToken ?? ""),
+ queryFn: () => getActivityByEditToken(editToken ?? ""),
+ enabled: Boolean(editToken),
+ });
+}
+export function useCreateActivity() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: createActivity,
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: activityKeys.all }),
+ });
+}
+export function useUpdateActivity() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, input }: { id: string; input: UpdateActivityInput }) =>
+ updateActivity(id, input),
+ onSuccess: (activity) => {
+ queryClient.invalidateQueries({ queryKey: activityKeys.all });
+ queryClient.invalidateQueries({
+ queryKey: activityKeys.edit(activity.edit_token),
+ });
+ },
+ });
+}
diff --git a/src/entities/activity/mappers.test.ts b/src/entities/activity/mappers.test.ts
new file mode 100644
index 0000000..9c65e0c
--- /dev/null
+++ b/src/entities/activity/mappers.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("expo-crypto", () => ({
+ randomUUID: () => "123e4567-e89b-12d3-a456-426614174000",
+}));
+
+import {
+ mapCreateActivityFormToInput,
+ mapUpdateActivityFormToInput,
+} from "./mappers";
+
+describe("activity/mappers", () => {
+ it("преобразует форму создания в payload для API", () => {
+ const input = mapCreateActivityFormToInput({
+ title: "Тестовая активность",
+ description: null,
+ city: "Москва",
+ location_text: null,
+ starts_at: "2026-05-30T12:30",
+ capacity: null,
+ cover_url: null,
+ });
+
+ expect(input).toMatchObject({
+ title: "Тестовая активность",
+ description: null,
+ city: "Москва",
+ location_text: null,
+ capacity: null,
+ cover_url: null,
+ status: "active",
+ });
+ expect(input.starts_at).toBe(new Date("2026-05-30T12:30").toISOString());
+ expect(input.slug).toMatch(/^тестовая-активность-[a-z0-9]{6}$/);
+ expect(input.edit_token).toMatch(/^[a-f0-9]{32}$/);
+ });
+
+ it("сохраняет заполненные необязательные поля при создании", () => {
+ const input = mapCreateActivityFormToInput({
+ title: "Activity",
+ description: "Описание",
+ city: "Москва",
+ location_text: "Парк",
+ starts_at: "2026-05-30T12:30",
+ capacity: 10,
+ cover_url: "https://example.com/image.jpg",
+ });
+
+ expect(input.description).toBe("Описание");
+ expect(input.location_text).toBe("Парк");
+ expect(input.capacity).toBe(10);
+ expect(input.cover_url).toBe("https://example.com/image.jpg");
+ });
+
+ it("преобразует форму обновления в payload для API", () => {
+ const input = mapUpdateActivityFormToInput({
+ title: "Activity",
+ description: null,
+ city: "Москва",
+ location_text: null,
+ starts_at: "2026-05-30T12:30",
+ capacity: null,
+ cover_url: null,
+ status: "cancelled",
+ });
+
+ expect(input).toEqual({
+ title: "Activity",
+ description: null,
+ city: "Москва",
+ location_text: null,
+ starts_at: new Date("2026-05-30T12:30").toISOString(),
+ capacity: null,
+ cover_url: null,
+ status: "cancelled",
+ });
+ });
+});
diff --git a/src/entities/activity/mappers.ts b/src/entities/activity/mappers.ts
new file mode 100644
index 0000000..d51bffa
--- /dev/null
+++ b/src/entities/activity/mappers.ts
@@ -0,0 +1,52 @@
+import type {
+ CreateActivityForm,
+ UpdateActivityForm,
+ UpdateActivityFormInput,
+} from "./schemas";
+import type {
+ Activity,
+ CreateActivityInput,
+ UpdateActivityInput,
+} from "./types";
+import {
+ generateActivitySlug,
+ generateEditToken,
+ toDateTimeLocalInputValue,
+ toIsoDate,
+} from "./utils";
+
+export function mapCreateActivityFormToInput(
+ form: CreateActivityForm,
+): CreateActivityInput {
+ return {
+ ...form,
+ starts_at: toIsoDate(form.starts_at),
+ slug: generateActivitySlug(form.title),
+ edit_token: generateEditToken(),
+ status: "active",
+ };
+}
+
+export function mapUpdateActivityFormToInput(
+ form: UpdateActivityForm,
+): UpdateActivityInput {
+ return {
+ ...form,
+ starts_at: toIsoDate(form.starts_at),
+ };
+}
+
+export function mapActivityToUpdateForm(
+ activity: Activity,
+): UpdateActivityFormInput {
+ return {
+ title: activity.title,
+ description: activity.description ?? "",
+ city: activity.city,
+ location_text: activity.location_text ?? "",
+ starts_at: toDateTimeLocalInputValue(activity.starts_at),
+ capacity: activity.capacity,
+ cover_url: activity.cover_url ?? "",
+ status: activity.status,
+ };
+}
diff --git a/src/entities/activity/schemas.ts b/src/entities/activity/schemas.ts
new file mode 100644
index 0000000..45fb291
--- /dev/null
+++ b/src/entities/activity/schemas.ts
@@ -0,0 +1,35 @@
+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(),
+);
+
+export const createActivitySchema = 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(isValidActivityDateTime, "Введите корректные дату и время"),
+ capacity,
+ cover_url: coverUrl,
+});
+
+export const updateActivitySchema = createActivitySchema.extend({
+ status: z.enum(["active", "cancelled"]),
+});
+
+export type CreateActivityFormInput = z.input;
+export type CreateActivityForm = z.output;
+export type UpdateActivityFormInput = z.input;
+export type UpdateActivityForm = z.output;
diff --git a/src/entities/activity/types.ts b/src/entities/activity/types.ts
new file mode 100644
index 0000000..4db8b5e
--- /dev/null
+++ b/src/entities/activity/types.ts
@@ -0,0 +1,34 @@
+export type ActivityStatus = "active" | "cancelled";
+
+export type Activity = {
+ 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;
+ edit_token: string;
+ status: ActivityStatus;
+ created_at: string;
+ updated_at: string;
+};
+
+export type CreateActivityInput = {
+ slug: string;
+ title: string;
+ description?: string | null;
+ city: string;
+ location_text?: string | null;
+ starts_at: string;
+ capacity?: number | null;
+ cover_url?: string | null;
+ edit_token: string;
+ status: ActivityStatus;
+};
+
+export type UpdateActivityInput = Partial<
+ Omit
+>;
diff --git a/src/entities/activity/utils.test.ts b/src/entities/activity/utils.test.ts
new file mode 100644
index 0000000..67c646e
--- /dev/null
+++ b/src/entities/activity/utils.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("expo-crypto", () => ({
+ randomUUID: () => "123e4567-e89b-12d3-a456-426614174000",
+}));
+
+import {
+ generateActivitySlug,
+ generateEditToken,
+ getNextActivityStatus,
+ isValidActivityDateTime,
+ slugifyTitle,
+ toDateTimeLocalInputValue,
+ toIsoDate,
+} 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("возвращает следующий статус активности", () => {
+ expect(getNextActivityStatus("active")).toBe("cancelled");
+ expect(getNextActivityStatus("cancelled")).toBe("active");
+ });
+});
diff --git a/src/entities/activity/utils.ts b/src/entities/activity/utils.ts
new file mode 100644
index 0000000..063d794
--- /dev/null
+++ b/src/entities/activity/utils.ts
@@ -0,0 +1,66 @@
+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: "отменена",
+};
+
+export const activityStatusOptions = (
+ Object.keys(activityStatusLabels) as ActivityStatus[]
+).map((value) => ({ value, label: activityStatusLabels[value] }));
+
+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;
+}
+
+function shortRandom() {
+ return Math.random().toString(36).slice(2, 8);
+}
+
+function padDatePart(value: number) {
+ return String(value).padStart(2, "0");
+}
diff --git a/src/entities/participant/api.ts b/src/entities/participant/api.ts
new file mode 100644
index 0000000..24edf58
--- /dev/null
+++ b/src/entities/participant/api.ts
@@ -0,0 +1,22 @@
+import { requireSupabase } from "../../shared/supabase/client";
+import type { CreateParticipantInput, Participant } from "./types";
+
+export async function listParticipants(activityId: string) {
+ const { data, error } = await requireSupabase()
+ .from("participants")
+ .select("*")
+ .eq("activity_id", activityId)
+ .order("created_at", { ascending: false });
+ if (error) throw error;
+ return (data ?? []) as Participant[];
+}
+
+export async function createParticipant(input: CreateParticipantInput) {
+ const { data, error } = await requireSupabase()
+ .from("participants")
+ .insert(input)
+ .select("*")
+ .single();
+ if (error) throw error;
+ return data as Participant;
+}
diff --git a/src/entities/participant/hooks.ts b/src/entities/participant/hooks.ts
new file mode 100644
index 0000000..a4b1990
--- /dev/null
+++ b/src/entities/participant/hooks.ts
@@ -0,0 +1,25 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { createParticipant, listParticipants } from "./api";
+
+export const participantKeys = {
+ list: (activityId: string) => ["participants", activityId] as const,
+};
+
+export function useParticipants(activityId?: string) {
+ return useQuery({
+ queryKey: participantKeys.list(activityId ?? ""),
+ queryFn: () => listParticipants(activityId ?? ""),
+ enabled: Boolean(activityId),
+ });
+}
+
+export function useCreateParticipant() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: createParticipant,
+ onSuccess: (_data, variables) =>
+ queryClient.invalidateQueries({
+ queryKey: participantKeys.list(variables.activity_id),
+ }),
+ });
+}
diff --git a/src/entities/participant/mappers.test.ts b/src/entities/participant/mappers.test.ts
new file mode 100644
index 0000000..972e4b7
--- /dev/null
+++ b/src/entities/participant/mappers.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { mapParticipantFormToInput } from "./mappers";
+
+describe("participant/mappers", () => {
+ it("преобразует форму отклика в payload для API", () => {
+ expect(
+ mapParticipantFormToInput("activity-1", {
+ name: "Анна",
+ telegram: "",
+ status: "going",
+ comment: "",
+ }),
+ ).toEqual({
+ activity_id: "activity-1",
+ name: "Анна",
+ telegram: null,
+ status: "going",
+ comment: null,
+ });
+ });
+
+ it("сохраняет заполненные необязательные поля", () => {
+ expect(
+ mapParticipantFormToInput("activity-1", {
+ name: "Анна",
+ telegram: "@anna",
+ status: "maybe",
+ comment: "Буду позже",
+ }),
+ ).toEqual({
+ activity_id: "activity-1",
+ name: "Анна",
+ telegram: "@anna",
+ status: "maybe",
+ comment: "Буду позже",
+ });
+ });
+});
diff --git a/src/entities/participant/mappers.ts b/src/entities/participant/mappers.ts
new file mode 100644
index 0000000..561311a
--- /dev/null
+++ b/src/entities/participant/mappers.ts
@@ -0,0 +1,14 @@
+import type { ParticipantForm } from "./schemas";
+import type { CreateParticipantInput } from "./types";
+
+export function mapParticipantFormToInput(
+ activityId: string,
+ form: ParticipantForm,
+): CreateParticipantInput {
+ return {
+ activity_id: activityId,
+ ...form,
+ telegram: form.telegram || null,
+ comment: form.comment || null,
+ };
+}
diff --git a/src/entities/participant/schemas.ts b/src/entities/participant/schemas.ts
new file mode 100644
index 0000000..d175473
--- /dev/null
+++ b/src/entities/participant/schemas.ts
@@ -0,0 +1,10 @@
+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(),
+});
+
+export type ParticipantForm = z.infer;
diff --git a/src/entities/participant/types.ts b/src/entities/participant/types.ts
new file mode 100644
index 0000000..6fba2b1
--- /dev/null
+++ b/src/entities/participant/types.ts
@@ -0,0 +1,20 @@
+export type ParticipantStatus = "going" | "maybe" | "cant";
+
+export type Participant = {
+ id: string;
+ activity_id: string;
+ name: string;
+ telegram: string | null;
+ status: ParticipantStatus;
+ comment: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export type CreateParticipantInput = {
+ activity_id: string;
+ name: string;
+ telegram?: string | null;
+ status: ParticipantStatus;
+ comment?: string | null;
+};
diff --git a/src/entities/participant/utils.test.ts b/src/entities/participant/utils.test.ts
new file mode 100644
index 0000000..bf55e9d
--- /dev/null
+++ b/src/entities/participant/utils.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import type { Participant } from "./types";
+import { getParticipantStats, getParticipantStatusText } from "./utils";
+
+const participants: Participant[] = [
+ {
+ id: "1",
+ activity_id: "a1",
+ name: "Анна",
+ telegram: null,
+ status: "going",
+ comment: null,
+ created_at: "",
+ updated_at: "",
+ },
+ {
+ id: "2",
+ activity_id: "a1",
+ name: "Борис",
+ telegram: null,
+ status: "maybe",
+ comment: null,
+ created_at: "",
+ updated_at: "",
+ },
+ {
+ id: "3",
+ activity_id: "a1",
+ name: "Вера",
+ telegram: null,
+ status: "going",
+ comment: null,
+ created_at: "",
+ updated_at: "",
+ },
+];
+
+describe("participant/utils", () => {
+ it("возвращает русский текст статуса", () => {
+ expect(getParticipantStatusText("going")).toBe("иду");
+ expect(getParticipantStatusText("maybe")).toBe("может быть");
+ expect(getParticipantStatusText("cant")).toBe("не могу");
+ expect(getParticipantStatusText("unknown")).toBe("unknown");
+ });
+
+ it("возвращает статистику участников", () => {
+ expect(getParticipantStats(participants)).toEqual({
+ going: 2,
+ maybe: 1,
+ cant: 0,
+ });
+ });
+});
diff --git a/src/entities/participant/utils.ts b/src/entities/participant/utils.ts
new file mode 100644
index 0000000..0454f66
--- /dev/null
+++ b/src/entities/participant/utils.ts
@@ -0,0 +1,25 @@
+import type { Participant, ParticipantStatus } from "./types";
+
+export const participantStatusLabels: Record = {
+ going: "иду",
+ maybe: "может быть",
+ 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;
+}
+
+export function getParticipantStats(participants: Participant[]) {
+ const stats = { going: 0, maybe: 0, cant: 0 };
+ for (const { status } of participants) stats[status] += 1;
+ return stats;
+}
+
+function isParticipantStatus(status: string): status is ParticipantStatus {
+ return Object.hasOwn(participantStatusLabels, status);
+}
diff --git a/src/features/create-activity/useCreateActivityForm.ts b/src/features/create-activity/useCreateActivityForm.ts
new file mode 100644
index 0000000..94bc469
--- /dev/null
+++ b/src/features/create-activity/useCreateActivityForm.ts
@@ -0,0 +1,46 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useRouter } from "expo-router";
+import { useForm } from "react-hook-form";
+import { useCreateActivity } from "../../entities/activity/hooks";
+import { mapCreateActivityFormToInput } from "../../entities/activity/mappers";
+import {
+ type CreateActivityForm,
+ type CreateActivityFormInput,
+ createActivitySchema,
+} from "../../entities/activity/schemas";
+import { setFormServerError } from "../../shared/lib/setFormServerError";
+
+export function useCreateActivityForm() {
+ const router = useRouter();
+ const mutation = useCreateActivity();
+ const form = useForm({
+ resolver: zodResolver(createActivitySchema),
+ defaultValues: {
+ title: "",
+ description: "",
+ city: "",
+ location_text: "",
+ starts_at: "",
+ capacity: null,
+ cover_url: "",
+ },
+ });
+
+ async function submit(values: CreateActivityForm) {
+ try {
+ form.clearErrors("root.server");
+ const activity = await mutation.mutateAsync(
+ mapCreateActivityFormToInput(values),
+ );
+ router.replace(`/manage/${activity.edit_token}`);
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось создать активность", error);
+ }
+ }
+
+ return {
+ form,
+ submit,
+ isSaving: mutation.isPending,
+ };
+}
diff --git a/src/features/manage-activity/ManageActivityForm.tsx b/src/features/manage-activity/ManageActivityForm.tsx
new file mode 100644
index 0000000..fd6178b
--- /dev/null
+++ b/src/features/manage-activity/ManageActivityForm.tsx
@@ -0,0 +1,107 @@
+import { Controller, type UseFormReturn } from "react-hook-form";
+import { Text, View } from "react-native";
+import type {
+ UpdateActivityForm,
+ UpdateActivityFormInput,
+} from "../../entities/activity/schemas";
+import type { ActivityStatus } from "../../entities/activity/types";
+import { activityStatusOptions } from "../../entities/activity/utils";
+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 { Select } from "../../shared/ui/Select";
+import { Textarea } from "../../shared/ui/Textarea";
+
+type Props = {
+ form: UseFormReturn;
+ status: ActivityStatus;
+ isSaving: boolean;
+ onSubmit: (values: UpdateActivityForm) => void;
+ onToggleStatus: () => void;
+};
+
+export function ManageActivityForm({
+ form,
+ status,
+ isSaving,
+ onSubmit,
+ onToggleStatus,
+}: Props) {
+ const { control, handleSubmit } = form;
+ const { errors } = form.formState;
+
+ return (
+
+ Форма управления
+
+
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+ {errors.root?.server?.message}
+
+ );
+}
diff --git a/src/features/manage-activity/useManageActivityForm.ts b/src/features/manage-activity/useManageActivityForm.ts
new file mode 100644
index 0000000..3d34c4f
--- /dev/null
+++ b/src/features/manage-activity/useManageActivityForm.ts
@@ -0,0 +1,85 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useLocalSearchParams } from "expo-router";
+import { useForm } from "react-hook-form";
+import {
+ useActivityByEditToken,
+ useUpdateActivity,
+} from "../../entities/activity/hooks";
+import {
+ mapActivityToUpdateForm,
+ mapUpdateActivityFormToInput,
+} from "../../entities/activity/mappers";
+import {
+ type UpdateActivityForm,
+ type UpdateActivityFormInput,
+ updateActivitySchema,
+} from "../../entities/activity/schemas";
+import { getNextActivityStatus } from "../../entities/activity/utils";
+import { useParticipants } from "../../entities/participant/hooks";
+import { buildActivityPublicUrl } from "../../shared/lib/buildActivityPublicUrl";
+import { setFormServerError } from "../../shared/lib/setFormServerError";
+
+export function useManageActivityForm() {
+ const { editToken } = useLocalSearchParams<{ editToken: string }>();
+ const activityQuery = useActivityByEditToken(editToken);
+ const activity = activityQuery.data;
+ const participantsQuery = useParticipants(activity?.id);
+ const mutation = useUpdateActivity();
+ const form = useForm({
+ resolver: zodResolver(updateActivitySchema),
+ values: activity ? mapActivityToUpdateForm(activity) : undefined,
+ resetOptions: { keepDirtyValues: true },
+ });
+
+ const publicUrl = activity ? buildActivityPublicUrl(activity.slug) : "";
+
+ async function submit(values: UpdateActivityForm) {
+ if (!activity) return;
+
+ try {
+ form.clearErrors("root.server");
+ await mutation.mutateAsync({
+ id: activity.id,
+ input: mapUpdateActivityFormToInput(values),
+ });
+ } catch (error) {
+ setFormServerError(
+ form.setError,
+ "Не удалось сохранить активность",
+ error,
+ );
+ }
+ }
+
+ async function toggleStatus() {
+ if (!activity) return;
+
+ const previous = activity.status;
+ const next = getNextActivityStatus(previous);
+ form.setValue("status", next);
+ try {
+ form.clearErrors("root.server");
+ await mutation.mutateAsync({ id: activity.id, input: { status: next } });
+ } catch (error) {
+ form.setValue("status", previous);
+ setFormServerError(
+ form.setError,
+ "Не удалось изменить статус активности",
+ error,
+ );
+ }
+ }
+
+ return {
+ activity,
+ isLoading: activityQuery.isLoading,
+ loadError: activityQuery.error,
+ participants: participantsQuery.data ?? [],
+ participantsLoading: participantsQuery.isLoading,
+ publicUrl,
+ form,
+ submit,
+ toggleStatus,
+ isSaving: mutation.isPending,
+ };
+}
diff --git a/src/features/respond-to-activity/ResponseForm.tsx b/src/features/respond-to-activity/ResponseForm.tsx
new file mode 100644
index 0000000..f029743
--- /dev/null
+++ b/src/features/respond-to-activity/ResponseForm.tsx
@@ -0,0 +1,64 @@
+import { Controller, 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;
+ isSaving: boolean;
+ onSubmit: (values: ParticipantForm) => void;
+};
+
+export function ResponseForm({ form, isSaving, onSubmit }: Props) {
+ const { control, handleSubmit } = form;
+ const { errors } = form.formState;
+
+ return (
+
+ Форма отклика
+
+
+ (
+
+ )}
+ />
+
+
+ {errors.root?.server?.message}
+
+ );
+}
diff --git a/src/features/respond-to-activity/useRespondToActivityForm.ts b/src/features/respond-to-activity/useRespondToActivityForm.ts
new file mode 100644
index 0000000..6bc01f7
--- /dev/null
+++ b/src/features/respond-to-activity/useRespondToActivityForm.ts
@@ -0,0 +1,68 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useLocalSearchParams } from "expo-router";
+import { useForm } from "react-hook-form";
+import { useActivityBySlug } from "../../entities/activity/hooks";
+import {
+ useCreateParticipant,
+ useParticipants,
+} from "../../entities/participant/hooks";
+import { mapParticipantFormToInput } from "../../entities/participant/mappers";
+import {
+ type ParticipantForm,
+ participantSchema,
+} from "../../entities/participant/schemas";
+import { getParticipantStats } from "../../entities/participant/utils";
+import { setFormServerError } from "../../shared/lib/setFormServerError";
+import { useRespondedFlag } from "./useRespondedFlag";
+
+const emptyResponse: ParticipantForm = {
+ name: "",
+ telegram: "",
+ status: "going",
+ comment: "",
+};
+
+export function useRespondToActivityForm() {
+ const { slug } = useLocalSearchParams<{ slug: string }>();
+ const activityQuery = useActivityBySlug(slug);
+ const activity = activityQuery.data;
+ const participantsQuery = useParticipants(activity?.id);
+ const mutation = useCreateParticipant();
+ const form = useForm({
+ resolver: zodResolver(participantSchema),
+ defaultValues: emptyResponse,
+ });
+
+ const { hasResponded, markResponded } = useRespondedFlag(activity?.id);
+
+ const participants = participantsQuery.data ?? [];
+ const stats = getParticipantStats(participants);
+
+ async function submit(values: ParticipantForm) {
+ if (!activity) return;
+
+ try {
+ form.clearErrors("root.server");
+ await mutation.mutateAsync(
+ mapParticipantFormToInput(activity.id, values),
+ );
+ form.reset(emptyResponse);
+ await markResponded();
+ } catch (error) {
+ setFormServerError(form.setError, "Не удалось отправить отклик", error);
+ }
+ }
+
+ return {
+ activity,
+ isLoading: activityQuery.isLoading,
+ loadError: activityQuery.error,
+ participants,
+ participantsLoading: participantsQuery.isLoading,
+ stats,
+ form,
+ submit,
+ isSaving: mutation.isPending,
+ hasResponded,
+ };
+}
diff --git a/src/features/respond-to-activity/useRespondedFlag.ts b/src/features/respond-to-activity/useRespondedFlag.ts
new file mode 100644
index 0000000..e73be01
--- /dev/null
+++ b/src/features/respond-to-activity/useRespondedFlag.ts
@@ -0,0 +1,37 @@
+import { useEffect, useState } from "react";
+import { readDeviceFlag, writeDeviceFlag } from "../../shared/lib/storage";
+
+// Локальная защита от повторного отклика: один отклик на устройство.
+// Настоящая планка появится вместе с аутентификацией.
+const respondedKey = (activityId: string) => `responded:${activityId}`;
+
+export function useRespondedFlag(activityId?: string) {
+ const [hasResponded, setHasResponded] = useState(false);
+
+ useEffect(() => {
+ if (activityId == null) return;
+ let active = true;
+ readDeviceFlag(respondedKey(activityId))
+ .then((responded) => {
+ if (active) setHasResponded(responded);
+ })
+ // Флаг не критичен: ошибка чтения = считаем, что отклика не было.
+ .catch(() => {
+ if (active) setHasResponded(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [activityId]);
+
+ async function markResponded() {
+ if (activityId == null) return;
+ setHasResponded(true);
+ // Сбой записи не должен ронять успешный отклик: флаг переживёт только сессию.
+ try {
+ await writeDeviceFlag(respondedKey(activityId));
+ } catch {}
+ }
+
+ return { hasResponded, markResponded };
+}
diff --git a/src/shared/errors/getRussianErrorMessage.test.ts b/src/shared/errors/getRussianErrorMessage.test.ts
new file mode 100644
index 0000000..ab34f28
--- /dev/null
+++ b/src/shared/errors/getRussianErrorMessage.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { getRussianErrorMessage } from "./getRussianErrorMessage";
+
+describe("getRussianErrorMessage", () => {
+ it("переводит известные технические ошибки", () => {
+ expect(
+ getRussianErrorMessage(
+ new Error(
+ 'invalid input syntax for type timestamp with time zone: "12 мая"',
+ ),
+ { fallback: "Ошибка" },
+ ),
+ ).toBe("Введите корректные дату и время");
+
+ expect(
+ getRussianErrorMessage(new Error("Network request failed"), {
+ fallback: "Ошибка",
+ }),
+ ).toBe("Ошибка сети. Проверьте подключение к интернету");
+ });
+
+ it("возвращает fallback для неизвестной ошибки", () => {
+ expect(
+ getRussianErrorMessage(new Error("Some backend error"), {
+ fallback: "Ошибка",
+ }),
+ ).toBe("Ошибка");
+ expect(getRussianErrorMessage("строка", { fallback: "Ошибка" })).toBe(
+ "Ошибка",
+ );
+ });
+});
diff --git a/src/shared/errors/getRussianErrorMessage.ts b/src/shared/errors/getRussianErrorMessage.ts
new file mode 100644
index 0000000..e94fa43
--- /dev/null
+++ b/src/shared/errors/getRussianErrorMessage.ts
@@ -0,0 +1,34 @@
+type ErrorMessageOptions = {
+ fallback: string;
+};
+
+const knownErrorMessages: Array<[RegExp, string]> = [
+ [
+ /invalid input syntax for type timestamp/i,
+ "Введите корректные дату и время",
+ ],
+ [/network request failed/i, "Ошибка сети. Проверьте подключение к интернету"],
+ [/failed to fetch/i, "Ошибка сети. Не удалось получить данные"],
+ [/jwt expired/i, "Сессия истекла. Войдите заново"],
+ [/permission denied/i, "Недостаточно прав для выполнения действия"],
+ [/duplicate key/i, "Такая запись уже существует"],
+ [/violates foreign key constraint/i, "Связанная запись не найдена"],
+ [/violates not-null constraint/i, "Заполните обязательные поля"],
+];
+
+export function getRussianErrorMessage(
+ error: unknown,
+ { fallback }: ErrorMessageOptions,
+) {
+ if (!(error instanceof Error)) return fallback;
+
+ const message = error.message.trim();
+ if (!message) return fallback;
+
+ const knownMessage = knownErrorMessages.find(([pattern]) =>
+ pattern.test(message),
+ );
+ if (knownMessage) return knownMessage[1];
+
+ return fallback;
+}
diff --git a/src/shared/lib/blurActiveElement.ts b/src/shared/lib/blurActiveElement.ts
new file mode 100644
index 0000000..b38e9d4
--- /dev/null
+++ b/src/shared/lib/blurActiveElement.ts
@@ -0,0 +1,10 @@
+import { Platform } from "react-native";
+
+export function blurActiveElement() {
+ if (Platform.OS !== "web" || typeof document === "undefined") return;
+
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLElement) {
+ activeElement.blur();
+ }
+}
diff --git a/src/shared/lib/buildActivityPublicUrl.ts b/src/shared/lib/buildActivityPublicUrl.ts
new file mode 100644
index 0000000..e8ade23
--- /dev/null
+++ b/src/shared/lib/buildActivityPublicUrl.ts
@@ -0,0 +1,8 @@
+import { Platform } from "react-native";
+
+export function buildActivityPublicUrl(slug: string) {
+ const path = `/a/${slug}`;
+ if (Platform.OS !== "web" || typeof window === "undefined") return path;
+
+ return `${window.location.origin}${path}`;
+}
diff --git a/src/shared/lib/setFormServerError.ts b/src/shared/lib/setFormServerError.ts
new file mode 100644
index 0000000..4320d81
--- /dev/null
+++ b/src/shared/lib/setFormServerError.ts
@@ -0,0 +1,12 @@
+import type { FieldValues, UseFormSetError } from "react-hook-form";
+import { getRussianErrorMessage } from "../errors/getRussianErrorMessage";
+
+export function setFormServerError(
+ setError: UseFormSetError,
+ fallback: string,
+ error: unknown,
+) {
+ setError("root.server", {
+ message: getRussianErrorMessage(error, { fallback }),
+ });
+}
diff --git a/src/shared/lib/storage.ts b/src/shared/lib/storage.ts
new file mode 100644
index 0000000..3938a4f
--- /dev/null
+++ b/src/shared/lib/storage.ts
@@ -0,0 +1,12 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+
+// Локальное хранилище устройства (на web — localStorage).
+// Булевы флаги вида «это устройство уже сделало X», без серверной проверки.
+export async function readDeviceFlag(key: string) {
+ const value = await AsyncStorage.getItem(key);
+ return value === "1";
+}
+
+export async function writeDeviceFlag(key: string) {
+ await AsyncStorage.setItem(key, "1");
+}
diff --git a/src/shared/supabase/client.ts b/src/shared/supabase/client.ts
new file mode 100644
index 0000000..efb0064
--- /dev/null
+++ b/src/shared/supabase/client.ts
@@ -0,0 +1,26 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { createClient } from "@supabase/supabase-js";
+
+const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
+const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_KEY;
+
+export const supabase =
+ supabaseUrl && supabaseAnonKey
+ ? createClient(supabaseUrl, supabaseAnonKey, {
+ auth: {
+ storage: AsyncStorage,
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: false,
+ },
+ })
+ : null;
+
+export function requireSupabase() {
+ if (!supabase) {
+ throw new Error(
+ "Supabase не настроен. Добавь EXPO_PUBLIC_SUPABASE_URL и EXPO_PUBLIC_SUPABASE_KEY.",
+ );
+ }
+ return supabase;
+}
diff --git a/src/shared/ui/ActivityNotFound.tsx b/src/shared/ui/ActivityNotFound.tsx
new file mode 100644
index 0000000..86897e5
--- /dev/null
+++ b/src/shared/ui/ActivityNotFound.tsx
@@ -0,0 +1,14 @@
+import { Text } from "react-native";
+import { BackLink } from "./BackLink";
+import { ErrorText } from "./ErrorText";
+import { Page } from "./Page";
+
+export function ActivityNotFound({ error }: { error?: unknown }) {
+ return (
+
+
+ Активность не найдена
+
+
+ );
+}
diff --git a/src/shared/ui/BackLink.tsx b/src/shared/ui/BackLink.tsx
new file mode 100644
index 0000000..2cfce95
--- /dev/null
+++ b/src/shared/ui/BackLink.tsx
@@ -0,0 +1,5 @@
+import { TextLink } from "./TextLink";
+
+export function BackLink() {
+ return ← На главную;
+}
diff --git a/src/shared/ui/Button.tsx b/src/shared/ui/Button.tsx
new file mode 100644
index 0000000..a88fee6
--- /dev/null
+++ b/src/shared/ui/Button.tsx
@@ -0,0 +1,18 @@
+import type { ComponentProps } from "react";
+import { Pressable, Text } from "react-native";
+
+type ButtonProps = ComponentProps & {
+ title?: string;
+ children?: never;
+};
+
+export function Button({ title, ...props }: ButtonProps) {
+ return (
+
+ {title}
+
+ );
+}
diff --git a/src/shared/ui/DateTimeInput.tsx b/src/shared/ui/DateTimeInput.tsx
new file mode 100644
index 0000000..1eae390
--- /dev/null
+++ b/src/shared/ui/DateTimeInput.tsx
@@ -0,0 +1,47 @@
+import type { ChangeEvent } from "react";
+import { Platform } from "react-native";
+import { FieldShell } from "./FieldShell";
+import { Input, type InputProps } from "./Input";
+
+export function DateTimeInput({
+ label,
+ error,
+ value,
+ onChangeText,
+ placeholder = "ГГГГ-ММ-ДД ЧЧ:ММ",
+ nativeID,
+ ...props
+}: InputProps) {
+ if (Platform.OS === "web") {
+ const inputId = nativeID ? String(nativeID) : undefined;
+
+ return (
+
+ ) =>
+ onChangeText?.(event.target.value)
+ }
+ />
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/shared/ui/ErrorText.tsx b/src/shared/ui/ErrorText.tsx
new file mode 100644
index 0000000..93c6ebc
--- /dev/null
+++ b/src/shared/ui/ErrorText.tsx
@@ -0,0 +1,16 @@
+import { Text } from "react-native";
+import { getRussianErrorMessage } from "../errors/getRussianErrorMessage";
+
+type Props = {
+ error?: unknown;
+ fallback?: string;
+ children?: string | null;
+};
+
+export function ErrorText({ error, fallback = "", children }: Props) {
+ const message =
+ error != null ? getRussianErrorMessage(error, { fallback }) : children;
+ if (!message) return null;
+
+ return {message};
+}
diff --git a/src/shared/ui/FieldShell.tsx b/src/shared/ui/FieldShell.tsx
new file mode 100644
index 0000000..113d7ff
--- /dev/null
+++ b/src/shared/ui/FieldShell.tsx
@@ -0,0 +1,18 @@
+import type { ReactNode } from "react";
+import { Text, View } from "react-native";
+
+type Props = {
+ label: ReactNode;
+ error?: string;
+ children: ReactNode;
+};
+
+export function FieldShell({ label, error, children }: Props) {
+ return (
+
+ {label}
+ {children}
+ {error ? {error} : null}
+
+ );
+}
diff --git a/src/shared/ui/FormField.tsx b/src/shared/ui/FormField.tsx
new file mode 100644
index 0000000..b2ac7ae
--- /dev/null
+++ b/src/shared/ui/FormField.tsx
@@ -0,0 +1,40 @@
+import type { ComponentType } from "react";
+import {
+ type Control,
+ Controller,
+ type FieldValues,
+ type Path,
+} from "react-hook-form";
+import { Input, type InputProps } from "./Input";
+
+type Props = {
+ control: Control;
+ name: Path;
+ label: string;
+ error?: string;
+ component?: ComponentType;
+};
+
+export function FormField({
+ control,
+ name,
+ label,
+ error,
+ component: Field = Input,
+}: Props) {
+ return (
+ (
+
+ )}
+ />
+ );
+}
diff --git a/src/shared/ui/Input.tsx b/src/shared/ui/Input.tsx
new file mode 100644
index 0000000..a479b9e
--- /dev/null
+++ b/src/shared/ui/Input.tsx
@@ -0,0 +1,16 @@
+import type { ComponentProps, ReactNode } from "react";
+import { TextInput } from "react-native";
+import { FieldShell } from "./FieldShell";
+
+export type InputProps = ComponentProps & {
+ label: ReactNode;
+ error?: string;
+};
+
+export function Input({ label, error, ...props }: InputProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/shared/ui/LoadingPage.tsx b/src/shared/ui/LoadingPage.tsx
new file mode 100644
index 0000000..8eeffa9
--- /dev/null
+++ b/src/shared/ui/LoadingPage.tsx
@@ -0,0 +1,10 @@
+import { LoadingText } from "./LoadingText";
+import { Page } from "./Page";
+
+export function LoadingPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/shared/ui/LoadingText.tsx b/src/shared/ui/LoadingText.tsx
new file mode 100644
index 0000000..39cbc26
--- /dev/null
+++ b/src/shared/ui/LoadingText.tsx
@@ -0,0 +1,4 @@
+import { Text } from "react-native";
+export function LoadingText() {
+ return Загрузка...;
+}
diff --git a/src/shared/ui/Page.tsx b/src/shared/ui/Page.tsx
new file mode 100644
index 0000000..44a8cd6
--- /dev/null
+++ b/src/shared/ui/Page.tsx
@@ -0,0 +1,10 @@
+import type { ReactNode } from "react";
+import { ScrollView, View } from "react-native";
+
+export function Page({ children }: { children?: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/shared/ui/ParticipantList.tsx b/src/shared/ui/ParticipantList.tsx
new file mode 100644
index 0000000..e986f66
--- /dev/null
+++ b/src/shared/ui/ParticipantList.tsx
@@ -0,0 +1,32 @@
+import { Text } from "react-native";
+import { LoadingText } from "./LoadingText";
+
+type ParticipantRow = {
+ id: string;
+ name: string;
+ status: string;
+ telegram: string | null;
+ comment: string | null;
+};
+
+type Props = {
+ participants: ParticipantRow[];
+ loading: boolean;
+ statusText: (status: string) => string;
+};
+
+export function ParticipantList({ participants, loading, statusText }: Props) {
+ return (
+ <>
+ Список участников
+ {loading ? : null}
+ {participants.length === 0 ? Откликов пока нет. : null}
+ {participants.map((participant) => (
+
+ - {participant.name} / {statusText(participant.status)} /{" "}
+ {participant.telegram || ""} / {participant.comment || ""}
+
+ ))}
+ >
+ );
+}
diff --git a/src/shared/ui/Select.tsx b/src/shared/ui/Select.tsx
new file mode 100644
index 0000000..b85627c
--- /dev/null
+++ b/src/shared/ui/Select.tsx
@@ -0,0 +1,39 @@
+import type { ReactNode } from "react";
+import { View } from "react-native";
+import { Button } from "./Button";
+import { FieldShell } from "./FieldShell";
+
+type SelectOption = {
+ value: Value;
+ label: ReactNode;
+};
+
+type SelectProps = {
+ label: ReactNode;
+ value: Value;
+ onChange: (value: Value) => void;
+ options: SelectOption[];
+ error?: string;
+};
+
+export function Select({
+ label,
+ value,
+ onChange,
+ options,
+ error,
+}: SelectProps) {
+ return (
+
+
+ {options.map((option) => (
+
+
+ );
+}
diff --git a/src/shared/ui/TextLink.tsx b/src/shared/ui/TextLink.tsx
new file mode 100644
index 0000000..5b23cf5
--- /dev/null
+++ b/src/shared/ui/TextLink.tsx
@@ -0,0 +1,17 @@
+import { Link } from "expo-router";
+import type { ComponentProps } from "react";
+import { Text } from "react-native";
+import { blurActiveElement } from "../lib/blurActiveElement";
+
+type Props = {
+ href: ComponentProps["href"];
+ children: string;
+};
+
+export function TextLink({ href, children }: Props) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/shared/ui/Textarea.tsx b/src/shared/ui/Textarea.tsx
new file mode 100644
index 0000000..7696375
--- /dev/null
+++ b/src/shared/ui/Textarea.tsx
@@ -0,0 +1,7 @@
+import { Input, type InputProps } from "./Input";
+
+export function Textarea(props: InputProps) {
+ return (
+
+ );
+}
diff --git a/src/utils/supabase.ts b/src/utils/supabase.ts
deleted file mode 100644
index 29a2e34..0000000
--- a/src/utils/supabase.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { createClient } from "@supabase/supabase-js";
-
-const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
-const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_KEY;
-
-export const isSupabaseConfigured = Boolean(supabaseUrl && supabaseAnonKey);
-
-export const supabase = isSupabaseConfigured
- ? createClient(supabaseUrl!, supabaseAnonKey!, {
- auth: {
- storage: AsyncStorage,
- autoRefreshToken: true,
- persistSession: true,
- detectSessionInUrl: false,
- },
- })
- : null;
-
-export type SupabaseConnectionResult = {
- ok: boolean;
- message: string;
-};
-
-export async function checkSupabaseConnection(): Promise {
- if (!supabaseUrl || !supabaseAnonKey) {
- return {
- ok: false,
- message:
- "Missing EXPO_PUBLIC_SUPABASE_URL or EXPO_PUBLIC_SUPABASE_KEY. Add them to .env and restart Expo.",
- };
- }
-
- if (!supabase) {
- return { ok: false, message: "Supabase client could not be created." };
- }
-
- try {
- const { error } = await supabase
- .from("__connection_check__")
- .select("*")
- .limit(1);
-
- if (!error) {
- return { ok: true, message: "Connected to Supabase." };
- }
-
- // A missing table error still proves the URL/key reached Supabase correctly.
- if (error.code === "PGRST205" || error.code === "42P01") {
- return { ok: true, message: "Connected to Supabase REST API." };
- }
-
- return {
- ok: false,
- message: `Supabase responded: ${error.message}`,
- };
- } catch (error) {
- return {
- ok: false,
- message: error instanceof Error ? error.message : "Unable to reach Supabase.",
- };
- }
-}