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/*"] + } + } +}