diff --git a/README.md b/README.md
index c32ea5a..b2c4a57 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,58 @@
-# Second
\ No newline at end of file
+# Fetch Squads MVP
+
+## Шаг 0. Настройка окружения на Windows
+1. **Установите Node.js** с сайта [https://nodejs.org/](https://nodejs.org/). Проверьте установку командой `node -v` в новой консоли PowerShell.
+2. **Создайте проект React на базе Vite**:
+ ```bash
+ npm create vite@latest fetch-squads-mvp -- --template react
+ ```
+3. **Перейдите в директорию проекта и установите зависимости**:
+ ```bash
+ cd fetch-squads-mvp
+ npm install firebase react-router-dom tailwindcss postcss autoprefixer
+ npx tailwindcss init -p
+ ```
+4. **Настройте `tailwind.config.js`** — добавьте пути к файлам исходников:
+ ```js
+ export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+ };
+ ```
+5. **Создайте проект в [Firebase Console](https://console.firebase.google.com/)**:
+ - Добавьте Web App и скопируйте настройки Firebase (они понадобятся в `src/firebase/config.js`).
+ - Включите **Authentication** с методом Email/Password.
+ - Создайте **Cloud Firestore** в режиме production или тестовом (на ваше усмотрение).
+6. **Установите Firebase CLI и инициализируйте сервисы**:
+ ```bash
+ npm install -g firebase-tools
+ firebase login
+ firebase init firestore
+ firebase init functions
+ ```
+ При инициализации выберите существующий проект, укажите использование JavaScript для функций и разрешите установку зависимостей.
+
+## Переменные окружения
+Создайте файл `.env` в корне проекта и заполните его значениями из Firebase Console:
+```bash
+VITE_FIREBASE_API_KEY=...
+VITE_FIREBASE_AUTH_DOMAIN=...
+VITE_FIREBASE_PROJECT_ID=...
+VITE_FIREBASE_STORAGE_BUCKET=...
+VITE_FIREBASE_MESSAGING_SENDER_ID=...
+VITE_FIREBASE_APP_ID=...
+VITE_CLOUD_FUNCTIONS_BASE_URL=https://us-central1-<ваш-project-id>.cloudfunctions.net
+```
+При локальном запуске функций через `firebase emulators:start` замените `VITE_CLOUD_FUNCTIONS_BASE_URL` на URL эмулятора, например `http://localhost:5001/<ваш-project-id>/us-central1`.
+
+## Шаг 8. Финальные команды запуска
+- Деплой облачных функций: `firebase deploy --only functions`
+- Запуск локального сервера разработки: `npm run dev`
+
+Прототип будет доступен по адресу [http://localhost:5173](http://localhost:5173) в любом современном браузере и на любой ОС.
diff --git a/functions/index.js b/functions/index.js
new file mode 100644
index 0000000..9c015fd
--- /dev/null
+++ b/functions/index.js
@@ -0,0 +1,73 @@
+const functions = require("firebase-functions");
+const admin = require("firebase-admin");
+
+if (!admin.apps.length) {
+ admin.initializeApp();
+}
+
+exports.shareReceiptPoints = functions.https.onRequest(async (req, res) => {
+ res.set("Access-Control-Allow-Origin", "*");
+ res.set("Access-Control-Allow-Headers", "Content-Type");
+ res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
+
+ if (req.method === "OPTIONS") {
+ return res.status(204).send("");
+ }
+
+ if (req.method !== "POST") {
+ return res.status(405).json({ error: "Method not allowed" });
+ }
+
+ const { squadId, points } = req.body || {};
+
+ if (!squadId || typeof squadId !== "string") {
+ return res.status(400).json({ error: "squadId is required" });
+ }
+
+ const numericPoints = Number(points);
+ if (!numericPoints || numericPoints <= 0) {
+ return res.status(400).json({ error: "points must be a positive number" });
+ }
+
+ const firestore = admin.firestore();
+ const squadRef = firestore.collection("squads").doc(squadId);
+
+ try {
+ const result = await firestore.runTransaction(async (transaction) => {
+ const snapshot = await transaction.get(squadRef);
+ if (!snapshot.exists) {
+ throw new Error("Squad not found");
+ }
+
+ const squadData = snapshot.data();
+ const members = squadData.members || [];
+ if (!Array.isArray(members) || members.length === 0) {
+ throw new Error("Squad has no members to share points");
+ }
+
+ const perMember = Math.round((numericPoints / members.length) * 100) / 100;
+ const updatedTotal = (squadData.totalPoints || 0) + numericPoints;
+
+ transaction.update(squadRef, {
+ totalPoints: updatedTotal,
+ lastReceipt: {
+ amount: numericPoints,
+ perMember,
+ processedAt: admin.firestore.FieldValue.serverTimestamp(),
+ },
+ });
+
+ return { perMember, updatedTotal, members: members.length };
+ });
+
+ return res.json({
+ ok: true,
+ distributedPerMember: result.perMember,
+ membersCount: result.members,
+ totalPoints: result.updatedTotal,
+ });
+ } catch (error) {
+ console.error("shareReceiptPoints error", error);
+ return res.status(400).json({ error: error.message || "Unable to share receipt" });
+ }
+});
diff --git a/seed.js b/seed.js
new file mode 100644
index 0000000..cc65008
--- /dev/null
+++ b/seed.js
@@ -0,0 +1,65 @@
+/* eslint-disable no-console */
+const admin = require("firebase-admin");
+
+if (!admin.apps.length) {
+ admin.initializeApp({
+ credential: admin.credential.applicationDefault(),
+ });
+}
+
+const db = admin.firestore();
+
+const USERS = [
+ {
+ uid: "demo-user-1",
+ email: "captain@fetchsquads.dev",
+ displayName: "Командир",
+ },
+ {
+ uid: "demo-user-2",
+ email: "scout@fetchsquads.dev",
+ displayName: "Разведчик",
+ },
+ {
+ uid: "demo-user-3",
+ email: "healer@fetchsquads.dev",
+ displayName: "Поддержка",
+ },
+];
+
+async function seed() {
+ try {
+ const squadRef = db.collection("squads").doc();
+ const squadData = {
+ squadName: "Демо-отряд Fetch",
+ members: USERS.slice(0, 2).map((user) => user.uid),
+ totalPoints: 3200,
+ goal: {
+ name: "Собрать 25 000 очков на мегапати",
+ targetPoints: 25000,
+ },
+ shoppingList: [
+ { text: "Кофе для всей команды", completed: true },
+ { text: "Закупка снеков", completed: false },
+ ],
+ mascotUrl: "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/1f415.svg",
+ lastReceipt: null,
+ };
+
+ await squadRef.set(squadData);
+
+ for (const user of USERS) {
+ await db.collection("users").doc(user.uid).set({
+ email: user.email,
+ displayName: user.displayName,
+ squadId: squadData.members.includes(user.uid) ? squadRef.id : null,
+ });
+ }
+
+ console.log(`Создан демо-отряд ${squadRef.id} и ${USERS.length} пользователей.`);
+ } catch (error) {
+ console.error("Не удалось выполнить seed", error);
+ }
+}
+
+seed();
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..cfe1d41
--- /dev/null
+++ b/src/App.jsx
@@ -0,0 +1,66 @@
+import { createContext, useContext, useEffect, useMemo, useState } from "react";
+import { Routes, Route, Navigate } from "react-router-dom";
+import { onAuthStateChanged } from "firebase/auth";
+import { auth } from "./firebase/config";
+import LoginPage from "./pages/LoginPage";
+import HomePage from "./pages/HomePage";
+
+const AuthContext = createContext({ user: null, loading: true });
+
+export const useAuth = () => useContext(AuthContext);
+
+const ProtectedRoute = ({ children }) => {
+ const { user, loading } = useAuth();
+
+ if (loading) {
+ return (
+
+ Загрузка...
+
+ );
+ }
+
+ if (!user) {
+ return ;
+ }
+
+ return children;
+};
+
+const App = () => {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
+ setUser(firebaseUser);
+ setLoading(false);
+ });
+
+ return () => unsubscribe();
+ }, []);
+
+ const contextValue = useMemo(() => ({ user, loading }), [user, loading]);
+
+ return (
+
+
+
+
+
+ }
+ />
+ : }
+ />
+ } />
+
+
+ );
+};
+
+export default App;
diff --git a/src/components/InviteButton.jsx b/src/components/InviteButton.jsx
new file mode 100644
index 0000000..81c9964
--- /dev/null
+++ b/src/components/InviteButton.jsx
@@ -0,0 +1,39 @@
+import { useState } from "react";
+
+const InviteButton = ({ link }) => {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ if (!link) return;
+ try {
+ await navigator.clipboard.writeText(link);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Не удалось скопировать ссылку", err);
+ }
+ };
+
+ return (
+
+
+
Приглашение в отряд
+
+ Скопируйте ссылку и отправьте друзьям, чтобы они присоединились.
+
+
+
+ {link || "Ссылка появится после создания отряда"}
+
+
+
+ );
+};
+
+export default InviteButton;
diff --git a/src/components/Mascot.jsx b/src/components/Mascot.jsx
new file mode 100644
index 0000000..edd6024
--- /dev/null
+++ b/src/components/Mascot.jsx
@@ -0,0 +1,19 @@
+const Mascot = ({ mascotUrl, squadName }) => {
+ return (
+
+
+

+
+
Талисман отряда
+
+ {squadName || "Ваша команда"}
+
+
+ );
+};
+
+export default Mascot;
diff --git a/src/components/MemberList.jsx b/src/components/MemberList.jsx
new file mode 100644
index 0000000..3639ac9
--- /dev/null
+++ b/src/components/MemberList.jsx
@@ -0,0 +1,31 @@
+const MemberList = ({ members }) => {
+ return (
+
+
Участники отряда
+ {members.length === 0 ? (
+
+ Пока только вы в отряде — пригласите друзей по ссылке!
+
+ ) : (
+
+ {members.map((member) => (
+ -
+
+
{member.displayName || member.email}
+
{member.email}
+
+
+ участник
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default MemberList;
diff --git a/src/components/ShareReceiptModal.jsx b/src/components/ShareReceiptModal.jsx
new file mode 100644
index 0000000..a0171c8
--- /dev/null
+++ b/src/components/ShareReceiptModal.jsx
@@ -0,0 +1,93 @@
+import { useEffect, useState } from "react";
+
+const ShareReceiptModal = ({ open, onClose, onSubmit, membersCount }) => {
+ const [points, setPoints] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ if (!open) {
+ setPoints("");
+ setError("");
+ setLoading(false);
+ }
+ }, [open]);
+
+ if (!open) return null;
+
+ const parsedPoints = Number(points) || 0;
+ const share = membersCount > 0 ? Math.floor((parsedPoints / membersCount) * 100) / 100 : 0;
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ setError("");
+
+ const numericPoints = Number(points);
+ if (!numericPoints || numericPoints <= 0) {
+ setError("Введите количество очков больше нуля");
+ return;
+ }
+
+ setLoading(true);
+ try {
+ await onSubmit(numericPoints);
+ onClose();
+ } catch (err) {
+ setError(err.message || "Не удалось отправить очки");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
Отсканировать общий чек
+
+
+
+
+
+
+ );
+};
+
+export default ShareReceiptModal;
diff --git a/src/components/SharedGoal.jsx b/src/components/SharedGoal.jsx
new file mode 100644
index 0000000..ea810a8
--- /dev/null
+++ b/src/components/SharedGoal.jsx
@@ -0,0 +1,31 @@
+const SharedGoal = ({ totalPoints, goal }) => {
+ const target = goal?.targetPoints || 1;
+ const progress = Math.min(100, Math.round((totalPoints / target) * 100));
+
+ return (
+
+
+
+
Общая цель
+
{goal?.name}
+
+
+ {totalPoints.toLocaleString()} / {target.toLocaleString()} очков
+
+
+
+
+
+
+ Осталось {(target - totalPoints > 0 ? target - totalPoints : 0).toLocaleString()} очков до цели.
+
+
+ );
+};
+
+export default SharedGoal;
diff --git a/src/components/ShoppingList.jsx b/src/components/ShoppingList.jsx
new file mode 100644
index 0000000..ec5a53b
--- /dev/null
+++ b/src/components/ShoppingList.jsx
@@ -0,0 +1,78 @@
+import { useState } from "react";
+
+const ShoppingList = ({ items, onAddItem, onToggleItem }) => {
+ const [text, setText] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ if (!text.trim()) return;
+ setLoading(true);
+ try {
+ await onAddItem(text.trim());
+ setText("");
+ setError("");
+ } catch (err) {
+ setError(err.message || "Не удалось добавить пункт");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleToggle = async (index) => {
+ try {
+ await onToggleItem(index);
+ setError("");
+ } catch (err) {
+ setError(err.message || "Не удалось обновить пункт");
+ }
+ };
+
+ return (
+
+
Список покупок
+
+
+ {error && (
+
{error}
+ )}
+
+
+ );
+};
+
+export default ShoppingList;
diff --git a/src/components/SquadDashboard.jsx b/src/components/SquadDashboard.jsx
new file mode 100644
index 0000000..7bfbf77
--- /dev/null
+++ b/src/components/SquadDashboard.jsx
@@ -0,0 +1,70 @@
+import InviteButton from "./InviteButton";
+import Mascot from "./Mascot";
+import MemberList from "./MemberList";
+import SharedGoal from "./SharedGoal";
+import ShoppingList from "./ShoppingList";
+
+const SquadDashboard = ({
+ squad,
+ members,
+ shareLink,
+ onLogout,
+ onOpenReceiptModal,
+ onAddShoppingItem,
+ onToggleShoppingItem,
+}) => {
+ return (
+
+
+
+
+
+
+
+
+
+
Командный квест
+
+ За выходные отсканируйте 3 чека из разных ресторанов. Каждая команда,
+ выполнившая задание, получает бонусные 1000 очков!
+
+
+
+
+
+ );
+};
+
+export default SquadDashboard;
diff --git a/src/firebase/config.js b/src/firebase/config.js
new file mode 100644
index 0000000..e9710ee
--- /dev/null
+++ b/src/firebase/config.js
@@ -0,0 +1,19 @@
+import { initializeApp } from "firebase/app";
+import { getAuth } from "firebase/auth";
+import { getFirestore } from "firebase/firestore";
+
+const firebaseConfig = {
+ apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
+ authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
+ projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
+ storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
+ messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
+ appId: import.meta.env.VITE_FIREBASE_APP_ID,
+};
+
+const app = initializeApp(firebaseConfig);
+
+export const auth = getAuth(app);
+export const db = getFirestore(app);
+
+export default app;
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..c454353
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,12 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ @apply bg-slate-950 text-white;
+ font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+a {
+ @apply text-orange-400;
+}
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..d719969
--- /dev/null
+++ b/src/main.jsx
@@ -0,0 +1,13 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("root")).render(
+
+
+
+
+
+);
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
new file mode 100644
index 0000000..e5f7c98
--- /dev/null
+++ b/src/pages/HomePage.jsx
@@ -0,0 +1,231 @@
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ addDoc,
+ arrayUnion,
+ collection,
+ doc,
+ getDoc,
+ setDoc,
+ updateDoc,
+} from "firebase/firestore";
+import { signOut } from "firebase/auth";
+import { db, auth } from "../firebase/config";
+import { useAuth } from "../App";
+import SquadPage from "./SquadPage";
+
+const DEFAULT_GOAL = {
+ name: "Общая копилка",
+ targetPoints: 25000,
+};
+
+const DEFAULT_MASCOT =
+ "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/1f43f.svg";
+
+const HomePage = () => {
+ const { user } = useAuth();
+ const navigate = useNavigate();
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [squadName, setSquadName] = useState("");
+ const [inviteValue, setInviteValue] = useState("");
+ const [processing, setProcessing] = useState(false);
+
+ useEffect(() => {
+ if (!user) return;
+
+ const fetchProfile = async () => {
+ try {
+ const ref = doc(db, "users", user.uid);
+ const snap = await getDoc(ref);
+ if (snap.exists()) {
+ setProfile({ id: snap.id, ...snap.data() });
+ } else {
+ const freshProfile = {
+ email: user.email,
+ displayName: user.displayName || user.email,
+ squadId: null,
+ };
+ await setDoc(ref, freshProfile);
+ setProfile({ id: ref.id, ...freshProfile });
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchProfile();
+ }, [user]);
+
+ const handleLogout = async () => {
+ await signOut(auth);
+ navigate("/login");
+ };
+
+ const createSquad = async () => {
+ if (!user) return;
+ setProcessing(true);
+ setError("");
+ try {
+ const newSquadName = squadName.trim() || `${user.displayName || "Сквад"}`;
+ const squadRef = await addDoc(collection(db, "squads"), {
+ squadName: newSquadName,
+ members: [user.uid],
+ totalPoints: 0,
+ goal: DEFAULT_GOAL,
+ shoppingList: [],
+ mascotUrl: DEFAULT_MASCOT,
+ lastReceipt: null,
+ });
+
+ await updateDoc(doc(db, "users", user.uid), {
+ squadId: squadRef.id,
+ });
+
+ setProfile((prev) => (prev ? { ...prev, squadId: squadRef.id } : prev));
+ setSquadName("");
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const parseSquadId = (value) => {
+ const trimmed = value.trim();
+ if (!trimmed) return "";
+
+ const urlMatch = trimmed.match(/squadId=([a-zA-Z0-9-]+)/);
+ if (urlMatch) {
+ return urlMatch[1];
+ }
+
+ return trimmed.split("/").filter(Boolean).pop() || trimmed;
+ };
+
+ const joinSquad = async () => {
+ if (!user) return;
+ setProcessing(true);
+ setError("");
+ try {
+ const squadId = parseSquadId(inviteValue);
+ if (!squadId) {
+ throw new Error("Укажите корректную ссылку или ID отряда");
+ }
+
+ const squadRef = doc(db, "squads", squadId);
+ const snapshot = await getDoc(squadRef);
+ if (!snapshot.exists()) {
+ throw new Error("Отряд не найден");
+ }
+
+ await updateDoc(squadRef, {
+ members: arrayUnion(user.uid),
+ });
+
+ await updateDoc(doc(db, "users", user.uid), {
+ squadId,
+ });
+
+ setProfile((prev) => (prev ? { ...prev, squadId } : prev));
+ setInviteValue("");
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const shareLink = useMemo(() => {
+ if (!profile?.squadId) return "";
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
+ return `${origin}/join?squadId=${profile.squadId}`;
+ }, [profile?.squadId]);
+
+ if (loading) {
+ return (
+
+ Загрузка профиля...
+
+ );
+ }
+
+ if (!profile?.squadId) {
+ return (
+
+
+
+
Добро пожаловать, {user?.displayName || user?.email}
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default HomePage;
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..afa1613
--- /dev/null
+++ b/src/pages/LoginPage.jsx
@@ -0,0 +1,124 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ createUserWithEmailAndPassword,
+ signInWithEmailAndPassword,
+ updateProfile,
+} from "firebase/auth";
+import { doc, setDoc } from "firebase/firestore";
+import { auth, db } from "../firebase/config";
+
+const LoginPage = () => {
+ const navigate = useNavigate();
+ const [isRegister, setIsRegister] = useState(false);
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ setError("");
+ setLoading(true);
+
+ try {
+ if (isRegister) {
+ const result = await createUserWithEmailAndPassword(auth, email, password);
+ await updateProfile(result.user, { displayName: displayName || email });
+ await setDoc(doc(db, "users", result.user.uid), {
+ email,
+ displayName: displayName || email,
+ squadId: null,
+ });
+ } else {
+ await signInWithEmailAndPassword(auth, email, password);
+ }
+
+ navigate("/");
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
Fetch Squads
+
+
+
+
+
+
+
+
+ );
+};
+
+export default LoginPage;
diff --git a/src/pages/SquadPage.jsx b/src/pages/SquadPage.jsx
new file mode 100644
index 0000000..8e1237b
--- /dev/null
+++ b/src/pages/SquadPage.jsx
@@ -0,0 +1,178 @@
+import { useEffect, useMemo, useState } from "react";
+import { doc, getDoc, onSnapshot, updateDoc } from "firebase/firestore";
+import SquadDashboard from "../components/SquadDashboard";
+import ShareReceiptModal from "../components/ShareReceiptModal";
+import { db } from "../firebase/config";
+
+const SquadPage = ({ squadId, onLogout, shareLink }) => {
+ const [squad, setSquad] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const functionsBaseUrl = useMemo(() => {
+ const envUrl = import.meta.env.VITE_CLOUD_FUNCTIONS_BASE_URL;
+ if (envUrl) return envUrl.replace(/\/$/, "");
+ const projectId = import.meta.env.VITE_FIREBASE_PROJECT_ID;
+ return projectId
+ ? `https://us-central1-${projectId}.cloudfunctions.net`
+ : "";
+ }, []);
+
+ useEffect(() => {
+ if (!squadId) return;
+
+ const ref = doc(db, "squads", squadId);
+ const unsubscribe = onSnapshot(
+ ref,
+ (snapshot) => {
+ if (snapshot.exists()) {
+ setSquad({ id: snapshot.id, ...snapshot.data() });
+ setError("");
+ } else {
+ setError("Отряд не найден");
+ }
+ setLoading(false);
+ },
+ (err) => {
+ setError(err.message);
+ setLoading(false);
+ }
+ );
+
+ return () => unsubscribe();
+ }, [squadId]);
+
+ useEffect(() => {
+ const loadMembers = async () => {
+ if (!squad?.members?.length) {
+ setMembers([]);
+ return;
+ }
+
+ try {
+ const results = await Promise.all(
+ squad.members.map(async (memberId) => {
+ const userSnap = await getDoc(doc(db, "users", memberId));
+ if (userSnap.exists()) {
+ return { id: userSnap.id, ...userSnap.data() };
+ }
+ return { id: memberId, displayName: "Неизвестный", email: "n/a" };
+ })
+ );
+ setMembers(results);
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+
+ loadMembers();
+ }, [squad?.members]);
+
+ const handleAddShoppingItem = async (text) => {
+ if (!squad) return;
+ const nextList = [...(squad.shoppingList || []), { text, completed: false }];
+ try {
+ await updateDoc(doc(db, "squads", squadId), {
+ shoppingList: nextList,
+ });
+ } catch (err) {
+ console.error('Не удалось добавить пункт списка', err);
+ throw new Error('Не удалось добавить пункт списка');
+ }
+ };
+
+ const handleToggleShoppingItem = async (index) => {
+ if (!squad) return;
+ const list = [...(squad.shoppingList || [])];
+ if (!list[index]) return;
+ list[index] = { ...list[index], completed: !list[index].completed };
+ try {
+ await updateDoc(doc(db, "squads", squadId), {
+ shoppingList: list,
+ });
+ } catch (err) {
+ console.error('Не удалось обновить пункт списка', err);
+ throw new Error('Не удалось обновить пункт списка');
+ }
+ };
+
+ const handleShareReceipt = async (points) => {
+ if (!functionsBaseUrl) {
+ throw new Error("Укажите VITE_CLOUD_FUNCTIONS_BASE_URL в .env");
+ }
+
+ const response = await fetch(`${functionsBaseUrl}/shareReceiptPoints`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ squadId, points }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.error || "Не удалось распределить очки");
+ }
+
+ return response.json();
+ };
+
+ if (error) {
+ return (
+
+
Ошибка: {error}
+
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+ Загрузка отряда...
+
+ );
+ }
+
+ if (!squad) {
+ return (
+
+
Данные отряда недоступны.
+
+
+ );
+ }
+
+ return (
+ <>
+ setIsModalOpen(true)}
+ onAddShoppingItem={handleAddShoppingItem}
+ onToggleShoppingItem={handleToggleShoppingItem}
+ />
+ setIsModalOpen(false)}
+ onSubmit={handleShareReceipt}
+ membersCount={members.length || 1}
+ />
+ >
+ );
+};
+
+export default SquadPage;