From 889ba8ffdd508ee2cae62f61fe9ef1b7a2059fa3 Mon Sep 17 00:00:00 2001 From: Fredess74 Date: Fri, 26 Sep 2025 14:46:12 -0400 Subject: [PATCH] Remove binary assets to unblock PR --- .gitignore | 10 ++ App.tsx | 75 +++++++++ README.md | 95 ++++++++++- app.config.ts | 39 +++++ babel.config.js | 18 ++ nativewind.d.ts | 1 + package.json | 53 ++++++ postcss.config.js | 6 + seed.js | 183 ++++++++++++++++++++ src/components/Mascot.tsx | 27 +++ src/components/WeeklyChallengeCard.tsx | 28 ++++ src/hooks/useAuth.tsx | 106 ++++++++++++ src/hooks/useLeague.ts | 54 ++++++ src/hooks/useSquad.ts | 76 +++++++++ src/navigation/RootNavigator.tsx | 29 ++++ src/navigation/stacks/AuthNavigator.tsx | 21 +++ src/navigation/stacks/MainNavigator.tsx | 71 ++++++++ src/screens/auth/LoginScreen.tsx | 72 ++++++++ src/screens/auth/RegisterScreen.tsx | 98 +++++++++++ src/screens/leagues/LeagueScreen.tsx | 84 ++++++++++ src/screens/main/HomeScreen.tsx | 88 ++++++++++ src/screens/main/ProfileScreen.tsx | 41 +++++ src/screens/main/ScanReceiptModal.tsx | 69 ++++++++ src/screens/squads/SquadInviteScreen.tsx | 33 ++++ src/screens/squads/SquadScreen.tsx | 204 +++++++++++++++++++++++ src/services/firebase.ts | 26 +++ src/services/leagueService.ts | 89 ++++++++++ src/services/squadService.ts | 110 ++++++++++++ src/types/models.ts | 66 ++++++++ src/types/navigation.ts | 34 ++++ src/utils/date.ts | 22 +++ src/utils/hash.ts | 8 + tailwind.config.js | 16 ++ todo.md | 16 ++ tsconfig.json | 9 + 35 files changed, 1976 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 app.config.ts create mode 100644 babel.config.js create mode 100644 nativewind.d.ts create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 seed.js create mode 100644 src/components/Mascot.tsx create mode 100644 src/components/WeeklyChallengeCard.tsx create mode 100644 src/hooks/useAuth.tsx create mode 100644 src/hooks/useLeague.ts create mode 100644 src/hooks/useSquad.ts create mode 100644 src/navigation/RootNavigator.tsx create mode 100644 src/navigation/stacks/AuthNavigator.tsx create mode 100644 src/navigation/stacks/MainNavigator.tsx create mode 100644 src/screens/auth/LoginScreen.tsx create mode 100644 src/screens/auth/RegisterScreen.tsx create mode 100644 src/screens/leagues/LeagueScreen.tsx create mode 100644 src/screens/main/HomeScreen.tsx create mode 100644 src/screens/main/ProfileScreen.tsx create mode 100644 src/screens/main/ScanReceiptModal.tsx create mode 100644 src/screens/squads/SquadInviteScreen.tsx create mode 100644 src/screens/squads/SquadScreen.tsx create mode 100644 src/services/firebase.ts create mode 100644 src/services/leagueService.ts create mode 100644 src/services/squadService.ts create mode 100644 src/types/models.ts create mode 100644 src/types/navigation.ts create mode 100644 src/utils/date.ts create mode 100644 src/utils/hash.ts create mode 100644 tailwind.config.js create mode 100644 todo.md create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f00fd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.expo/ +dist/ +build/ +.expo-shared/ +.env +.DS_Store +web-build/ +*.log +coverage/ diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..05cbaf8 --- /dev/null +++ b/App.tsx @@ -0,0 +1,75 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { TailwindProvider } from 'nativewind'; +import { StatusBar } from 'expo-status-bar'; +import { LinkingOptions } from '@react-navigation/native'; +import { useMemo } from 'react'; + +import RootNavigator from '@/navigation/RootNavigator'; +import { AuthProvider } from '@/hooks/useAuth'; +import { RootStackParamList } from '@/types/navigation'; + +const queryClient = new QueryClient(); + +const App = () => { + const linking = useMemo>( + () => ({ + prefixes: ['fetchapp://', 'https://fetchapp.app'], + config: { + screens: { + Auth: { + screens: { + Login: 'login', + Register: 'register' + } + }, + Main: { + screens: { + HomeTab: { + screens: { + Home: '', + ScanReceiptModal: 'scan' + } + }, + SquadTab: { + screens: { + Squad: 'squad/:squadId?', + SquadInvite: 'squad/join/:squadId' + } + }, + LeagueTab: { + screens: { + League: 'league' + } + }, + ProfileTab: { + screens: { + Profile: 'profile' + } + } + } + } + } + } + }), + [] + ); + + return ( + + + + + + + + + + + + + ); +}; + +export default App; diff --git a/README.md b/README.md index c32ea5a..31410c9 100644 --- a/README.md +++ b/README.md @@ -1 +1,94 @@ -# Second \ No newline at end of file +# Fetch Rewards MVP โ€“ Squads & Leagues + +This repository contains a React Native (Expo) MVP for Fetch Rewards that showcases the new **Squads** and **Leagues** experiences. The app is written in TypeScript, styled with NativeWind, and backed by Firebase Authentication and Firestore. Use it to explore receipt-sharing, collaborative goals, and weekly league leaderboards on both iOS and Android via Expo Go. + +## โœจ Features +- Email/password authentication backed by Firebase Auth with automatic Firestore profile creation. +- **Squads**: create or join teams, share invite links, split receipt points, manage a shared shopping list, and track progress toward group goals with a playful mascot. +- **Receipt simulator**: quickly award random points and split them across squad members. +- **Weekly challenges** and duel callouts to encourage friendly competition within squads. +- **Leagues**: weekly leaderboards, streak multipliers, promotion highlights, and seasonal countdown messaging. +- Firestore seeding script that creates demo users, squads, and leagues for richer testing data. + +## ๐Ÿงฑ Project Structure +``` +. +โ”œโ”€โ”€ App.tsx +โ”œโ”€โ”€ app.config.ts +โ”œโ”€โ”€ seed.js +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ hooks/ +โ”‚ โ”œโ”€โ”€ navigation/ +โ”‚ โ”œโ”€โ”€ screens/ +โ”‚ โ”œโ”€โ”€ services/ +โ”‚ โ”œโ”€โ”€ types/ +โ”‚ โ””โ”€โ”€ utils/ +โ””โ”€โ”€ tailwind.config.js +``` + +## ๐Ÿ› ๏ธ Prerequisites +1. **Node.js** (LTS recommended). Install from [nodejs.org](https://nodejs.org/). +2. **Expo CLI** โ€“ install globally: + ```bash + npm install -g expo-cli + ``` +3. **Firebase project** with: + - Web app configuration (API key, project ID, etc.). + - Firestore and Authentication enabled. +4. (Optional for seeding) **Firebase service account JSON** with read/write access to Firestore. + +## โš™๏ธ Setup +1. Clone the repository and install dependencies: + ```bash + git clone + cd fetch-rewards-mvp + npm install + ``` +2. Create a `.env` file in the project root and populate it with your Firebase keys: + ```env + FIREBASE_API_KEY=your-api-key + FIREBASE_AUTH_DOMAIN=your-app.firebaseapp.com + FIREBASE_PROJECT_ID=your-project-id + FIREBASE_STORAGE_BUCKET=your-app.appspot.com + FIREBASE_MESSAGING_SENDER_ID=... + FIREBASE_APP_ID=... + FIREBASE_MEASUREMENT_ID=... + # Optional for seeding + FIREBASE_SERVICE_ACCOUNT_PATH=path/to/serviceAccount.json + ``` + > On Windows, paths can be absolute (`C:\\path\\to\\serviceAccount.json`) or relative to the project directory. + +## ๐Ÿงช Seeding Firestore Data +Populate your Firestore project with 200 demo users, multiple squads, and league groups. + +1. Ensure your service account file is accessible and referenced via `FIREBASE_SERVICE_ACCOUNT_PATH` (or set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable). +2. Run the seeding script: + ```bash + npm run seed + ``` + The script prints progress as it creates user documents, squads, and league groupings. + +## ๐Ÿ“ฑ Running the App +1. Start the Expo development server: + ```bash + npm start + ``` +2. Press `i` or `a` to open iOS or Android simulators, or scan the QR code using **Expo Go** on your mobile device. +3. Register a new account directly in the app or sign in with seeded credentials that you manually add to Firebase Authentication. + +## ๐Ÿงญ Navigation Overview +- **Auth Stack**: Login and registration screens. +- **Main Tabs**: + - **Home**: receipt scanning simulator, squad goal snapshot, weekly highlights. + - **Squad**: squad management, mascot, shopping list, weekly challenges. + - **League**: weekly leaderboard, promotion summary, reward breakdowns. + - **Profile**: personal stats, streak tracker, logout. + +## โœ… Notes +- All Firebase interactions rely on the configuration exposed via `app.config.ts`. Ensure your `.env` values are present **before** launching the app. +- The seed script creates Firestore documents only; to log in as a seeded user, manually create matching credentials in Firebase Authentication or register through the app and then update the Firestore profile. +- NativeWind powers the `className` styling on React Native components. Tailwind tokens can be extended via `tailwind.config.js`. +- Default Expo icon and splash assets are used so the repository stays binary-free for easier pull requests. + +Enjoy exploring the Fetch Rewards Squads & Leagues MVP! ๐Ÿ’ฅ diff --git a/app.config.ts b/app.config.ts new file mode 100644 index 0000000..b494faf --- /dev/null +++ b/app.config.ts @@ -0,0 +1,39 @@ +import 'dotenv/config'; +import type { ExpoConfig } from '@expo/config'; + +const config: ExpoConfig = { + name: 'Fetch Rewards MVP', + slug: 'fetch-rewards-mvp', + version: '0.1.0', + orientation: 'portrait', + scheme: 'fetchapp', + userInterfaceStyle: 'light', + // Using Expo's default icon and splash assets to keep the repository binary-free. + updates: { + fallbackToCacheTimeout: 0 + }, + assetBundlePatterns: ['**/*'], + ios: { + supportsTablet: true, + bundleIdentifier: 'com.fetchrewards.squadsleagues' + }, + android: { + package: 'com.fetchrewards.squadsleagues' + }, + extra: { + eas: { + projectId: '00000000-0000-0000-0000-000000000000' + }, + firebase: { + apiKey: process.env.FIREBASE_API_KEY, + authDomain: process.env.FIREBASE_AUTH_DOMAIN, + projectId: process.env.FIREBASE_PROJECT_ID, + storageBucket: process.env.FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.FIREBASE_APP_ID, + measurementId: process.env.FIREBASE_MEASUREMENT_ID + } + } +}; + +export default config; diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..fb57fb2 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,18 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + 'nativewind/babel', + [ + 'module-resolver', + { + root: ['./'], + alias: { + '@': './src' + } + } + ] + ] + }; +}; diff --git a/nativewind.d.ts b/nativewind.d.ts new file mode 100644 index 0000000..a13e313 --- /dev/null +++ b/nativewind.d.ts @@ -0,0 +1 @@ +/// diff --git a/package.json b/package.json new file mode 100644 index 0000000..1700307 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "fetch-rewards-squads-leagues", + "version": "0.1.0", + "private": true, + "main": "expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "seed": "node seed.js" + }, + "dependencies": { + "@react-navigation/bottom-tabs": "^6.5.11", + "@react-navigation/native": "^6.1.9", + "@react-navigation/native-stack": "^6.9.17", + "@react-navigation/stack": "^6.3.21", + "@tanstack/react-query": "^5.24.7", + "expo": "~50.0.3", + "expo-constants": "~15.4.5", + "expo-font": "~11.10.3", + "expo-linear-gradient": "~12.8.2", + "expo-linking": "~6.2.2", + "expo-secure-store": "~12.3.1", + "expo-splash-screen": "~0.26.5", + "expo-status-bar": "~1.11.1", + "@react-native-clipboard/clipboard": "^1.13.2", + "firebase": "^10.7.2", + "firebase-admin": "^11.10.1", + "nanoid": "^5.0.6", + "dotenv": "^16.3.1", + "nativewind": "^2.0.11", + "react": "18.2.0", + "react-native": "0.73.6", + "react-native-gesture-handler": "~2.14.0", + "react-native-reanimated": "~3.6.2", + "react-native-safe-area-context": "4.7.4", + "react-native-screens": "~3.29.0", + "react-native-svg": "^14.1.0", + "zustand": "^4.4.6" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@types/react": "^18.2.37", + "@types/react-native": "^0.73.0", + "@types/react-test-renderer": "18.0.0", + "autoprefixer": "^10.4.16", + "babel-preset-expo": "^10.0.1", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.3", + "typescript": "^5.2.2" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/seed.js b/seed.js new file mode 100644 index 0000000..6bbb2c5 --- /dev/null +++ b/seed.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { nanoid } = require('nanoid/non-secure'); +const admin = require('firebase-admin'); + +const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT_PATH || process.env.GOOGLE_APPLICATION_CREDENTIALS; +const projectId = process.env.FIREBASE_PROJECT_ID; + +if (!projectId) { + console.error('FIREBASE_PROJECT_ID is required in your environment variables.'); + process.exit(1); +} + +if (!admin.apps.length) { + if (serviceAccountPath) { + const absolutePath = path.isAbsolute(serviceAccountPath) + ? serviceAccountPath + : path.join(process.cwd(), serviceAccountPath); + const serviceAccount = JSON.parse(fs.readFileSync(absolutePath, 'utf-8')); + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), + projectId + }); + } else { + admin.initializeApp({ + credential: admin.credential.applicationDefault(), + projectId + }); + } +} + +const db = admin.firestore(); + +const leagueTiers = ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Elite', 'Legend', 'Mythic']; +const firstNames = ['Alex', 'Taylor', 'Jordan', 'Morgan', 'Casey', 'River', 'Hayden', 'Skyler', 'Emerson', 'Phoenix']; +const lastNames = ['Kim', 'Lopez', 'Patel', 'Garcia', 'Nguyen', 'Smith', 'Johnson', 'Martinez', 'Brown', 'Singh']; + +const randomFrom = array => array[Math.floor(Math.random() * array.length)]; + +const generateUsers = count => + Array.from({ length: count }).map(() => { + const uid = nanoid(20); + const displayName = `${randomFrom(firstNames)} ${randomFrom(lastNames)}`; + const league = randomFrom(leagueTiers); + const totalPoints = Math.floor(Math.random() * 50000) + 500; + const weeklyLeaguePoints = Math.floor(Math.random() * 5000); + const scanDays = Math.floor(Math.random() * 7); + + return { + uid, + email: `${displayName.toLowerCase().replace(/\s/g, '.')}.${Math.floor(Math.random() * 9999)}@example.com`, + displayName, + totalPoints, + league, + weeklyLeaguePoints, + squadId: null, + scanStreak: { + days: scanDays, + lastScanDate: new Date(Date.now() - scanDays * 86400000).toISOString() + }, + createdAt: new Date().toISOString() + }; + }); + +const createSquads = (users, squadCount = 6) => { + const squads = []; + const shuffledUsers = [...users]; + + for (let i = 0; i < squadCount; i += 1) { + const squadId = nanoid(8); + const takeCount = Math.min(shuffledUsers.length, Math.floor(Math.random() * 6) + 4); + if (takeCount === 0) break; + const members = shuffledUsers.splice(0, takeCount); + const goalTarget = [10000, 25000, 50000][Math.floor(Math.random() * 3)]; + + members.forEach(member => { + // eslint-disable-next-line no-param-reassign + member.squadId = squadId; + }); + + const shoppingList = members.slice(0, Math.min(2, members.length)).map(member => ({ + id: nanoid(6), + itemName: randomFrom(['Energy drinks', 'Snacks', 'Study supplies', 'Party decor', 'Pizza fund']), + addedBy: member.uid, + createdAt: new Date().toISOString() + })); + + squads.push({ + squadId, + squadName: `Squad ${i + 1}`, + members: members.map(member => member.uid), + mascotStatus: 'happy', + sharedGoal: { + targetPoints: goalTarget, + currentPoints: Math.floor(goalTarget * Math.random()) + }, + shoppingList + }); + } + + return squads; +}; + +const getWeekRange = () => { + const current = new Date(); + const day = current.getUTCDay(); + const diffToMonday = (day + 6) % 7; + const weekStart = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() - diffToMonday)); + const weekEnd = new Date(weekStart); + weekEnd.setUTCDate(weekStart.getUTCDate() + 7); + const key = `${weekStart.getUTCFullYear()}-W${String(getWeekNumber(current)).padStart(2, '0')}`; + return { weekStart, weekEnd, key }; +}; + +const getWeekNumber = date => { + const tmpDate = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const dayNum = tmpDate.getUTCDay() || 7; + tmpDate.setUTCDate(tmpDate.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(tmpDate.getUTCFullYear(), 0, 1)); + return Math.ceil(((tmpDate - yearStart) / 86400000 + 1) / 7); +}; + +const seed = async () => { + const users = generateUsers(200); + const squads = createSquads(users); + const { weekStart, weekEnd, key } = getWeekRange(); + + console.log('Seeding users...'); + const userWrites = users.map(user => + db + .collection('users') + .doc(user.uid) + .set(user) + ); + await Promise.all(userWrites); + + console.log('Seeding squads...'); + const squadWrites = squads.map(squad => db.collection('squads').doc(squad.squadId).set(squad)); + await Promise.all(squadWrites); + + console.log('Seeding leagues...'); + const leagueGroups = {}; + users.forEach(user => { + const groupNumber = (user.uid.charCodeAt(0) + user.uid.charCodeAt(1)) % 5; + const leagueId = `${user.league.toLowerCase()}_${key}_group_${groupNumber + 1}`; + if (!leagueGroups[leagueId]) { + leagueGroups[leagueId] = { + leagueId, + leagueName: user.league, + members: [], + weekStartDate: weekStart.toISOString(), + weekEndDate: weekEnd.toISOString(), + weekKey: key + }; + } + leagueGroups[leagueId].members.push({ + uid: user.uid, + displayName: user.displayName, + pointsThisWeek: user.weeklyLeaguePoints, + league: user.league, + streakMultiplier: user.scanStreak.days >= 5 ? 1.5 : 1 + }); + }); + + const leagueWrites = Object.values(leagueGroups).map(group => + db + .collection('leagues') + .doc(group.leagueId) + .set(group) + ); + await Promise.all(leagueWrites); + + console.log('Seed complete!'); + process.exit(0); +}; + +seed().catch(error => { + console.error('Seeding failed', error); + process.exit(1); +}); diff --git a/src/components/Mascot.tsx b/src/components/Mascot.tsx new file mode 100644 index 0000000..32c6b13 --- /dev/null +++ b/src/components/Mascot.tsx @@ -0,0 +1,27 @@ +import Svg, { Circle, Path } from 'react-native-svg'; +import { View, Text } from 'react-native'; + +interface MascotProps { + status: 'happy' | 'excited' | 'sleepy'; +} + +const Mascot: React.FC = ({ status }) => { + const faceColor = status === 'excited' ? '#FF8F3F' : status === 'sleepy' ? '#A0AEC0' : '#7F5AF0'; + const smile = status === 'sleepy' ? 'M35 55 Q50 45 65 55' : 'M35 55 Q50 70 65 55'; + + return ( + + + + + + + + + + Mascot is {status} + + ); +}; + +export default Mascot; diff --git a/src/components/WeeklyChallengeCard.tsx b/src/components/WeeklyChallengeCard.tsx new file mode 100644 index 0000000..ac5b563 --- /dev/null +++ b/src/components/WeeklyChallengeCard.tsx @@ -0,0 +1,28 @@ +import { View, Text } from 'react-native'; + +import { Challenge } from '@/types/models'; + +interface WeeklyChallengeCardProps { + challenge: Challenge; +} + +const WeeklyChallengeCard: React.FC = ({ challenge }) => { + const progressPercentage = Math.min(100, Math.round((challenge.progress / challenge.target) * 100)); + + return ( + + {challenge.title} + {challenge.description} + + + + + + {challenge.progress} / {challenge.target} + + + + ); +}; + +export default WeeklyChallengeCard; diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx new file mode 100644 index 0000000..e893fd5 --- /dev/null +++ b/src/hooks/useAuth.tsx @@ -0,0 +1,106 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { + User, + createUserWithEmailAndPassword, + onAuthStateChanged, + signInWithEmailAndPassword, + signOut, + updateProfile +} from 'firebase/auth'; +import { doc, onSnapshot, setDoc } from 'firebase/firestore'; + +import { auth, db } from '@/services/firebase'; +import { UserProfile } from '@/types/models'; + +interface AuthContextValue { + user: User | null; + profile: UserProfile | null; + initializing: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, displayName: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [profile, setProfile] = useState(null); + const [initializing, setInitializing] = useState(true); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, currentUser => { + setUser(currentUser); + setInitializing(false); + }); + + return unsubscribe; + }, []); + + useEffect(() => { + if (!user?.uid) { + setProfile(null); + return; + } + + const unsubscribe = onSnapshot(doc(db, 'users', user.uid), snapshot => { + if (snapshot.exists()) { + setProfile(snapshot.data() as UserProfile); + } + }); + + return unsubscribe; + }, [user?.uid]); + + const login = async (email: string, password: string) => { + await signInWithEmailAndPassword(auth, email.trim(), password); + }; + + const register = async (email: string, password: string, displayName: string) => { + const credential = await createUserWithEmailAndPassword(auth, email.trim(), password); + if (credential.user) { + await updateProfile(credential.user, { displayName }); + const userProfile: UserProfile = { + uid: credential.user.uid, + email: credential.user.email ?? email, + displayName, + totalPoints: 0, + squadId: null, + league: 'Bronze', + weeklyLeaguePoints: 0, + scanStreak: { + days: 0 + }, + createdAt: new Date().toISOString() + }; + await setDoc(doc(db, 'users', credential.user.uid), userProfile); + setProfile(userProfile); + } + }; + + const logout = async () => { + await signOut(auth); + }; + + const value = useMemo( + () => ({ + user, + profile, + initializing, + login, + register, + logout + }), + [user, profile, initializing, login, register, logout] + ); + + return {children}; +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/hooks/useLeague.ts b/src/hooks/useLeague.ts new file mode 100644 index 0000000..f100720 --- /dev/null +++ b/src/hooks/useLeague.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useAuth } from './useAuth'; +import { LeagueGroup, LeagueGroupMember } from '@/types/models'; +import { fetchLeaderboard, getLeagueRewards, getOrCreateLeagueGroup, getSeasonCountdown } from '@/services/leagueService'; + +export const useLeague = () => { + const { profile } = useAuth(); + const [group, setGroup] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); + const [loading, setLoading] = useState(true); + const [seasonCountdown, setSeasonCountdown] = useState(getSeasonCountdown()); + + useEffect(() => { + const interval = setInterval(() => { + setSeasonCountdown(getSeasonCountdown()); + }, 1000 * 60 * 60 * 12); + return () => clearInterval(interval); + }, []); + + const refresh = useCallback(async () => { + if (!profile?.uid) { + setLoading(false); + return; + } + + try { + setLoading(true); + const currentGroup = await getOrCreateLeagueGroup(profile.league, profile.uid); + setGroup(currentGroup); + const data = await fetchLeaderboard(currentGroup.leagueId); + if (data) { + setLeaderboard(data.members); + } + } finally { + setLoading(false); + } + }, [profile?.league, profile?.uid]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const rewards = profile ? getLeagueRewards(profile.league) : { placementBonus: 0, streakMultiplierBonus: 0 }; + + return { + group, + leaderboard, + loading, + refresh, + rewards, + seasonCountdown + }; +}; diff --git a/src/hooks/useSquad.ts b/src/hooks/useSquad.ts new file mode 100644 index 0000000..8f50ad4 --- /dev/null +++ b/src/hooks/useSquad.ts @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; + +import { useAuth } from './useAuth'; +import { Squad } from '@/types/models'; +import { + addShoppingListItem, + createSquad, + distributeReceiptPoints, + generateSquadInviteLink, + joinSquad, + listenToSquad, + updateSharedGoal +} from '@/services/squadService'; + +export const useSquad = () => { + const { user, profile } = useAuth(); + const [squad, setSquad] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!profile?.squadId) { + setSquad(null); + setLoading(false); + return; + } + + const unsubscribe = listenToSquad(profile.squadId, data => { + setSquad(data); + setLoading(false); + }); + + return unsubscribe; + }, [profile?.squadId]); + + const createNewSquad = async (squadName: string) => { + if (!user) throw new Error('Must be signed in'); + const newSquad = await createSquad(squadName, user.uid); + setSquad(newSquad); + return newSquad; + }; + + const joinExistingSquad = async (squadId: string) => { + if (!user) throw new Error('Must be signed in'); + const updatedSquad = await joinSquad(squadId, user.uid); + setSquad(updatedSquad); + return updatedSquad; + }; + + const shareReceiptWithSquad = async (totalPoints: number) => { + if (!squad) throw new Error('You need a squad to share points'); + await distributeReceiptPoints(squad.squadId, totalPoints); + }; + + const addItemToShoppingList = async (itemName: string) => { + if (!squad || !user) throw new Error('You need a squad to add items'); + await addShoppingListItem(squad.squadId, itemName, user.uid); + }; + + const setSharedGoalTarget = async (target: number) => { + if (!squad) throw new Error('You need a squad to update goals'); + await updateSharedGoal(squad.squadId, target); + }; + + const inviteLink = squad ? generateSquadInviteLink(squad.squadId) : null; + + return { + squad, + loading, + createNewSquad, + joinExistingSquad, + shareReceiptWithSquad, + inviteLink, + addItemToShoppingList, + setSharedGoalTarget + }; +}; diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx new file mode 100644 index 0000000..0fa0b42 --- /dev/null +++ b/src/navigation/RootNavigator.tsx @@ -0,0 +1,29 @@ +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { ActivityIndicator, View } from 'react-native'; + +import AuthNavigator from './stacks/AuthNavigator'; +import MainNavigator from './stacks/MainNavigator'; +import { RootStackParamList } from '@/types/navigation'; +import { useAuth } from '@/hooks/useAuth'; + +const Stack = createNativeStackNavigator(); + +const RootNavigator = () => { + const { user, initializing } = useAuth(); + + if (initializing) { + return ( + + + + ); + } + + return ( + + {user ? : } + + ); +}; + +export default RootNavigator; diff --git a/src/navigation/stacks/AuthNavigator.tsx b/src/navigation/stacks/AuthNavigator.tsx new file mode 100644 index 0000000..06bcbba --- /dev/null +++ b/src/navigation/stacks/AuthNavigator.tsx @@ -0,0 +1,21 @@ +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +import LoginScreen from '@/screens/auth/LoginScreen'; +import RegisterScreen from '@/screens/auth/RegisterScreen'; +import { AuthStackParamList } from '@/types/navigation'; + +const Stack = createNativeStackNavigator(); + +const AuthNavigator = () => ( + + + + +); + +export default AuthNavigator; diff --git a/src/navigation/stacks/MainNavigator.tsx b/src/navigation/stacks/MainNavigator.tsx new file mode 100644 index 0000000..e9e5d96 --- /dev/null +++ b/src/navigation/stacks/MainNavigator.tsx @@ -0,0 +1,71 @@ +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { Ionicons } from '@expo/vector-icons'; + +import HomeScreen from '@/screens/main/HomeScreen'; +import ScanReceiptModal from '@/screens/main/ScanReceiptModal'; +import SquadScreen from '@/screens/squads/SquadScreen'; +import SquadInviteScreen from '@/screens/squads/SquadInviteScreen'; +import LeagueScreen from '@/screens/leagues/LeagueScreen'; +import ProfileScreen from '@/screens/main/ProfileScreen'; +import { HomeStackParamList, LeagueStackParamList, MainTabParamList, ProfileStackParamList, SquadStackParamList } from '@/types/navigation'; + +const Tab = createBottomTabNavigator(); +const HomeStack = createNativeStackNavigator(); +const SquadStack = createNativeStackNavigator(); +const LeagueStack = createNativeStackNavigator(); +const ProfileStack = createNativeStackNavigator(); + +const HomeNavigator = () => ( + + + + +); + +const SquadNavigator = () => ( + + + + +); + +const LeagueNavigator = () => ( + + + +); + +const ProfileNavigator = () => ( + + + +); + +const MainNavigator = () => ( + ({ + headerShown: false, + tabBarActiveTintColor: '#FF8F3F', + tabBarInactiveTintColor: '#7F8C8D', + tabBarStyle: { backgroundColor: '#FFFFFF' }, + tabBarIcon: ({ color, size }) => { + const icons: Record = { + HomeTab: 'home', + SquadTab: 'people', + LeagueTab: 'trophy', + ProfileTab: 'person-circle' + }; + const iconName = icons[route.name] ?? 'ellipse'; + return ; + } + })} + > + + + + + +); + +export default MainNavigator; diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx new file mode 100644 index 0000000..7c3b5d0 --- /dev/null +++ b/src/screens/auth/LoginScreen.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { Alert, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { LinearGradient } from 'expo-linear-gradient'; + +import { useAuth } from '@/hooks/useAuth'; +import { AuthStackParamList } from '@/types/navigation'; + +const LoginScreen = ({ navigation }: NativeStackScreenProps) => { + const { login } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + const handleLogin = async () => { + try { + setLoading(true); + await login(email, password); + } catch (error) { + Alert.alert('Login failed', (error as Error)?.message ?? 'Unable to log in.'); + } finally { + setLoading(false); + } + }; + + return ( + + + Fetch Squads + Sign in to reunite with your crew and chase rewards together. + + + + Email + + + + Password + + + + + + {loading ? 'Signing in...' : 'Sign In'} + + + navigation.navigate('Register')} className="mt-4 items-center"> + New to Fetch? Create an account + + + + ); +}; + +export default LoginScreen; diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx new file mode 100644 index 0000000..db32260 --- /dev/null +++ b/src/screens/auth/RegisterScreen.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { Alert, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { LinearGradient } from 'expo-linear-gradient'; + +import { useAuth } from '@/hooks/useAuth'; +import { AuthStackParamList } from '@/types/navigation'; + +const RegisterScreen = ({ navigation }: NativeStackScreenProps) => { + const { register } = useAuth(); + const [displayName, setDisplayName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + + const handleRegister = async () => { + if (password !== confirmPassword) { + Alert.alert('Oops!', 'Passwords do not match.'); + return; + } + + try { + setLoading(true); + await register(email, password, displayName); + } catch (error) { + Alert.alert('Registration failed', (error as Error)?.message ?? 'Unable to register.'); + } finally { + setLoading(false); + } + }; + + return ( + + + Ready to Squad Up? + Create your Fetch account and invite friends to earn rewards faster. + + + + Display name + + + + Email + + + + Password + + + + Confirm Password + + + + + + {loading ? 'Creating account...' : 'Create Account'} + + + navigation.navigate('Login')} className="mt-4 items-center"> + Already have an account? Sign in + + + + ); +}; + +export default RegisterScreen; diff --git a/src/screens/leagues/LeagueScreen.tsx b/src/screens/leagues/LeagueScreen.tsx new file mode 100644 index 0000000..d5e2ee3 --- /dev/null +++ b/src/screens/leagues/LeagueScreen.tsx @@ -0,0 +1,84 @@ +import { useMemo } from 'react'; +import { RefreshControl, ScrollView, Text, View } from 'react-native'; + +import { useAuth } from '@/hooks/useAuth'; +import { useLeague } from '@/hooks/useLeague'; + +const LeagueScreen = () => { + const { profile } = useAuth(); + const { leaderboard, loading, refresh, rewards, seasonCountdown } = useLeague(); + + const promotionCutoff = useMemo(() => Math.min(10, leaderboard.length), [leaderboard.length]); + + return ( + } + > + {profile?.league ?? 'Bronze'} League + Season ends in {seasonCountdown} โ€ข Weekly reset every Monday + + + This Week's Leaderboard + + Top {promotionCutoff} advance to the next league. Everyone keeps their hard-earned tier. + + + + {leaderboard.map((member, index) => { + const isCurrentUser = member.uid === profile?.uid; + const promoted = index < promotionCutoff; + return ( + + + + {index + 1}. {isCurrentUser ? 'You' : member.displayName ?? 'Fetcher'} + + {member.pointsThisWeek} pts โ€ข Streak x{member.streakMultiplier.toFixed(1)} + + {promoted && Promoted!} + + ); + })} + {!leaderboard.length && No leaderboard data yet. Scan a receipt to get started!} + + + + + Weekly Rewards + Top players earn a promotion bonus. + + + Placement Bonus + +{rewards.placementBonus} pts + + + Streak Bonus + +{rewards.streakMultiplierBonus} pts + + + + + + How Leagues Work + + Every Monday you're grouped with 30-50 players at your tier. Earn weekly points to climb the board. + + + Scan on at least 5 of 7 days to receive a 1.5x streak multiplier on your weekly points. + + + Finish in the top 10 to earn a promotion badge and bonus points. No demotions in this MVP. + + + + ); +}; + +export default LeagueScreen; diff --git a/src/screens/main/HomeScreen.tsx b/src/screens/main/HomeScreen.tsx new file mode 100644 index 0000000..24e336a --- /dev/null +++ b/src/screens/main/HomeScreen.tsx @@ -0,0 +1,88 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { useNavigation } from '@react-navigation/native'; +import { useMemo } from 'react'; +import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; + +import { useAuth } from '@/hooks/useAuth'; +import { useSquad } from '@/hooks/useSquad'; +import { HomeStackParamList } from '@/types/navigation'; +import WeeklyChallengeCard from '@/components/WeeklyChallengeCard'; +import { Challenge } from '@/types/models'; + +const demoChallenges: Challenge[] = [ + { + id: 'weekly-quest', + title: 'Squad Quest: Scan 5 receipts', + description: 'Team up and scan five receipts together to earn a 1.2x multiplier!', + progress: 3, + target: 5 + }, + { + id: 'duel', + title: 'Duel of the Week', + description: 'Challenge a squadmate to beat your points by Sunday night.', + progress: 1, + target: 1 + } +]; + +const HomeScreen = () => { + const navigation = useNavigation['navigation']>(); + const { profile } = useAuth(); + const { squad } = useSquad(); + + const squadProgress = useMemo(() => { + if (!squad) return 0; + const { currentPoints, targetPoints } = squad.sharedGoal; + return Math.min(1, currentPoints / targetPoints); + }, [squad]); + + return ( + + + + Hey {profile?.displayName ?? 'Fetcher'} ๐Ÿ‘‹ + You have {profile?.totalPoints ?? 0} lifetime points. + + navigation.navigate('ScanReceiptModal')} + className="mt-5 bg-secondary rounded-2xl py-4 items-center" + > + Scan Receipt + + + {squad ? ( + + Squad Goal + + + + + {squad.sharedGoal.currentPoints.toLocaleString()} / {squad.sharedGoal.targetPoints.toLocaleString()} points saved + + + ) : ( + navigation.getParent()?.navigate('SquadTab' as never)} + className="mt-6 bg-accent/20 border border-dashed border-accent rounded-2xl p-4" + > + + Create or join a squad to start sharing points! + + + )} + + + + Weekly Highlights + {demoChallenges.map(challenge => ( + + ))} + + + + ); +}; + +export default HomeScreen; diff --git a/src/screens/main/ProfileScreen.tsx b/src/screens/main/ProfileScreen.tsx new file mode 100644 index 0000000..f5f5ac4 --- /dev/null +++ b/src/screens/main/ProfileScreen.tsx @@ -0,0 +1,41 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; + +import { useAuth } from '@/hooks/useAuth'; + +const ProfileScreen = () => { + const { profile, logout } = useAuth(); + + return ( + + + + {profile?.displayName ?? 'Fetcher'} + {profile?.email} + + + + Lifetime Points + {profile?.totalPoints ?? 0} + + + League + {profile?.league ?? 'Bronze'} + Weekly points: {profile?.weeklyLeaguePoints ?? 0} + + + Scan Streak + {profile?.scanStreak.days ?? 0} days + + + + + Log out + + + + + ); +}; + +export default ProfileScreen; diff --git a/src/screens/main/ScanReceiptModal.tsx b/src/screens/main/ScanReceiptModal.tsx new file mode 100644 index 0000000..3d25f92 --- /dev/null +++ b/src/screens/main/ScanReceiptModal.tsx @@ -0,0 +1,69 @@ +import { useNavigation } from '@react-navigation/native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { useState } from 'react'; +import { Alert, Text, TouchableOpacity, View } from 'react-native'; + +import { useSquad } from '@/hooks/useSquad'; +import { HomeStackParamList } from '@/types/navigation'; + +const generatePoints = () => Math.floor(Math.random() * 400) + 100; + +const ScanReceiptModal = () => { + const navigation = useNavigation['navigation']>(); + const { squad, shareReceiptWithSquad } = useSquad(); + const [isScanning, setIsScanning] = useState(false); + const [points, setPoints] = useState(null); + + const handleSimulateScan = () => { + setIsScanning(true); + setTimeout(() => { + setPoints(generatePoints()); + setIsScanning(false); + }, 1200); + }; + + const handleShare = async () => { + if (!squad || !points) return; + try { + await shareReceiptWithSquad(points); + Alert.alert('Success', `Shared ${points} points with your squad!`); + navigation.goBack(); + } catch (error) { + Alert.alert('Error', (error as Error)?.message ?? 'Failed to share points.'); + } + }; + + return ( + + Scan Receipt + Use this simulator to award points and share them with your squad. + + + Camera simulation + + {isScanning ? 'Scanning...' : 'Tap to Scan'} + + + + {points && ( + + Receipt Points Awarded + {points} pts + + Share them with your squad to split evenly across all members. + + + + Share with Squad + + + )} + + ); +}; + +export default ScanReceiptModal; diff --git a/src/screens/squads/SquadInviteScreen.tsx b/src/screens/squads/SquadInviteScreen.tsx new file mode 100644 index 0000000..e2b288b --- /dev/null +++ b/src/screens/squads/SquadInviteScreen.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { Alert, Text, View } from 'react-native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; + +import { useSquad } from '@/hooks/useSquad'; +import { SquadStackParamList } from '@/types/navigation'; + +const SquadInviteScreen = ({ route, navigation }: NativeStackScreenProps) => { + const { joinExistingSquad } = useSquad(); + const { squadId } = route.params; + + useEffect(() => { + const join = async () => { + try { + await joinExistingSquad(squadId); + navigation.replace('Squad'); + } catch (error) { + Alert.alert('Unable to join squad', (error as Error)?.message ?? 'Please try again.'); + navigation.goBack(); + } + }; + + join(); + }, [joinExistingSquad, navigation, squadId]); + + return ( + + Joining squad... + + ); +}; + +export default SquadInviteScreen; diff --git a/src/screens/squads/SquadScreen.tsx b/src/screens/squads/SquadScreen.tsx new file mode 100644 index 0000000..763a27d --- /dev/null +++ b/src/screens/squads/SquadScreen.tsx @@ -0,0 +1,204 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import { useState } from 'react'; +import { Alert, FlatList, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +import Mascot from '@/components/Mascot'; +import { useAuth } from '@/hooks/useAuth'; +import { useSquad } from '@/hooks/useSquad'; + +const SquadScreen = () => { + const { profile } = useAuth(); + const { squad, loading, createNewSquad, joinExistingSquad, inviteLink, addItemToShoppingList, setSharedGoalTarget } = useSquad(); + const [squadName, setSquadName] = useState(''); + const [joinId, setJoinId] = useState(''); + const [itemName, setItemName] = useState(''); + const [updatingGoal, setUpdatingGoal] = useState(false); + + const members = squad?.members ?? []; + + const handleCreate = async () => { + if (!squadName.trim()) return; + try { + await createNewSquad(squadName.trim()); + setSquadName(''); + } catch (error) { + Alert.alert('Unable to create squad', (error as Error)?.message ?? 'Please try again.'); + } + }; + + const handleJoin = async () => { + if (!joinId.trim()) return; + try { + await joinExistingSquad(joinId.trim()); + setJoinId(''); + } catch (error) { + Alert.alert('Unable to join squad', (error as Error)?.message ?? 'Check the invite ID and try again.'); + } + }; + + const handleCopyInvite = () => { + if (inviteLink) { + Clipboard.setString(inviteLink); + Alert.alert('Invite link copied', 'Share it with your friends to squad up!'); + } + }; + + const handleAddItem = async () => { + if (!itemName.trim()) return; + try { + await addItemToShoppingList(itemName.trim()); + setItemName(''); + } catch (error) { + Alert.alert('Unable to add item', (error as Error)?.message ?? 'Please try again.'); + } + }; + + const handleUpdateGoal = async (target: number) => { + try { + setUpdatingGoal(true); + await setSharedGoalTarget(target); + } catch (error) { + Alert.alert('Unable to update goal', (error as Error)?.message ?? 'Please try again later.'); + } finally { + setUpdatingGoal(false); + } + }; + + if (loading) { + return ( + + Loading your squad... + + ); + } + + if (!squad) { + return ( + + Build your Squad + + Create a new squad or join an existing one to start splitting points, setting goals, and completing weekly quests. + + + + Create Squad + + + Create + + + + + Join Squad + + + Join + + + + ); + } + + return ( + + {squad.squadName} + Invite friends with the link below and start earning faster. + + + + + + + Copy invite link + {inviteLink} + + + + + Members + item} + renderItem={({ item }) => ( + {item === profile?.uid ? `${item} (you)` : item} + )} + /> + + + + Shared Goal + Current: {squad.sharedGoal.targetPoints.toLocaleString()} pts + + + + + {[10000, 25000, 50000].map(goal => ( + handleUpdateGoal(goal)} + className={`px-4 py-2 rounded-full border ${ + squad.sharedGoal.targetPoints === goal ? 'bg-accent text-white border-accent' : 'border-gray-200' + }`} + > + {goal.toLocaleString()} pts + + ))} + + {updatingGoal && Updating goal...} + + + + Squad Shopping List + + + + Add + + + + {squad.shoppingList.map(entry => ( + + {entry.itemName} + by {entry.addedBy} + + ))} + + + + + Weekly Challenges + + Quest + Scan 5 receipts together this week. + Progress updates automatically after each scan. + + + Squad Duel + + Challenge a teammate and see who collects more points before Sunday night. + + + + + ); +}; + +export default SquadScreen; diff --git a/src/services/firebase.ts b/src/services/firebase.ts new file mode 100644 index 0000000..9c8d226 --- /dev/null +++ b/src/services/firebase.ts @@ -0,0 +1,26 @@ +import { initializeApp, getApps, FirebaseApp } from 'firebase/app'; +import { getAuth } from 'firebase/auth'; +import { getFirestore } from 'firebase/firestore'; +import Constants from 'expo-constants'; + +let app: FirebaseApp; + +const initFirebase = () => { + if (!getApps().length) { + const firebaseConfig = Constants?.expoConfig?.extra?.firebase; + + if (!firebaseConfig) { + throw new Error('Firebase configuration is missing. Make sure to define it in the .env file.'); + } + + app = initializeApp(firebaseConfig); + } else { + app = getApps()[0]; + } + + return app; +}; + +export const firebaseApp = initFirebase(); +export const auth = getAuth(firebaseApp); +export const db = getFirestore(firebaseApp); diff --git a/src/services/leagueService.ts b/src/services/leagueService.ts new file mode 100644 index 0000000..df8652d --- /dev/null +++ b/src/services/leagueService.ts @@ -0,0 +1,89 @@ +import { doc, getDoc, setDoc, updateDoc } from 'firebase/firestore'; + +import { db } from './firebase'; +import { LeagueGroup, LeagueGroupMember, LeagueTier } from '@/types/models'; +import { getWeekRange } from '@/utils/date'; +import { hashStringToNumber } from '@/utils/hash'; + +const leagueTiers: LeagueTier[] = ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Elite', 'Legend', 'Mythic']; + +export const getNextLeague = (current: LeagueTier): LeagueTier => { + const index = leagueTiers.indexOf(current); + return leagueTiers[Math.min(index + 1, leagueTiers.length - 1)]; +}; + +export const getLeagueGroupId = (league: LeagueTier, weekKey: string, groupNumber: number) => + `${league.toLowerCase()}_${weekKey}_group_${groupNumber}`; + +export const getOrCreateLeagueGroup = async (league: LeagueTier, uid: string) => { + const { key, weekStart, weekEnd } = getWeekRange(); + const groupNumber = hashStringToNumber(uid + key + league, 5) + 1; + const targetGroupId = getLeagueGroupId(league, key, groupNumber); + + const groupRef = doc(db, 'leagues', targetGroupId); + const groupSnapshot = await getDoc(groupRef); + if (!groupSnapshot.exists()) { + const newGroup: LeagueGroup = { + leagueId: targetGroupId, + leagueName: league, + members: [], + weekStartDate: weekStart.toISOString(), + weekEndDate: weekEnd.toISOString(), + weekKey: key + }; + await setDoc(groupRef, newGroup); + } + + const groupData = (await getDoc(groupRef)).data() as LeagueGroup; + const members = groupData.members ?? []; + + const member: LeagueGroupMember = { + uid, + displayName: 'You', + pointsThisWeek: 0, + league, + streakMultiplier: 1 + }; + + await updateDoc(groupRef, { + members: [...members.filter(existing => existing.uid !== uid), member] + }); + + return { ...groupData, members: [...members.filter(existing => existing.uid !== uid), member] }; +}; + +export const updateWeeklyPoints = async (leagueId: string, member: LeagueGroupMember) => { + const groupRef = doc(db, 'leagues', leagueId); + const snapshot = await getDoc(groupRef); + if (!snapshot.exists()) return; + + const group = snapshot.data() as LeagueGroup; + const members = (group.members ?? []).filter(existing => existing.uid !== member.uid); + await updateDoc(groupRef, { members: [...members, member] }); +}; + +export const fetchLeaderboard = async (leagueId: string) => { + const snapshot = await getDoc(doc(db, 'leagues', leagueId)); + if (!snapshot.exists()) return null; + const group = snapshot.data() as LeagueGroup; + return { + ...group, + members: [...group.members].sort((a, b) => b.pointsThisWeek - a.pointsThisWeek) + }; +}; + +export const getSeasonCountdown = () => { + const seasonEnd = new Date(); + seasonEnd.setDate(seasonEnd.getDate() + (30 - (seasonEnd.getDate() % 30))); + const diff = seasonEnd.getTime() - Date.now(); + const days = Math.max(0, Math.floor(diff / (1000 * 60 * 60 * 24))); + return `${days} days`; +}; + +export const getLeagueRewards = (league: LeagueTier) => { + const base = (leagueTiers.indexOf(league) + 1) * 100; + return { + placementBonus: base, + streakMultiplierBonus: base / 2 + }; +}; diff --git a/src/services/squadService.ts b/src/services/squadService.ts new file mode 100644 index 0000000..1ed1d78 --- /dev/null +++ b/src/services/squadService.ts @@ -0,0 +1,110 @@ +import { nanoid } from 'nanoid/non-secure'; +import { + Timestamp, + arrayUnion, + collection, + doc, + getDoc, + increment, + onSnapshot, + setDoc, + updateDoc +} from 'firebase/firestore'; + +import { db } from './firebase'; +import { Squad } from '@/types/models'; + +export const squadsCollection = collection(db, 'squads'); + +export const generateSquadInviteLink = (squadId: string) => `fetchapp://squad/join/${squadId}`; + +export const createSquad = async (squadName: string, ownerId: string) => { + const squadId = nanoid(8); + const squadRef = doc(squadsCollection, squadId); + const squad: Squad = { + squadId, + squadName, + members: [ownerId], + mascotStatus: 'happy', + sharedGoal: { + targetPoints: 25000, + currentPoints: 0 + }, + shoppingList: [] + }; + + await setDoc(squadRef, squad); + await updateDoc(doc(db, 'users', ownerId), { squadId }); + + return squad; +}; + +export const listenToSquad = (squadId: string, callback: (squad: Squad | null) => void) => + onSnapshot(doc(squadsCollection, squadId), snapshot => { + if (snapshot.exists()) { + callback(snapshot.data() as Squad); + } else { + callback(null); + } + }); + +export const joinSquad = async (squadId: string, userId: string) => { + const squadRef = doc(squadsCollection, squadId); + const squadSnap = await getDoc(squadRef); + if (!squadSnap.exists()) { + throw new Error('Squad not found'); + } + + const squad = squadSnap.data() as Squad; + if (squad.members.includes(userId)) { + return squad; + } + + await updateDoc(squadRef, { + members: arrayUnion(userId) + }); + + await updateDoc(doc(db, 'users', userId), { squadId }); + return (await getDoc(squadRef)).data() as Squad; +}; + +export const addShoppingListItem = async (squadId: string, itemName: string, addedBy: string) => { + const squadRef = doc(squadsCollection, squadId); + const itemId = nanoid(); + await updateDoc(squadRef, { + shoppingList: arrayUnion({ + id: itemId, + itemName, + addedBy, + createdAt: Timestamp.now().toDate().toISOString() + }) + }); +}; + +export const updateSharedGoal = async (squadId: string, targetPoints: number) => { + await updateDoc(doc(squadsCollection, squadId), { + 'sharedGoal.targetPoints': targetPoints + }); +}; + +export const distributeReceiptPoints = async (squadId: string, totalPoints: number) => { + const squadSnap = await getDoc(doc(squadsCollection, squadId)); + if (!squadSnap.exists()) { + throw new Error('Squad not found'); + } + const squad = squadSnap.data() as Squad; + const perMember = Math.floor(totalPoints / squad.members.length); + + const batchUpdates = squad.members.map(memberId => + updateDoc(doc(db, 'users', memberId), { + totalPoints: increment(perMember), + weeklyLeaguePoints: increment(perMember) + }) + ); + + await Promise.all(batchUpdates); + await updateDoc(doc(squadsCollection, squadId), { + 'sharedGoal.currentPoints': increment(totalPoints), + mascotStatus: totalPoints > 400 ? 'excited' : 'happy' + }); +}; diff --git a/src/types/models.ts b/src/types/models.ts new file mode 100644 index 0000000..75ae5d9 --- /dev/null +++ b/src/types/models.ts @@ -0,0 +1,66 @@ +export type LeagueTier = + | 'Bronze' + | 'Silver' + | 'Gold' + | 'Platinum' + | 'Diamond' + | 'Elite' + | 'Legend' + | 'Mythic'; + +export interface UserProfile { + uid: string; + email: string; + displayName: string; + totalPoints: number; + squadId?: string | null; + league: LeagueTier; + weeklyLeaguePoints: number; + scanStreak: { + days: number; + lastScanDate?: string; + }; + createdAt: string; +} + +export interface Squad { + squadId: string; + squadName: string; + members: string[]; + mascotStatus: 'happy' | 'excited' | 'sleepy'; + sharedGoal: { + targetPoints: number; + currentPoints: number; + }; + shoppingList: Array<{ + id: string; + itemName: string; + addedBy: string; + createdAt: string; + }>; +} + +export interface LeagueGroupMember { + uid: string; + displayName: string; + pointsThisWeek: number; + league: LeagueTier; + streakMultiplier: number; +} + +export interface LeagueGroup { + leagueId: string; + leagueName: LeagueTier; + members: LeagueGroupMember[]; + weekStartDate: string; + weekEndDate: string; + weekKey?: string; +} + +export interface Challenge { + id: string; + title: string; + description: string; + progress: number; + target: number; +} diff --git a/src/types/navigation.ts b/src/types/navigation.ts new file mode 100644 index 0000000..bccf038 --- /dev/null +++ b/src/types/navigation.ts @@ -0,0 +1,34 @@ +export type RootStackParamList = { + Auth: undefined; + Main: undefined; +}; + +export type AuthStackParamList = { + Login: undefined; + Register: undefined; +}; + +export type MainTabParamList = { + HomeTab: undefined; + SquadTab: { squadId?: string } | undefined; + LeagueTab: undefined; + ProfileTab: undefined; +}; + +export type HomeStackParamList = { + Home: undefined; + ScanReceiptModal: undefined; +}; + +export type SquadStackParamList = { + Squad: { squadId?: string } | undefined; + SquadInvite: { squadId: string }; +}; + +export type LeagueStackParamList = { + League: undefined; +}; + +export type ProfileStackParamList = { + Profile: undefined; +}; diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..e05533e --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,22 @@ +export const getWeekRange = (date: Date = new Date()) => { + const current = new Date(date); + const day = current.getUTCDay(); + const diffToMonday = (day + 6) % 7; + const weekStart = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() - diffToMonday)); + const weekEnd = new Date(weekStart); + weekEnd.setUTCDate(weekStart.getUTCDate() + 7); + + return { + weekStart, + weekEnd, + key: `${weekStart.getUTCFullYear()}-W${String(getWeekNumber(current)).padStart(2, '0')}` + }; +}; + +export const getWeekNumber = (date: Date) => { + const tmpDate = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const dayNum = tmpDate.getUTCDay() || 7; + tmpDate.setUTCDate(tmpDate.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(tmpDate.getUTCFullYear(), 0, 1)); + return Math.ceil(((tmpDate.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +}; diff --git a/src/utils/hash.ts b/src/utils/hash.ts new file mode 100644 index 0000000..2fd148c --- /dev/null +++ b/src/utils/hash.ts @@ -0,0 +1,8 @@ +export const hashStringToNumber = (value: string, max: number) => { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash) % max; +}; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..04964b7 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,16 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./App.{ts,tsx}', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + primary: '#FF8F3F', + secondary: '#7F5AF0', + accent: '#2CB9B0', + background: '#F7F7FF', + surface: '#FFFFFF' + } + } + }, + plugins: [] +}; diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..e97ad89 --- /dev/null +++ b/todo.md @@ -0,0 +1,16 @@ +# TODO + +## Plan +- [x] Initialize Expo React Native TypeScript project structure within repo +- [x] Configure NativeWind, React Navigation, Firebase dependencies +- [x] Implement authentication flow with Firebase Auth integration +- [x] Build Squad feature screens and logic (creation, join, sharing, mascot, challenges) +- [x] Build League feature screens and logic (leaderboard, weekly reset simulation) +- [x] Implement Firestore data models, hooks, and utilities +- [x] Add seeding script for Firestore fake data (seed.js) +- [x] Create README with setup and launch instructions +- [x] Update assets (mascot animation placeholder) and UI components +- [x] Final review and testing instructions + +## Review Notes +- Seed script generates demo data for Firestore using Firebase Admin. Provide service account via env variables. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8030269 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["src/*"] + } + } +}