From 96211143952b4c1ca6fd78fd5384dbdb1decbf52 Mon Sep 17 00:00:00 2001 From: Fredess74 Date: Fri, 26 Sep 2025 01:32:44 -0400 Subject: [PATCH] Implement Fetch Squads MVP --- README.md | 59 ++++++- functions/index.js | 73 +++++++++ seed.js | 65 ++++++++ src/App.jsx | 66 ++++++++ src/components/InviteButton.jsx | 39 +++++ src/components/Mascot.jsx | 19 +++ src/components/MemberList.jsx | 31 ++++ src/components/ShareReceiptModal.jsx | 93 +++++++++++ src/components/SharedGoal.jsx | 31 ++++ src/components/ShoppingList.jsx | 78 +++++++++ src/components/SquadDashboard.jsx | 70 ++++++++ src/firebase/config.js | 19 +++ src/index.css | 12 ++ src/main.jsx | 13 ++ src/pages/HomePage.jsx | 231 +++++++++++++++++++++++++++ src/pages/LoginPage.jsx | 124 ++++++++++++++ src/pages/SquadPage.jsx | 178 +++++++++++++++++++++ 17 files changed, 1200 insertions(+), 1 deletion(-) create mode 100644 functions/index.js create mode 100644 seed.js create mode 100644 src/App.jsx create mode 100644 src/components/InviteButton.jsx create mode 100644 src/components/Mascot.jsx create mode 100644 src/components/MemberList.jsx create mode 100644 src/components/ShareReceiptModal.jsx create mode 100644 src/components/SharedGoal.jsx create mode 100644 src/components/ShoppingList.jsx create mode 100644 src/components/SquadDashboard.jsx create mode 100644 src/firebase/config.js create mode 100644 src/index.css create mode 100644 src/main.jsx create mode 100644 src/pages/HomePage.jsx create mode 100644 src/pages/LoginPage.jsx create mode 100644 src/pages/SquadPage.jsx 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 ( +
+
+ Squad mascot +
+

Талисман отряда

+

+ {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 ( +
+
+
+

Отсканировать общий чек

+ +
+ +
+
+ + setPoints(e.target.value)} + min="0" + className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-orange-500" + placeholder="Например, 500" + /> +
+ +
+

Участников: {membersCount}

+

Каждый получит примерно: {share.toLocaleString()} очков

+
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +}; + +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 ( +
+

Список покупок

+
+ setText(e.target.value)} + placeholder="Добавить пункт..." + className="flex-1 px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-orange-500" + /> + +
+ + {error && ( +

{error}

+ )} +
    + {items.map((item, index) => ( +
  • +
    + handleToggle(index)} + className="h-5 w-5 rounded border-slate-700 bg-slate-800 text-emerald-500" + /> + + {item.text} + +
    +
  • + ))} +
+
+ ); +}; + +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 ( +
+
+
+
+

{squad.squadName}

+

Командный центр Fetch Squads

+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+

Командный квест

+

+ За выходные отсканируйте 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}

+ +
+ +
+
+

Создать новый отряд

+ + setSquadName(e.target.value)} + placeholder="Например, Охотники за чеками" + className="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-orange-500" + /> + +
+ +
+

Присоединиться по ссылке

+ + setInviteValue(e.target.value)} + placeholder="Вставьте ссылку вида https://...squadId=XXX" + className="w-full px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-orange-500" + /> + +
+
+ + {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

+
+ + +
+ +
+ {isRegister && ( +
+ + setDisplayName(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-orange-500" + placeholder="Например, Анна" + /> +
+ )} + +
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-orange-500" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-orange-500" + required + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +}; + +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;