Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,58 @@
# Second
# 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) в любом современном браузере и на любой ОС.
73 changes: 73 additions & 0 deletions functions/index.js
Original file line number Diff line number Diff line change
@@ -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" });
}
});
65 changes: 65 additions & 0 deletions seed.js
Original file line number Diff line number Diff line change
@@ -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();
66 changes: 66 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-slate-900 text-white">
Загрузка...
</div>
);
}

if (!user) {
return <Navigate to="/login" replace />;
}

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 (
<AuthContext.Provider value={contextValue}>
<Routes>
<Route
path="/"
element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
}
/>
<Route
path="/login"
element={user ? <Navigate to="/" replace /> : <LoginPage />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthContext.Provider>
);
};

export default App;
39 changes: 39 additions & 0 deletions src/components/InviteButton.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-slate-900/60 border border-slate-700 rounded-3xl p-6 flex flex-col gap-4">
<div>
<h3 className="text-xl font-semibold">Приглашение в отряд</h3>
<p className="text-sm text-slate-300 mt-1">
Скопируйте ссылку и отправьте друзьям, чтобы они присоединились.
</p>
</div>
<div className="bg-slate-950 border border-slate-800 rounded-2xl px-4 py-3 text-sm break-all">
{link || "Ссылка появится после создания отряда"}
</div>
<button
onClick={handleCopy}
disabled={!link}
className="px-4 py-2 rounded-xl bg-indigo-500 hover:bg-indigo-600 font-semibold disabled:opacity-50"
>
{copied ? "Ссылка скопирована" : "Скопировать ссылку"}
</button>
</div>
);
};

export default InviteButton;
19 changes: 19 additions & 0 deletions src/components/Mascot.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const Mascot = ({ mascotUrl, squadName }) => {
return (
<div className="bg-slate-900/60 border border-slate-700 rounded-3xl p-6 flex flex-col items-center text-center">
<div className="w-32 h-32 rounded-full bg-slate-800 flex items-center justify-center overflow-hidden mb-4">
<img
src={mascotUrl}
alt="Squad mascot"
className="w-24 h-24 object-contain drop-shadow-lg"
/>
</div>
<h3 className="text-xl font-semibold">Талисман отряда</h3>
<p className="text-sm text-slate-300 mt-2">
{squadName || "Ваша команда"}
</p>
</div>
);
};

export default Mascot;
31 changes: 31 additions & 0 deletions src/components/MemberList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const MemberList = ({ members }) => {
return (
<div className="bg-slate-900/60 border border-slate-700 rounded-3xl p-6">
<h3 className="text-xl font-semibold mb-4">Участники отряда</h3>
{members.length === 0 ? (
<p className="text-sm text-slate-400 bg-slate-950/60 border border-dashed border-slate-800 rounded-2xl px-4 py-6 text-center">
Пока только вы в отряде — пригласите друзей по ссылке!
</p>
) : (
<ul className="space-y-3">
{members.map((member) => (
<li
key={member.id}
className="flex items-center justify-between bg-slate-950/60 border border-slate-800 rounded-2xl px-4 py-3"
>
<div>
<p className="font-medium">{member.displayName || member.email}</p>
<p className="text-xs text-slate-400">{member.email}</p>
</div>
<span className="text-xs uppercase tracking-wider text-slate-500">
участник
</span>
</li>
))}
</ul>
)}
</div>
);
};

export default MemberList;
Loading