diff --git a/README.md b/README.md index 31466b54c2..51d1bdbb8a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,26 @@ # Final Project -Replace this readme with your own information about your project. +BrainPet is a tamagotchi-like web app where the goal is to keep your virtual pet alive by tending to its needs. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +## The idea -## The problem +My goal was to create a virtual pet app that could integrate a learning aspect that allowed my students to solve exercises and gain coins and experience for their pets. The scope was a little ambitious for the given time frame, so it ended up just being a regular tamagotchi app for the hand-in, but I intend on developing the original idea later down the line. -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +## Technologies used + +- Frontend: React + TypeScript, TailwindCSS +- Backend: Node.js + Express +- Database: MongoDB + mongoose +- Authentication: JWT +- Navigation: TanStack Router (approved by Matilda) +- State management: Zustand +- External libraries: Framer Motion + node-cron + bcrypt +- Extracurricular hooks: useNavigate, useLocation. +- Additional tools: Figma, Postman, Lighthouse, Trello. + +The application is responsive for mobile, tablet and desktop (320px - 1600px) and supported on Chrome, Safari and Firefox. It follows accessibility standards, clean code practices and has a 100% score on Lighthouse. ## View it live -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +- Deployed frontend: https://brainpet.netlify.app/ +- Deployed backend: https://project-final-wgeu.onrender.com/ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000..65aaeb6ec6 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,14 @@ +# Node modules +node_modules + +# MacOS +.DS_Store + +# Environment variables +.env +.env.local +.env.*.local + +# Misc + +todo.md \ No newline at end of file diff --git a/backend/config/db.js b/backend/config/db.js new file mode 100644 index 0000000000..409f07d1a9 --- /dev/null +++ b/backend/config/db.js @@ -0,0 +1,19 @@ +import mongoose from "mongoose"; + +const connectDB = async () => { + try { + const uri = + process.env.NODE_ENV === "production" + ? process.env.MONGODB_URI_PROD + : process.env.MONGODB_URI_DEV; + + await mongoose.connect(uri); + + console.log(`✅ MongoDB connected to ${uri}`); + } catch (err) { + console.error("❌ MongoDB connection error:", err.message); + process.exit(1); + } +}; + +export default connectDB; \ No newline at end of file diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js new file mode 100644 index 0000000000..39f144aab3 --- /dev/null +++ b/backend/controllers/authController.js @@ -0,0 +1,94 @@ +import { User } from "../models/user.js"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; + +// Helper to generate JWT +const generateToken = (id) => { + return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: "30d" }); +}; + +// Register a new user +// POST /api/auth/register +// Public access + +export const registerUser = async (req, res) => { + const { initials, email, password, classroomCode } = req.body; + + try { + // Check if user exists + const userExists = await User.findOne({ email }); + if (userExists) { + return res.status(400).json({ error: "User already exists" }); + } + + // Hash password + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + // Create user + const user = await User.create({ + initials, + email, + password: hashedPassword, + classroomCode, + }); + + // Respond with token + res.status(201).json({ + _id: user._id, + initials: user.initials, + email: user.email, + classroomCode: user.classroomCode, + token: generateToken(user._id), + }); + + } catch (error) { + res.status(400).json({ error: error.message }); + } +}; + +// Log in an existing user +// POST api/auth/login +// Public access + +export const loginUser = async (req, res) => { + const { email, password } = req.body; + + try { + // Find user + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + // Compare password + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + // Respond with token + res.json({ + _id: user._id, + initials: user.initials, + email: user.email, + classroomCode: user.classroomCode, + token: generateToken(user._id), + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; + +// Get user profile info (minus password) +// GET api/auth/profile +// Private access + +export const getUserProfile = async (req, res) => { + try { + const user = await User.findById(req.user.id).select("-password"); + res.json(user); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; diff --git a/backend/controllers/petController.js b/backend/controllers/petController.js new file mode 100644 index 0000000000..d6bec304e7 --- /dev/null +++ b/backend/controllers/petController.js @@ -0,0 +1,292 @@ +import { Pet } from "../models/pet.js"; +import { Item } from "../models/item.js" +import { User } from "../models/user.js" +import { applyPetDecay } from "../utils/petUtils.js"; +import { awardCoins } from "../services/petService.js"; + +// ================= PET ===================== + +// POST create new pet +export const createPet = async (req, res) => { + + try { + + const { name } = req.body; + + // Find user's current pet + let existingPet = await Pet.findOne({ owner: req.user._id }).sort({ createdAt: -1 }); + + if (existingPet) { + existingPet = applyPetDecay(existingPet); + + if (existingPet.status === "alive") { + return res.status(400).json({ message: "You already have a living pet!" }); + } + } + + // Create pet + const newPet = await Pet.create({ + owner: req.user._id, + name: name || "Pomodoro", + coins: 500, + }); + + res.status(201).json(newPet); + + } catch (error) { + + console.error("Error creating pet:", error); + + res.status(500).json({ message: "Server error" }); + + } +}; + +// GET current pet +export const getPet = async (req, res) => { + + try { + // Find pet by user with status "alive" + let pet = await Pet.findOne({ owner: req.user._id, status: "alive" }); + + if (!pet) { + return res.status(404).json({ message: "No active pet found" }); + } + + // Apply natural decay for hunger, happiness, health + pet = applyPetDecay(pet); + + // Save changes with updated stats + await pet.save(); + + res.json(pet); + + } catch (error) { + + console.error("Error fetching pet:", error); + + res.status(500).json({ message: "Server error" }); + + } +}; + +// ================ PET INVENTORY ================== + +// GET inventory +export const getInventory = async (req, res) => { + try { + const pet = await Pet.findOne({ owner: req.user._id, status: "alive" }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + // Apply decay to make sure stats are up to date + applyPetDecay(pet); + await pet.save(); + + res.json(pet.inventory); + + } catch (error) { + + res.status(500).json({ message: "Failed to fetch inventory", error }); + } +}; + +// PATCH add item +export const addItem = async (req, res) => { + try { + const { itemName, category, quantity } = req.body; + + if (!itemName || !category || !quantity) { + return res.status(400).json({ message: "Item name, category and quantity required" }); + } + + const pet = await Pet.findOne({ owner: req.user._id, status: "alive" }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + // Check if item already exists + const existingItem = pet.inventory.find((item) => item.itemName === itemName); + + if (existingItem) { + existingItem.quantity += quantity; + } else { + pet.inventory.push({ itemName, category, quantity }); + } + + await pet.save(); + res.json({ message: "Item(s) added", inventory: pet.inventory }); + } catch (error) { + res.status(500).json({ message: "Failed to add item", error }); + } +}; + +// PATCH remove item +export const removeItem = async (req, res) => { + try { + const { itemName, quantity } = req.body; + + if (!itemName) { + return res.status(400).json({ message: "Item name required" }); + } + + const pet = await Pet.findOne({ owner: req.user._id, status: "alive" }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + const item = pet.inventory.find((item) => item.itemName === itemName); + if (!item) { + return res.status(404).json({ message: "Item not found in inventory" }); + } + + item.quantity -= quantity; + + // If quantity <= 0, remove item entirely + if (item.quantity <= 0) { + pet.inventory = pet.inventory.filter((i) => i.itemName !== itemName); + } + + await pet.save(); + res.json({ message: "Item removed", inventory: pet.inventory }); + } catch (error) { + res.status(500).json({ message: "Failed to remove item", error }); + } +}; + +// ================ GAMPEPLAY ==================== + +// PATCH /api/pet/use-item +export const useItem = async (req, res) => { + try { + const { itemName } = req.body; + + if (!itemName) { + return res.status(400).json({ message: "Item name required" }); + } + + let pet = await Pet.findOne({ owner: req.user._id, status: "alive" }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + // Apply decay before any changes + pet = applyPetDecay(pet); + + if (pet.status === "expired") { + return res.status(400).json({ message: "Cannot use item on an expired pet" }); + } + + // Find item in inventory + const inventoryItem = pet.inventory.find( + (item) => item.itemName === itemName && item.quantity > 0 + ); + if (!inventoryItem) { + return res.status(404).json({ message: "Item not found in inventory" }); + } + + // Fetch item details from Item collection in DB + const storeItem = await Item.findOne({ name: itemName }); + if (!storeItem) { + return res.status(404).json({ message: "Item definition not found" }); + } + + // Apply all stat effects + let effectApplied = false; + + storeItem.effects?.forEach(({ stat, amount }) => { + if (["hunger", "happiness", "health"].includes(stat)) { + const before = pet[stat]; + pet[stat] = Math.min(5, Math.max(0, pet[stat] + amount)); // cap 0-5 + if (pet[stat] !== before) effectApplied = true; + } else if (["coins"].includes(stat)) { + const before = pet[stat]; + pet[stat] = Math.max(0, pet[stat] + amount); // no cap, but no negative values + if (pet[stat] !== before) effectApplied = true; + } + }); + + // Apply all condition effects + let conditionApplied = false; + + storeItem.conditions?.forEach(({ condition, setTo }) => { + if (pet.conditions && condition in pet.conditions) { + const before = pet.conditions[condition]; + pet.conditions[condition] = setTo; + if (before !== setTo) conditionApplied = true; + } + }); + + // Apply power-up + let powerupApplied = false; + + if (storeItem.powerup?.type && storeItem.powerup?.duration) { + + // check if powerup is already active + const alreadyActive = pet.activePowerups.find( + (p) => + p.type === storeItem.powerup.type && + new Date(p.expiresAt).getTime() > Date.now() + ); + + if (!alreadyActive) { + pet.activePowerups.push({ + type: storeItem.powerup.type, + expiresAt: new Date(Date.now() + storeItem.powerup.duration), + }); + powerupApplied = true; + } else { + return res.status(400).json( + { message: `${storeItem.powerup.type} is already active.` } + ); + } + } + + // Only decrement inventory if something happened + if (effectApplied || conditionApplied || powerupApplied) { + inventoryItem.quantity -= 1; + if (inventoryItem.quantity <= 0) { + pet.inventory = pet.inventory.filter((i) => i.itemName !== itemName); + } + } else { + return res.status(400).json({ message: `${itemName} had no effect.` }); + } + + await pet.save(); + + res.json({ + message: `${itemName} used! Your pet looks pleased.`, + pet, + }); + } catch (error) { + res.status(500).json({ message: "Failed to use item", error }); + } +}; + +// ================ PROGRESSION ====================== + +// PATCH /api/pet/coins +export const addCoins = async (req, res) => { + try { + const { amount } = req.body; + if (typeof amount !== "number" || amount <= 0) { + return res.status(400).json({ message: "Coin amount must be a positive integer" }); + } + + const result = await awardCoins(req.user._id, amount); + + res.json({ + message: `Added ${result.finalAmount} coins`, + pet: result.pet, + }); + } catch (error) { + if (error.message === "Pet not found") { + return res.status(404).json({ message: error.message }); + } + if (error.message.includes("expired")) { + return res.status(400).json({ message: error.message }); + } + res.status(500).json({ message: "Failed to add coins", error }); + } +}; \ No newline at end of file diff --git a/backend/controllers/storeController.js b/backend/controllers/storeController.js new file mode 100644 index 0000000000..10534aa7f0 --- /dev/null +++ b/backend/controllers/storeController.js @@ -0,0 +1,77 @@ +import { Pet } from "../models/pet.js"; +import { Item } from "../models/item.js" + +// GET all items from store +export const getStoreItems = async (req, res) => { + try { + const items = await Item.find(); + res.status(200).json(items); + } catch (error) { + res.status(500).json({ message: "Error fetching store items.", error }); + } +}; + +// GET fetch a single item by ID +export const getStoreItemById = async (req, res) => { + try { + const { id } = req.params; + const item = await Item.findById(id); + + if (!item) { + return res.status(404).json({ message: "Item not found!" }); + } + + res.status(200).json(item); + } catch (error) { + res.status(500).json({ message: "Error fetching store item.", error }); + } +}; + +// POST buy item from store +export const buyItem = async (req, res) => { + try { + const { itemId } = req.body; // (called itemId, but is actually the item name, not its actual id) + const userId = req.user._id; + + const pet = await Pet.findOne({ owner: userId, status: "alive" }); + if (!pet) { + return res.status(404).json({ message: "Pet not found!" }); + } + + const item = await Item.findOne({ name: itemId }); + if (!item) { + return res.status(404).json({ message: "Item not found!" }); + } + + if (pet.coins < item.price) { + return res.status(400).json({ message: "Not enough coins!" }); + } + + // Deduct coins + pet.coins -= item.price; + + // Check if pet already has this item in inventory + const existingItem = pet.inventory.find( + (invItem) => invItem.itemName === item.name + ); + + if (existingItem) { + existingItem.quantity += 1; + } else { + pet.inventory.push({ + itemName: item.name, + category: item.category, + quantity: 1, + }); + } + + await pet.save(); + + res.json({ + message: `${item.name} added to inventory!`, + pet, + }); + } catch (error) { + res.status(500).json({ message: "Error buying item!", error }); + } +}; diff --git a/backend/jobs/petCron.js b/backend/jobs/petCron.js new file mode 100644 index 0000000000..eb3b5c9a38 --- /dev/null +++ b/backend/jobs/petCron.js @@ -0,0 +1,31 @@ +// jobs/petCron.js + +import cron from "node-cron"; +import dotenv from "dotenv"; +import connectDB from "../config/db.js"; +import { Pet } from "../models/pet.js"; +import { applyPetDecay } from "../utils/petUtils.js"; + +dotenv.config(); + +// Connect to DB +connectDB(); + +// Schedule cron job to run every hour at minute 0 +cron.schedule("0 * * * *", async () => { + console.log(`[${new Date().toISOString()}] Running pet hourly update...`); + + try { + const pets = await Pet.find({ status: "alive" }); + + for (let pet of pets) { // potentially batch updates for performance in the future if need be (e.g. do 50 pets per batch) + const updatedPet = applyPetDecay(pet); + await updatedPet.save(); + } + + console.log(`[${new Date().toISOString()}] Pet updates complete.`); + + } catch (err) { + console.error("Error running pet hourly update:", err); + } +}); \ No newline at end of file diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js new file mode 100644 index 0000000000..0ada5ac6fb --- /dev/null +++ b/backend/middleware/authMiddleware.js @@ -0,0 +1,29 @@ +import jwt from "jsonwebtoken"; +import { User } from "../models/user.js"; + +export const authenticateUser = async (req, res, next) => { + let token; + + if ( + req.headers.authorization && + req.headers.authorization.startsWith("Bearer") + ) { + try { + token = req.headers.authorization.split(" ")[1]; + + // Verify token + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Attach user to request + req.user = await User.findById(decoded.id).select("-password"); + + next(); + } catch (error) { + return res.status(401).json({ error: "Not authorized, token failed" }); + } + } + + if (!token) { + return res.status(401).json({ error: "Not authorized, no token" }); + } +} \ No newline at end of file diff --git a/backend/models/item.js b/backend/models/item.js new file mode 100644 index 0000000000..6a9fbaa8a7 --- /dev/null +++ b/backend/models/item.js @@ -0,0 +1,59 @@ +import mongoose from "mongoose"; + +const itemSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + unique: true, + }, + category: { + type: String, + enum: ["food", "toy", "medicine", "powerup", "misc"], + required: true, + }, + effects: { + type: [ + { + stat: { + type: String, + enum: ["hunger", "happiness", "health", "coins"], + }, + amount: Number, + }, + ], + default: [], + }, + conditions: { + type: [ + { + condition: { + type: String, + enum: ["isSick", "isPooped"], // add more conditions later if need be + required: true, + }, + setTo: { + type: Boolean, + required: true, + }, + }, + ], + default: [], + }, + powerup: { + type: { + type: String, + enum: ["statFreeze", "doubleCoins"], // add more later if need be + }, + duration: Number, // in ms, e.g. 30 * 60 * 1000 (that equals 30 mins) + default: {} + }, + price: { + type: Number, + default: 0, // price in coins + }, + description: { + type: String, + } +}, { timestamps: true }); + +export const Item = mongoose.model("Item", itemSchema); \ No newline at end of file diff --git a/backend/models/pet.js b/backend/models/pet.js new file mode 100644 index 0000000000..dcde513bac --- /dev/null +++ b/backend/models/pet.js @@ -0,0 +1,95 @@ +import mongoose from "mongoose"; + +const petSchema = new mongoose.Schema( + { + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + name: { + type: String, + default: "Pomodoro", + }, + health: { + type: Number, + default: 5, + min: 0, + max: 5, + }, + happiness: { + type: Number, + default: 5, + min: 0, + max: 5, + }, + hunger: { + type: Number, + default: 5, + min: 0, + max: 5, + }, + coins: { + type: Number, + default: 0, + min: 0, + max: 998, + }, + inventory: [ + { + itemName: String, + category: { + type: String, + enum: ["food", "toy", "medicine", "powerup", "misc"], + }, + quantity: { + type: Number, + default: 1, + }, + }, + ], + lastUpdated: { + type: Date, + default: Date.now, + }, + statTimers: { + hungerLastUpdated: { type: Date, default: Date.now }, + happinessLastUpdated: { type: Date, default: Date.now }, + healthLastUpdated: { type: Date, default: Date.now }, + }, + conditions: { + isPooped: { type: Boolean, default: false }, + isSick: { type: Boolean, default: false }, + nextPoopTime: { type: Date, default: () => new Date(Date.now() + Math.random() * 24 * 60 * 60 * 1000) }, // once every 24hrs + nextSicknessTime: { type: Date, default: () => new Date(Date.now() + Math.random() * 14 * 24 * 60 * 60 * 1000) }, // once every 14 days + }, + activePowerups: [ + { + type: { + type: String, + enum: ["statFreeze", "doubleCoins"], // expand later potentially + required: true, + }, + expiresAt: { + type: Date, + required: true, + }, + } + ], + status: { + type: String, + enum: ["alive", "expired"], + default: "alive", + }, + bornAt: { + type: Date, + default: Date.now, + }, + expiredAt: { + type: Date, + }, + }, + { timestamps: true } +); + +export const Pet = mongoose.model("Pet", petSchema); \ No newline at end of file diff --git a/backend/models/user.js b/backend/models/user.js new file mode 100644 index 0000000000..cad086a3a8 --- /dev/null +++ b/backend/models/user.js @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; + +const userSchema = new mongoose.Schema( + { + initials: { + type: String, + required: [true, "First and last name initials are required"], + minlength: 2, + maxlength: 6, + trim: true, + set: (value) => value.toUpperCase(), + }, + email: { + type: String, + required: [true, "Email is required"], + unique: true, + lowercase: true, + trim: true, + match: [ + /^[a-zA-Z0-9._%+-]+@osloskolen\.no$/, + "Email must end with @osloskolen.no", + ], + }, + password: { + type: String, + required: [true, "Password is required"], + minlength: [8, "Password must be at least 8 characters"], + }, + classroomCode: { + type: String, + required: [true, "Classroom code is required"], + trim: true, + set: (value) => value.toUpperCase(), + match: [ + /^[A-Z]{3}-\d{4}$/, + "Classroom code must be in format XXX-0000", + ], + }, + }, + { timestamps: true } +); + +export const User = mongoose.model("User", userSchema); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..fd89d100d1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,20 +1,28 @@ { "name": "project-final-backend", "version": "1.0.0", + "type": "module", "description": "Server part of final project", "scripts": { - "start": "babel-node server.js", - "dev": "nodemon server.js --exec babel-node" + "start": "NODE_ENV=development babel-node server.js", + "dev": "NODE_ENV=development nodemon server.js --exec babel-node" }, "author": "", "license": "ISC", "dependencies": { - "@babel/core": "^7.17.9", - "@babel/node": "^7.16.8", - "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^17.2.1", "express": "^4.17.3", + "express-list-endpoints": "^7.1.1", + "jsonwebtoken": "^9.0.2", "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "node-cron": "^4.2.1" + }, + "devDependencies": { + "@babel/core": "^7.28.3", + "@babel/node": "^7.28.0", + "@babel/preset-env": "^7.28.3", + "nodemon": "^3.1.10" } -} \ No newline at end of file +} diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js new file mode 100644 index 0000000000..3d69fdd6c6 --- /dev/null +++ b/backend/routes/authRoutes.js @@ -0,0 +1,11 @@ +import express from "express"; +import { registerUser, loginUser, getUserProfile } from "../controllers/authController.js"; +import { authenticateUser } from "../middleware/authMiddleware.js"; + +const router = express.Router(); + +router.post("/register", registerUser); +router.post("/login", loginUser); +router.get("/profile", authenticateUser, getUserProfile); + +export default router; \ No newline at end of file diff --git a/backend/routes/petRoutes.js b/backend/routes/petRoutes.js new file mode 100644 index 0000000000..09fde095e7 --- /dev/null +++ b/backend/routes/petRoutes.js @@ -0,0 +1,30 @@ +import express from "express"; +import { authenticateUser } from "../middleware/authMiddleware.js"; +import { + createPet, + getPet, + useItem, + addCoins, + getInventory, + addItem, + removeItem, +} from "../controllers/petController.js"; + +const router = express.Router(); + +// Pet lifecycle +router.post("/", authenticateUser, createPet); +router.get("/", authenticateUser, getPet); + +// Gameplay +router.patch("/use-item", authenticateUser, useItem); + +// Progression +router.patch("/coins", authenticateUser, addCoins); + +// Inventory +router.get("/inventory", authenticateUser, getInventory); +router.patch("/inventory/add", authenticateUser, addItem); +router.patch("/inventory/remove", authenticateUser, removeItem); + +export default router; \ No newline at end of file diff --git a/backend/routes/storeRoutes.js b/backend/routes/storeRoutes.js new file mode 100644 index 0000000000..a450868854 --- /dev/null +++ b/backend/routes/storeRoutes.js @@ -0,0 +1,12 @@ +import express from "express"; +import { authenticateUser } from "../middleware/authMiddleware.js"; +import { getStoreItems, getStoreItemById, buyItem } from "../controllers/storeController.js" + +const router = express.Router(); + +// Store +router.get("/", authenticateUser, getStoreItems); +router.get("/:id", authenticateUser, getStoreItemById); +router.post("/buy", authenticateUser, buyItem); + +export default router; \ No newline at end of file diff --git a/backend/seeds/seedItems.js b/backend/seeds/seedItems.js new file mode 100644 index 0000000000..2cb5cc807c --- /dev/null +++ b/backend/seeds/seedItems.js @@ -0,0 +1,125 @@ +// seeds/seedItems.js +import dotenv from "dotenv"; +import connectDB from "../config/db.js"; +import { Item } from "../models/item.js"; + +dotenv.config(); + +const items = [ + { + name: "Peanuts", + category: "food", + effects: [{ stat: "hunger", amount: 1 }], + price: 10, + description: "A handful of peanuts. Yum!" + }, + { + name: "Blueberries", + category: "food", + effects: [ + { stat: "hunger", amount: 1 }, + { stat: "health", amount: 1 }, + ], + price: 18, + description: "Your pet's favorite healthy snack!" + }, + { + name: "Sushi", + category: "food", + effects: [{ stat: "hunger", amount: 2 }], + price: 20, + description: "A filling portion of freshly made maki. Heavenly!" + }, + { + name: "Burrito", + category: "food", + effects: [ + { stat: "hunger", amount: 2 }, + { stat: "happiness", amount: 1 }, + ], + price: 35, + description: "A juicy burrito for your pet. How nice!" + }, + { + name: "Yo-yo", + category: "toy", + effects: [{ stat: "happiness", amount: 1 }], + price: 10, + description: "A cool yo-yo to keep your pet busy. Neat!" + }, + { + name: "Ball", + category: "toy", + effects: [{ stat: "happiness", amount: 2 }], + price: 18, + description: "A bouncy ball to cheer up your pet. Hooray!" + }, + { + name: "Bandage", + category: "medicine", + effects: [{ stat: "health", amount: 1 }], + price: 10, + description: "A sturdy bandage to heal your pet's wounds. How nifty!" + }, + { + name: "Soap", + category: "medicine", + conditions: [ + { condition: "isPooped", setTo: false } + ], + price: 3, + description: "A bar of soap to clean up after your pet. Mess be gone!" + }, + { + name: "Mugwort", + category: "medicine", + effects: [{ stat: "health", amount: 2 }], + price: 18, + description: "A bitter herb to boost pet health. Open sesame!" + }, + { + name: "Medicine", + category: "medicine", + conditions: [ + { condition: "isSick", setTo: false } + ], + price: 40, + description: "A medicinal powder that cures disease. Hallelujah! " + }, + { + name: "Double Coins", + category: "powerup", + powerup: { type: "doubleCoins", duration: 30 * 60 * 1000 }, // 30 min + price: 200, + description: "Earn double coins for 30 minutes." + }, + { + name: "Stat Freeze", + category: "powerup", + powerup: { type: "statFreeze", duration: 24 * 60 * 60 * 1000 }, // 24 hrs + price: 75, + description: "Freeze all pet stats for 24 hours." + }, +]; + +// Seeding function +const seedItems = async () => { + try { + await connectDB(); + + // Clear out old items before seeding + await Item.deleteMany({}); + console.log("Existing items deleted"); + + // Insert new items + await Item.insertMany(items); + console.log("Items seeded successfully"); + + process.exit(); + } catch (error) { + console.error("Error seeding items:", error); + process.exit(1); + } +}; + +seedItems(); diff --git a/backend/server.js b/backend/server.js index 070c875189..cfdd93ea7e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,10 +1,15 @@ import express from "express"; import cors from "cors"; -import mongoose from "mongoose"; +import listEndpoints from "express-list-endpoints"; +import dotenv from "dotenv"; +import connectDB from "./config/db.js" +import authRoutes from "./routes/authRoutes.js"; +import petRoutes from "./routes/petRoutes.js"; +import storeRoutes from "./routes/storeRoutes.js"; +import "./jobs/petCron.js"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +dotenv.config(); +connectDB(); const port = process.env.PORT || 8080; const app = express(); @@ -12,10 +17,38 @@ const app = express(); app.use(cors()); app.use(express.json()); +// List all API endpoints for documentation + app.get("/", (req, res) => { - res.send("Hello Technigo!"); + const endpoints = listEndpoints(app); + res.json({ + message: "Welcome to the BrainPet API!", + endpoints: endpoints, + }); }); +// Endpoint for registering a user. +// Endpoint for logging in a user. +// Endpoint for retrieving the data of an authenticated user. + +app.use("/api/auth", authRoutes); + +// Endpoint for creating a pet. +// Endpoint for fetching pet. +// Endpoint for fetching pet inventory. +// Endpoint for adding item to inventory. +// Endpoint for removing item from inventory. +// Endpoint for using items on pet. +// Endpoint for adding coins to pet. + +app.use("/api/pet", petRoutes); + +// Endpoint for getting all store items +// Endpoint for getting single store item by id +// Endpoint for buying item from store + +app.use("/api/store", storeRoutes); + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); diff --git a/backend/services/petService.js b/backend/services/petService.js new file mode 100644 index 0000000000..a3673615c6 --- /dev/null +++ b/backend/services/petService.js @@ -0,0 +1,23 @@ +import { Pet } from "../models/pet.js"; +import { applyPetDecay } from "../utils/petUtils.js"; + +// Award coins + +export const awardCoins = async (userId, amount) => { + let pet = await Pet.findOne({ owner: userId, status: "alive" }); + if (!pet) throw new Error("Pet not found"); + + pet = applyPetDecay(pet); + if (pet.status === "expired") throw new Error("Cannot add coins to expired pet"); + + const hasDoubleCoins = pet.activePowerups.some( + (p) => p.type === "doubleCoins" && new Date(p.expiresAt).getTime() > Date.now() + ); + + const finalAmount = hasDoubleCoins ? amount * 2 : amount; + pet.coins = Math.min(998, pet.coins + finalAmount); + + await pet.save(); + + return { finalAmount, pet }; +}; \ No newline at end of file diff --git a/backend/utils/petUtils.js b/backend/utils/petUtils.js new file mode 100644 index 0000000000..ccc929ce4f --- /dev/null +++ b/backend/utils/petUtils.js @@ -0,0 +1,119 @@ +// Pet decay util + +export const applyPetDecay = (pet) => { + if (!pet || pet.status === "expired") return pet; + + const now = new Date(); + const MS_PER_HOUR = 1000 * 60 * 60; + + // Poop logic + const POOP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours + const MIN_TIME_AFTER_LAST_POOP = 8 * 60 * 60 * 1000; // 8 hours + + // Always reschedule next poop if the scheduled time has passed + if (pet.conditions.nextPoopTime <= now) { + + // Only mark as pooped if not already + if (!pet.conditions.isPooped) { + pet.conditions.isPooped = true; + } + + // Schedule the next poop from now + pet.conditions.nextPoopTime = new Date( + now.getTime() + MIN_TIME_AFTER_LAST_POOP + Math.random() * (POOP_INTERVAL - MIN_TIME_AFTER_LAST_POOP) + ); + } + + // Sickness logic + const SICKNESS_INTERVAL = 14 * 24 * 60 * 60 * 1000; // 14 days + const MIN_TIME_AFTER_LAST_SICKNESS = 2 * 24 * 60 * 60 * 1000; // 2 days + + // Always reschedule next sickness if the scheduled time has passed + if (pet.conditions.nextSicknessTime <= now) { + + // Only mark as sick if not already + if (!pet.conditions.isSick) { + pet.conditions.isSick = true; + } + + // Schedule the next sickness from now + pet.conditions.nextSicknessTime = new Date( + now.getTime() + + MIN_TIME_AFTER_LAST_SICKNESS + + Math.random() * (SICKNESS_INTERVAL - MIN_TIME_AFTER_LAST_SICKNESS) + ); + } + + // Remove expired power-ups (lazy evaluation) + if (pet.activePowerups?.length) { + pet.activePowerups = pet.activePowerups.filter( + (p) => new Date(p.expiresAt).getTime() > now + ); + } + + // If statFreeze is active, reset all stat timers to now and skip decay + const statFreeze = pet.activePowerups?.find( + (p) => p.type === "statFreeze" && new Date(p.expiresAt).getTime() > now + ); + + if (statFreeze) { + Object.keys(pet.statTimers).forEach((key) => { + pet.statTimers[key] = now; + }); + + pet.lastUpdated = now; + + return pet; + } + + // Retrieve the last updated time for a stat + const getTimer = (key) => { + return pet.statTimers?.[key] ? new Date(pet.statTimers[key]) : now; + } + + // Intervals in hours for each stat + const hungerInterval = 12; + const happinessInterval = pet.conditions.isPooped ? 6 : 12; + const healthInterval = pet.conditions.isSick ? 6 : 12; + + // Calculate cycles for each stat using its own timer + const hungerLastUpdated = getTimer("hungerLastUpdated"); + const happinessLastUpdated = getTimer("happinessLastUpdated"); + const healthLastUpdated = getTimer("healthLastUpdated"); + + const hungerHours = Math.floor((now - hungerLastUpdated) / MS_PER_HOUR); + const happinessHours = Math.floor((now - happinessLastUpdated) / MS_PER_HOUR); + const healthHours = Math.floor((now - healthLastUpdated) / MS_PER_HOUR); + + const hungerCycles = Math.floor(hungerHours / hungerInterval); + const happinessCycles = Math.floor(happinessHours / happinessInterval); + const healthCycles = Math.floor(healthHours / healthInterval); + + // Return if there are no stats to update + if (hungerCycles === 0 && happinessCycles === 0 && healthCycles === 0) { + return pet; + } + + // Apply any decay + pet.hunger = Math.max(0, pet.hunger - hungerCycles); + pet.happiness = Math.max(0, pet.happiness - happinessCycles); + pet.health = Math.max(0, pet.health - healthCycles); + + // Update each stat timer by the amount consumed for that stat, leftover hours carried over to next check + pet.statTimers.hungerLastUpdated = new Date(hungerLastUpdated.getTime() + hungerCycles * hungerInterval * MS_PER_HOUR); + pet.statTimers.happinessLastUpdated = new Date(happinessLastUpdated.getTime() + happinessCycles * happinessInterval * MS_PER_HOUR); + pet.statTimers.healthLastUpdated = new Date(healthLastUpdated.getTime() + healthCycles * healthInterval * MS_PER_HOUR); + + // Expire pet if any stat hits 0 + if (pet.health === 0 || pet.hunger === 0 || pet.happiness === 0) { + pet.status = "expired"; + pet.expiredAt = now; + } + + // Update lastUpdated (leave in for now) + pet.lastUpdated = now; + + return pet; + +}; + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000..287c6c7448 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Node modules +node_modules/ + +# Build output +dist/ +build/ + +# Logs +*.log + +# Misc +todo.md + +# Environment variables +.env +.env.local +.env.*.local + +# VS Code settings +.vscode/ + +# MacOS +.DS_Store + +# TanStack Query cache +.tanstack/ \ No newline at end of file diff --git a/frontend/declarations.d.ts b/frontend/declarations.d.ts new file mode 100644 index 0000000000..6dff89f56e --- /dev/null +++ b/frontend/declarations.d.ts @@ -0,0 +1,14 @@ +declare module "*.png" { + const src: string; + export default src; +} + +declare module "*.jpg" { + const src: string; + export default src; +} + +declare module "*.svg" { + const src: string; + export default src; +} diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..92b3c491e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,17 +10,27 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-router": "^1.131.27", + "@tanstack/react-router-devtools": "^1.131.27", + "motion": "^12.23.12", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zustand": "^5.0.7" }, "devDependencies": { - "@types/react": "^18.2.15", - "@types/react-dom": "^18.2.7", + "@tailwindcss/vite": "^4.1.12", + "@tanstack/router-plugin": "^1.131.27", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@typescript-eslint/eslint-plugin": "^8.40.0", + "@typescript-eslint/parser": "^8.40.0", "@vitejs/plugin-react": "^4.0.3", "eslint": "^8.45.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", + "tailwindcss": "^4.1.12", + "typescript": "^5.9.2", "vite": "^6.3.5" } } diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000000..50a463356b --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index 0a24275e6e..0000000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,8 +0,0 @@ -export const App = () => { - - return ( - <> -

Welcome to Final Project!

- - ); -}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000..bf9d98bdef --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,12 @@ +import { Outlet } from '@tanstack/react-router' + +const App = () => { + return ( +
+

My React + TanStack Router App

+ +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/frontend/src/assets/app/coins-hard.png b/frontend/src/assets/app/coins-hard.png new file mode 100644 index 0000000000..45705b77eb Binary files /dev/null and b/frontend/src/assets/app/coins-hard.png differ diff --git a/frontend/src/assets/app/console.svg b/frontend/src/assets/app/console.svg new file mode 100644 index 0000000000..083997051e --- /dev/null +++ b/frontend/src/assets/app/console.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/app/full-stat-5.png b/frontend/src/assets/app/full-stat-5.png new file mode 100644 index 0000000000..e34a7dd24b Binary files /dev/null and b/frontend/src/assets/app/full-stat-5.png differ diff --git a/frontend/src/assets/app/happiness.png b/frontend/src/assets/app/happiness.png new file mode 100644 index 0000000000..99670ba297 Binary files /dev/null and b/frontend/src/assets/app/happiness.png differ diff --git a/frontend/src/assets/app/health.png b/frontend/src/assets/app/health.png new file mode 100644 index 0000000000..cd0a238a8d Binary files /dev/null and b/frontend/src/assets/app/health.png differ diff --git a/frontend/src/assets/app/hunger.png b/frontend/src/assets/app/hunger.png new file mode 100644 index 0000000000..74cfa69b12 Binary files /dev/null and b/frontend/src/assets/app/hunger.png differ diff --git a/frontend/src/assets/app/sprite-frame-one.png b/frontend/src/assets/app/sprite-frame-one.png new file mode 100644 index 0000000000..b1c523e049 Binary files /dev/null and b/frontend/src/assets/app/sprite-frame-one.png differ diff --git a/frontend/src/assets/app/sprite-frame-two.png b/frontend/src/assets/app/sprite-frame-two.png new file mode 100644 index 0000000000..fa3d323ee9 Binary files /dev/null and b/frontend/src/assets/app/sprite-frame-two.png differ diff --git a/frontend/src/assets/landing/logo-sprite-card.png b/frontend/src/assets/landing/logo-sprite-card.png new file mode 100644 index 0000000000..5c334239da Binary files /dev/null and b/frontend/src/assets/landing/logo-sprite-card.png differ diff --git a/frontend/src/assets/landing/logo-sprite.png b/frontend/src/assets/landing/logo-sprite.png new file mode 100644 index 0000000000..1f75b4b40f Binary files /dev/null and b/frontend/src/assets/landing/logo-sprite.png differ diff --git a/frontend/src/components/app/AnimateSprite.tsx b/frontend/src/components/app/AnimateSprite.tsx new file mode 100644 index 0000000000..3898709040 --- /dev/null +++ b/frontend/src/components/app/AnimateSprite.tsx @@ -0,0 +1,31 @@ +import { motion } from "framer-motion"; +import { useState, useEffect } from "react"; +import spriteOne from "../../assets/app/sprite-frame-one.png"; +import spriteTwo from "../../assets/app/sprite-frame-two.png"; + +export const AnimateSprite = () => { + const [frame, setFrame] = useState(1); + + // Toggle between the two frames every 500ms + useEffect(() => { + const intervalId = setInterval(() => { + setFrame((prev) => (prev === 1 ? 2 : 1)); + }, 600); // Change frame every 500ms + + return () => clearInterval(intervalId); // Clean up the interval when component unmounts + }, []); + + return ( + + {`Frame + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/app/AppHeader.tsx b/frontend/src/components/app/AppHeader.tsx new file mode 100644 index 0000000000..a72561d57a --- /dev/null +++ b/frontend/src/components/app/AppHeader.tsx @@ -0,0 +1,24 @@ +import { useAuthStore } from "../../store/auth"; +import { useNavigate } from "@tanstack/react-router"; + +export const AppHeader = () => { + const user = useAuthStore((state) => state.user); + const logout = useAuthStore((state) => state.logout); + const navigate = useNavigate(); + + const greetingName = user?.initials || "there"; + + return ( +
+

+ Hi, {greetingName}! +

+

+ BrainPet v1.0 +

+ +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/app/LeftPanel.tsx b/frontend/src/components/app/LeftPanel.tsx new file mode 100644 index 0000000000..b35c4c609d --- /dev/null +++ b/frontend/src/components/app/LeftPanel.tsx @@ -0,0 +1,15 @@ +import console from "../../assets/app/console.svg" +import { AnimateSprite } from "./AnimateSprite" + +export const LeftPanel = () => { + return ( +
+ Pet console +
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/landing/FeatureCard.tsx b/frontend/src/components/landing/FeatureCard.tsx new file mode 100644 index 0000000000..7463af8053 --- /dev/null +++ b/frontend/src/components/landing/FeatureCard.tsx @@ -0,0 +1,12 @@ +type CardProps = { + children: React.ReactNode; + className?: string; +}; + +export const FeatureCard = ({ children, className=''}: CardProps) => { + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/landing/Features.tsx b/frontend/src/components/landing/Features.tsx new file mode 100644 index 0000000000..a421ac6ff4 --- /dev/null +++ b/frontend/src/components/landing/Features.tsx @@ -0,0 +1,26 @@ +import { FeatureCard } from "./FeatureCard" +import spriteOne from "../../assets/landing/logo-sprite-card.png" + +export const Features = () => { + return ( +
+ {/* Main card */} + +
+
+

+ Feed your pet.
+ Feed your brain. +

+

+ Earn coins by completing daily quests, then spend them on food, toys, and medicine to keep your 8-bit buddy alive and happy. +

+
+
+ Happy pet +
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx new file mode 100644 index 0000000000..23b5cb1363 --- /dev/null +++ b/frontend/src/components/layout/Footer.tsx @@ -0,0 +1,25 @@ +import { Link } from "@tanstack/react-router" +import logoSprite from "../../assets/landing/logo-sprite.png" + +export const Footer = () => { + return ( +
+ + Home + + + About + + + Privacy Policy + + + Terms of Service + +
+ BrainPet logo +

BrainPet © 2025

+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000000..df6256ee46 --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,15 @@ +import { NavBar } from "./NavBar" +import { Link } from "@tanstack/react-router" +import logoSprite from "../../assets/landing/logo-sprite.png" + +export const Header = () => { + return ( +
+ + BrainPet logo +

BrainPet

+ + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/layout/NavBar.tsx b/frontend/src/components/layout/NavBar.tsx new file mode 100644 index 0000000000..8aa05842e7 --- /dev/null +++ b/frontend/src/components/layout/NavBar.tsx @@ -0,0 +1,14 @@ +import { Link } from "@tanstack/react-router" + +export const NavBar = () => { + return ( +
+ {/* + About + */} + + Log In + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb2..6716ff1d4b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1,50 @@ +@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"); + +@import "tailwindcss"; + +@theme { + --color-ammo-100: #eeffcc; + --color-ammo-200: #bedc7f; + --color-ammo-300: #89a257; + --color-ammo-400: #4d8061; + --color-ammo-500: #305d42; + --color-ammo-600: #1e3a29; + --color-ammo-700: #112318; + --color-ammo-800: #040c06; + + --color-customs-100: #ffffff; + --color-customs-200: #414f43; + --color-customs-300: #597e64; /* sleep background color*/ + --color-customs-400: #89a257; + + --font-pstp: "Press Start 2P", "sans-serif"; + + --breakpoint-xs: 420px; +} + +@layer base { + html { + font-family: var(--font-pstp); + } + + body { + background-color: var(--color-ammo-800); + color: var(--color-customs-100); /* default text color */ + } + + img { + max-width: 100%; + height: auto; + } + + a { + color: inherit; + text-decoration: none; + } + + /* Custom selection highlight, still to be tweaked */ + ::selection { + background: var(--color-ammo-100); + color: var(--color-ammo-800); + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index 51294f3998..0000000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; -import "./index.css"; - -ReactDOM.createRoot(document.getElementById("root")).render( - - - -); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000..9f8837f7b8 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider, createRouter } from '@tanstack/react-router' +import "./index.css"; +import { useAuthStore } from "./store/auth" + +// import the generated route tree +import { routeTree } from './routeTree.gen' + +// create a new router instance +const router = createRouter({ routeTree }) + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +// Run rehydrate before router mounts +useAuthStore.getState().rehydrate() + +// Render the app + +const rootElement = document.getElementById('root')! +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000000..2dac6e32e0 --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,692 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as TosRouteImport } from './routes/tos' +import { Route as PrivpolicyRouteImport } from './routes/privpolicy' +import { Route as LoginRouteImport } from './routes/login' +import { Route as AppRouteImport } from './routes/app' +import { Route as AboutRouteImport } from './routes/about' +import { Route as IndexRouteImport } from './routes/index' +import { Route as AppStoreRouteImport } from './routes/app/store' +import { Route as AppStatsRouteImport } from './routes/app/stats' +import { Route as AppMenuRouteImport } from './routes/app/menu' +import { Route as AppInventoryRouteImport } from './routes/app/inventory' +import { Route as AppStoreToysRouteImport } from './routes/app/store/toys' +import { Route as AppStorePowerupsRouteImport } from './routes/app/store/powerups' +import { Route as AppStoreMedicineRouteImport } from './routes/app/store/medicine' +import { Route as AppStoreFoodRouteImport } from './routes/app/store/food' +import { Route as AppInventoryToysRouteImport } from './routes/app/inventory/toys' +import { Route as AppInventoryPowerupsRouteImport } from './routes/app/inventory/powerups' +import { Route as AppInventoryMedicineRouteImport } from './routes/app/inventory/medicine' +import { Route as AppInventoryFoodRouteImport } from './routes/app/inventory/food' +import { Route as AppStoreToysItemIdRouteImport } from './routes/app/store/toys.$itemId' +import { Route as AppStorePowerupsItemIdRouteImport } from './routes/app/store/powerups.$itemId' +import { Route as AppStoreMedicineItemIdRouteImport } from './routes/app/store/medicine.$itemId' +import { Route as AppStoreFoodItemIdRouteImport } from './routes/app/store/food.$itemId' +import { Route as AppInventoryToysItemIdRouteImport } from './routes/app/inventory/toys.$itemId' +import { Route as AppInventoryPowerupsItemIdRouteImport } from './routes/app/inventory/powerups.$itemId' +import { Route as AppInventoryMedicineItemIdRouteImport } from './routes/app/inventory/medicine.$itemId' +import { Route as AppInventoryFoodItemIdRouteImport } from './routes/app/inventory/food.$itemId' + +const TosRoute = TosRouteImport.update({ + id: '/tos', + path: '/tos', + getParentRoute: () => rootRouteImport, +} as any) +const PrivpolicyRoute = PrivpolicyRouteImport.update({ + id: '/privpolicy', + path: '/privpolicy', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const AppRoute = AppRouteImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AppStoreRoute = AppStoreRouteImport.update({ + id: '/store', + path: '/store', + getParentRoute: () => AppRoute, +} as any) +const AppStatsRoute = AppStatsRouteImport.update({ + id: '/stats', + path: '/stats', + getParentRoute: () => AppRoute, +} as any) +const AppMenuRoute = AppMenuRouteImport.update({ + id: '/menu', + path: '/menu', + getParentRoute: () => AppRoute, +} as any) +const AppInventoryRoute = AppInventoryRouteImport.update({ + id: '/inventory', + path: '/inventory', + getParentRoute: () => AppRoute, +} as any) +const AppStoreToysRoute = AppStoreToysRouteImport.update({ + id: '/toys', + path: '/toys', + getParentRoute: () => AppStoreRoute, +} as any) +const AppStorePowerupsRoute = AppStorePowerupsRouteImport.update({ + id: '/powerups', + path: '/powerups', + getParentRoute: () => AppStoreRoute, +} as any) +const AppStoreMedicineRoute = AppStoreMedicineRouteImport.update({ + id: '/medicine', + path: '/medicine', + getParentRoute: () => AppStoreRoute, +} as any) +const AppStoreFoodRoute = AppStoreFoodRouteImport.update({ + id: '/food', + path: '/food', + getParentRoute: () => AppStoreRoute, +} as any) +const AppInventoryToysRoute = AppInventoryToysRouteImport.update({ + id: '/toys', + path: '/toys', + getParentRoute: () => AppInventoryRoute, +} as any) +const AppInventoryPowerupsRoute = AppInventoryPowerupsRouteImport.update({ + id: '/powerups', + path: '/powerups', + getParentRoute: () => AppInventoryRoute, +} as any) +const AppInventoryMedicineRoute = AppInventoryMedicineRouteImport.update({ + id: '/medicine', + path: '/medicine', + getParentRoute: () => AppInventoryRoute, +} as any) +const AppInventoryFoodRoute = AppInventoryFoodRouteImport.update({ + id: '/food', + path: '/food', + getParentRoute: () => AppInventoryRoute, +} as any) +const AppStoreToysItemIdRoute = AppStoreToysItemIdRouteImport.update({ + id: '/$itemId', + path: '/$itemId', + getParentRoute: () => AppStoreToysRoute, +} as any) +const AppStorePowerupsItemIdRoute = AppStorePowerupsItemIdRouteImport.update({ + id: '/$itemId', + path: '/$itemId', + getParentRoute: () => AppStorePowerupsRoute, +} as any) +const AppStoreMedicineItemIdRoute = AppStoreMedicineItemIdRouteImport.update({ + id: '/$itemId', + path: '/$itemId', + getParentRoute: () => AppStoreMedicineRoute, +} as any) +const AppStoreFoodItemIdRoute = AppStoreFoodItemIdRouteImport.update({ + id: '/$itemId', + path: '/$itemId', + getParentRoute: () => AppStoreFoodRoute, +} as any) +const AppInventoryToysItemIdRoute = AppInventoryToysItemIdRouteImport.update({ + id: '/$itemId', + path: '/$itemId', + getParentRoute: () => AppInventoryToysRoute, +} as any) +const AppInventoryPowerupsItemIdRoute = + AppInventoryPowerupsItemIdRouteImport.update({ + id: '/$itemId', + path: '/$itemId', + getParentRoute: () => AppInventoryPowerupsRoute, + } as any) +const AppInventoryMedicineItemIdRoute = + AppInventoryMedicineItemIdRouteImport.update({ + id: '/$itemId', + path: '/$itemId', + getParentRoute: () => AppInventoryMedicineRoute, + } as any) +const AppInventoryFoodItemIdRoute = AppInventoryFoodItemIdRouteImport.update({ + id: '/$itemId', + path: '/$itemId', + getParentRoute: () => AppInventoryFoodRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/app': typeof AppRouteWithChildren + '/login': typeof LoginRoute + '/privpolicy': typeof PrivpolicyRoute + '/tos': typeof TosRoute + '/app/inventory': typeof AppInventoryRouteWithChildren + '/app/menu': typeof AppMenuRoute + '/app/stats': typeof AppStatsRoute + '/app/store': typeof AppStoreRouteWithChildren + '/app/inventory/food': typeof AppInventoryFoodRouteWithChildren + '/app/inventory/medicine': typeof AppInventoryMedicineRouteWithChildren + '/app/inventory/powerups': typeof AppInventoryPowerupsRouteWithChildren + '/app/inventory/toys': typeof AppInventoryToysRouteWithChildren + '/app/store/food': typeof AppStoreFoodRouteWithChildren + '/app/store/medicine': typeof AppStoreMedicineRouteWithChildren + '/app/store/powerups': typeof AppStorePowerupsRouteWithChildren + '/app/store/toys': typeof AppStoreToysRouteWithChildren + '/app/inventory/food/$itemId': typeof AppInventoryFoodItemIdRoute + '/app/inventory/medicine/$itemId': typeof AppInventoryMedicineItemIdRoute + '/app/inventory/powerups/$itemId': typeof AppInventoryPowerupsItemIdRoute + '/app/inventory/toys/$itemId': typeof AppInventoryToysItemIdRoute + '/app/store/food/$itemId': typeof AppStoreFoodItemIdRoute + '/app/store/medicine/$itemId': typeof AppStoreMedicineItemIdRoute + '/app/store/powerups/$itemId': typeof AppStorePowerupsItemIdRoute + '/app/store/toys/$itemId': typeof AppStoreToysItemIdRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/app': typeof AppRouteWithChildren + '/login': typeof LoginRoute + '/privpolicy': typeof PrivpolicyRoute + '/tos': typeof TosRoute + '/app/inventory': typeof AppInventoryRouteWithChildren + '/app/menu': typeof AppMenuRoute + '/app/stats': typeof AppStatsRoute + '/app/store': typeof AppStoreRouteWithChildren + '/app/inventory/food': typeof AppInventoryFoodRouteWithChildren + '/app/inventory/medicine': typeof AppInventoryMedicineRouteWithChildren + '/app/inventory/powerups': typeof AppInventoryPowerupsRouteWithChildren + '/app/inventory/toys': typeof AppInventoryToysRouteWithChildren + '/app/store/food': typeof AppStoreFoodRouteWithChildren + '/app/store/medicine': typeof AppStoreMedicineRouteWithChildren + '/app/store/powerups': typeof AppStorePowerupsRouteWithChildren + '/app/store/toys': typeof AppStoreToysRouteWithChildren + '/app/inventory/food/$itemId': typeof AppInventoryFoodItemIdRoute + '/app/inventory/medicine/$itemId': typeof AppInventoryMedicineItemIdRoute + '/app/inventory/powerups/$itemId': typeof AppInventoryPowerupsItemIdRoute + '/app/inventory/toys/$itemId': typeof AppInventoryToysItemIdRoute + '/app/store/food/$itemId': typeof AppStoreFoodItemIdRoute + '/app/store/medicine/$itemId': typeof AppStoreMedicineItemIdRoute + '/app/store/powerups/$itemId': typeof AppStorePowerupsItemIdRoute + '/app/store/toys/$itemId': typeof AppStoreToysItemIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/app': typeof AppRouteWithChildren + '/login': typeof LoginRoute + '/privpolicy': typeof PrivpolicyRoute + '/tos': typeof TosRoute + '/app/inventory': typeof AppInventoryRouteWithChildren + '/app/menu': typeof AppMenuRoute + '/app/stats': typeof AppStatsRoute + '/app/store': typeof AppStoreRouteWithChildren + '/app/inventory/food': typeof AppInventoryFoodRouteWithChildren + '/app/inventory/medicine': typeof AppInventoryMedicineRouteWithChildren + '/app/inventory/powerups': typeof AppInventoryPowerupsRouteWithChildren + '/app/inventory/toys': typeof AppInventoryToysRouteWithChildren + '/app/store/food': typeof AppStoreFoodRouteWithChildren + '/app/store/medicine': typeof AppStoreMedicineRouteWithChildren + '/app/store/powerups': typeof AppStorePowerupsRouteWithChildren + '/app/store/toys': typeof AppStoreToysRouteWithChildren + '/app/inventory/food/$itemId': typeof AppInventoryFoodItemIdRoute + '/app/inventory/medicine/$itemId': typeof AppInventoryMedicineItemIdRoute + '/app/inventory/powerups/$itemId': typeof AppInventoryPowerupsItemIdRoute + '/app/inventory/toys/$itemId': typeof AppInventoryToysItemIdRoute + '/app/store/food/$itemId': typeof AppStoreFoodItemIdRoute + '/app/store/medicine/$itemId': typeof AppStoreMedicineItemIdRoute + '/app/store/powerups/$itemId': typeof AppStorePowerupsItemIdRoute + '/app/store/toys/$itemId': typeof AppStoreToysItemIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/about' + | '/app' + | '/login' + | '/privpolicy' + | '/tos' + | '/app/inventory' + | '/app/menu' + | '/app/stats' + | '/app/store' + | '/app/inventory/food' + | '/app/inventory/medicine' + | '/app/inventory/powerups' + | '/app/inventory/toys' + | '/app/store/food' + | '/app/store/medicine' + | '/app/store/powerups' + | '/app/store/toys' + | '/app/inventory/food/$itemId' + | '/app/inventory/medicine/$itemId' + | '/app/inventory/powerups/$itemId' + | '/app/inventory/toys/$itemId' + | '/app/store/food/$itemId' + | '/app/store/medicine/$itemId' + | '/app/store/powerups/$itemId' + | '/app/store/toys/$itemId' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/about' + | '/app' + | '/login' + | '/privpolicy' + | '/tos' + | '/app/inventory' + | '/app/menu' + | '/app/stats' + | '/app/store' + | '/app/inventory/food' + | '/app/inventory/medicine' + | '/app/inventory/powerups' + | '/app/inventory/toys' + | '/app/store/food' + | '/app/store/medicine' + | '/app/store/powerups' + | '/app/store/toys' + | '/app/inventory/food/$itemId' + | '/app/inventory/medicine/$itemId' + | '/app/inventory/powerups/$itemId' + | '/app/inventory/toys/$itemId' + | '/app/store/food/$itemId' + | '/app/store/medicine/$itemId' + | '/app/store/powerups/$itemId' + | '/app/store/toys/$itemId' + id: + | '__root__' + | '/' + | '/about' + | '/app' + | '/login' + | '/privpolicy' + | '/tos' + | '/app/inventory' + | '/app/menu' + | '/app/stats' + | '/app/store' + | '/app/inventory/food' + | '/app/inventory/medicine' + | '/app/inventory/powerups' + | '/app/inventory/toys' + | '/app/store/food' + | '/app/store/medicine' + | '/app/store/powerups' + | '/app/store/toys' + | '/app/inventory/food/$itemId' + | '/app/inventory/medicine/$itemId' + | '/app/inventory/powerups/$itemId' + | '/app/inventory/toys/$itemId' + | '/app/store/food/$itemId' + | '/app/store/medicine/$itemId' + | '/app/store/powerups/$itemId' + | '/app/store/toys/$itemId' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute + AppRoute: typeof AppRouteWithChildren + LoginRoute: typeof LoginRoute + PrivpolicyRoute: typeof PrivpolicyRoute + TosRoute: typeof TosRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/tos': { + id: '/tos' + path: '/tos' + fullPath: '/tos' + preLoaderRoute: typeof TosRouteImport + parentRoute: typeof rootRouteImport + } + '/privpolicy': { + id: '/privpolicy' + path: '/privpolicy' + fullPath: '/privpolicy' + preLoaderRoute: typeof PrivpolicyRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/app/store': { + id: '/app/store' + path: '/store' + fullPath: '/app/store' + preLoaderRoute: typeof AppStoreRouteImport + parentRoute: typeof AppRoute + } + '/app/stats': { + id: '/app/stats' + path: '/stats' + fullPath: '/app/stats' + preLoaderRoute: typeof AppStatsRouteImport + parentRoute: typeof AppRoute + } + '/app/menu': { + id: '/app/menu' + path: '/menu' + fullPath: '/app/menu' + preLoaderRoute: typeof AppMenuRouteImport + parentRoute: typeof AppRoute + } + '/app/inventory': { + id: '/app/inventory' + path: '/inventory' + fullPath: '/app/inventory' + preLoaderRoute: typeof AppInventoryRouteImport + parentRoute: typeof AppRoute + } + '/app/store/toys': { + id: '/app/store/toys' + path: '/toys' + fullPath: '/app/store/toys' + preLoaderRoute: typeof AppStoreToysRouteImport + parentRoute: typeof AppStoreRoute + } + '/app/store/powerups': { + id: '/app/store/powerups' + path: '/powerups' + fullPath: '/app/store/powerups' + preLoaderRoute: typeof AppStorePowerupsRouteImport + parentRoute: typeof AppStoreRoute + } + '/app/store/medicine': { + id: '/app/store/medicine' + path: '/medicine' + fullPath: '/app/store/medicine' + preLoaderRoute: typeof AppStoreMedicineRouteImport + parentRoute: typeof AppStoreRoute + } + '/app/store/food': { + id: '/app/store/food' + path: '/food' + fullPath: '/app/store/food' + preLoaderRoute: typeof AppStoreFoodRouteImport + parentRoute: typeof AppStoreRoute + } + '/app/inventory/toys': { + id: '/app/inventory/toys' + path: '/toys' + fullPath: '/app/inventory/toys' + preLoaderRoute: typeof AppInventoryToysRouteImport + parentRoute: typeof AppInventoryRoute + } + '/app/inventory/powerups': { + id: '/app/inventory/powerups' + path: '/powerups' + fullPath: '/app/inventory/powerups' + preLoaderRoute: typeof AppInventoryPowerupsRouteImport + parentRoute: typeof AppInventoryRoute + } + '/app/inventory/medicine': { + id: '/app/inventory/medicine' + path: '/medicine' + fullPath: '/app/inventory/medicine' + preLoaderRoute: typeof AppInventoryMedicineRouteImport + parentRoute: typeof AppInventoryRoute + } + '/app/inventory/food': { + id: '/app/inventory/food' + path: '/food' + fullPath: '/app/inventory/food' + preLoaderRoute: typeof AppInventoryFoodRouteImport + parentRoute: typeof AppInventoryRoute + } + '/app/store/toys/$itemId': { + id: '/app/store/toys/$itemId' + path: '/$itemId' + fullPath: '/app/store/toys/$itemId' + preLoaderRoute: typeof AppStoreToysItemIdRouteImport + parentRoute: typeof AppStoreToysRoute + } + '/app/store/powerups/$itemId': { + id: '/app/store/powerups/$itemId' + path: '/$itemId' + fullPath: '/app/store/powerups/$itemId' + preLoaderRoute: typeof AppStorePowerupsItemIdRouteImport + parentRoute: typeof AppStorePowerupsRoute + } + '/app/store/medicine/$itemId': { + id: '/app/store/medicine/$itemId' + path: '/$itemId' + fullPath: '/app/store/medicine/$itemId' + preLoaderRoute: typeof AppStoreMedicineItemIdRouteImport + parentRoute: typeof AppStoreMedicineRoute + } + '/app/store/food/$itemId': { + id: '/app/store/food/$itemId' + path: '/$itemId' + fullPath: '/app/store/food/$itemId' + preLoaderRoute: typeof AppStoreFoodItemIdRouteImport + parentRoute: typeof AppStoreFoodRoute + } + '/app/inventory/toys/$itemId': { + id: '/app/inventory/toys/$itemId' + path: '/$itemId' + fullPath: '/app/inventory/toys/$itemId' + preLoaderRoute: typeof AppInventoryToysItemIdRouteImport + parentRoute: typeof AppInventoryToysRoute + } + '/app/inventory/powerups/$itemId': { + id: '/app/inventory/powerups/$itemId' + path: '/$itemId' + fullPath: '/app/inventory/powerups/$itemId' + preLoaderRoute: typeof AppInventoryPowerupsItemIdRouteImport + parentRoute: typeof AppInventoryPowerupsRoute + } + '/app/inventory/medicine/$itemId': { + id: '/app/inventory/medicine/$itemId' + path: '/$itemId' + fullPath: '/app/inventory/medicine/$itemId' + preLoaderRoute: typeof AppInventoryMedicineItemIdRouteImport + parentRoute: typeof AppInventoryMedicineRoute + } + '/app/inventory/food/$itemId': { + id: '/app/inventory/food/$itemId' + path: '/$itemId' + fullPath: '/app/inventory/food/$itemId' + preLoaderRoute: typeof AppInventoryFoodItemIdRouteImport + parentRoute: typeof AppInventoryFoodRoute + } + } +} + +interface AppInventoryFoodRouteChildren { + AppInventoryFoodItemIdRoute: typeof AppInventoryFoodItemIdRoute +} + +const AppInventoryFoodRouteChildren: AppInventoryFoodRouteChildren = { + AppInventoryFoodItemIdRoute: AppInventoryFoodItemIdRoute, +} + +const AppInventoryFoodRouteWithChildren = + AppInventoryFoodRoute._addFileChildren(AppInventoryFoodRouteChildren) + +interface AppInventoryMedicineRouteChildren { + AppInventoryMedicineItemIdRoute: typeof AppInventoryMedicineItemIdRoute +} + +const AppInventoryMedicineRouteChildren: AppInventoryMedicineRouteChildren = { + AppInventoryMedicineItemIdRoute: AppInventoryMedicineItemIdRoute, +} + +const AppInventoryMedicineRouteWithChildren = + AppInventoryMedicineRoute._addFileChildren(AppInventoryMedicineRouteChildren) + +interface AppInventoryPowerupsRouteChildren { + AppInventoryPowerupsItemIdRoute: typeof AppInventoryPowerupsItemIdRoute +} + +const AppInventoryPowerupsRouteChildren: AppInventoryPowerupsRouteChildren = { + AppInventoryPowerupsItemIdRoute: AppInventoryPowerupsItemIdRoute, +} + +const AppInventoryPowerupsRouteWithChildren = + AppInventoryPowerupsRoute._addFileChildren(AppInventoryPowerupsRouteChildren) + +interface AppInventoryToysRouteChildren { + AppInventoryToysItemIdRoute: typeof AppInventoryToysItemIdRoute +} + +const AppInventoryToysRouteChildren: AppInventoryToysRouteChildren = { + AppInventoryToysItemIdRoute: AppInventoryToysItemIdRoute, +} + +const AppInventoryToysRouteWithChildren = + AppInventoryToysRoute._addFileChildren(AppInventoryToysRouteChildren) + +interface AppInventoryRouteChildren { + AppInventoryFoodRoute: typeof AppInventoryFoodRouteWithChildren + AppInventoryMedicineRoute: typeof AppInventoryMedicineRouteWithChildren + AppInventoryPowerupsRoute: typeof AppInventoryPowerupsRouteWithChildren + AppInventoryToysRoute: typeof AppInventoryToysRouteWithChildren +} + +const AppInventoryRouteChildren: AppInventoryRouteChildren = { + AppInventoryFoodRoute: AppInventoryFoodRouteWithChildren, + AppInventoryMedicineRoute: AppInventoryMedicineRouteWithChildren, + AppInventoryPowerupsRoute: AppInventoryPowerupsRouteWithChildren, + AppInventoryToysRoute: AppInventoryToysRouteWithChildren, +} + +const AppInventoryRouteWithChildren = AppInventoryRoute._addFileChildren( + AppInventoryRouteChildren, +) + +interface AppStoreFoodRouteChildren { + AppStoreFoodItemIdRoute: typeof AppStoreFoodItemIdRoute +} + +const AppStoreFoodRouteChildren: AppStoreFoodRouteChildren = { + AppStoreFoodItemIdRoute: AppStoreFoodItemIdRoute, +} + +const AppStoreFoodRouteWithChildren = AppStoreFoodRoute._addFileChildren( + AppStoreFoodRouteChildren, +) + +interface AppStoreMedicineRouteChildren { + AppStoreMedicineItemIdRoute: typeof AppStoreMedicineItemIdRoute +} + +const AppStoreMedicineRouteChildren: AppStoreMedicineRouteChildren = { + AppStoreMedicineItemIdRoute: AppStoreMedicineItemIdRoute, +} + +const AppStoreMedicineRouteWithChildren = + AppStoreMedicineRoute._addFileChildren(AppStoreMedicineRouteChildren) + +interface AppStorePowerupsRouteChildren { + AppStorePowerupsItemIdRoute: typeof AppStorePowerupsItemIdRoute +} + +const AppStorePowerupsRouteChildren: AppStorePowerupsRouteChildren = { + AppStorePowerupsItemIdRoute: AppStorePowerupsItemIdRoute, +} + +const AppStorePowerupsRouteWithChildren = + AppStorePowerupsRoute._addFileChildren(AppStorePowerupsRouteChildren) + +interface AppStoreToysRouteChildren { + AppStoreToysItemIdRoute: typeof AppStoreToysItemIdRoute +} + +const AppStoreToysRouteChildren: AppStoreToysRouteChildren = { + AppStoreToysItemIdRoute: AppStoreToysItemIdRoute, +} + +const AppStoreToysRouteWithChildren = AppStoreToysRoute._addFileChildren( + AppStoreToysRouteChildren, +) + +interface AppStoreRouteChildren { + AppStoreFoodRoute: typeof AppStoreFoodRouteWithChildren + AppStoreMedicineRoute: typeof AppStoreMedicineRouteWithChildren + AppStorePowerupsRoute: typeof AppStorePowerupsRouteWithChildren + AppStoreToysRoute: typeof AppStoreToysRouteWithChildren +} + +const AppStoreRouteChildren: AppStoreRouteChildren = { + AppStoreFoodRoute: AppStoreFoodRouteWithChildren, + AppStoreMedicineRoute: AppStoreMedicineRouteWithChildren, + AppStorePowerupsRoute: AppStorePowerupsRouteWithChildren, + AppStoreToysRoute: AppStoreToysRouteWithChildren, +} + +const AppStoreRouteWithChildren = AppStoreRoute._addFileChildren( + AppStoreRouteChildren, +) + +interface AppRouteChildren { + AppInventoryRoute: typeof AppInventoryRouteWithChildren + AppMenuRoute: typeof AppMenuRoute + AppStatsRoute: typeof AppStatsRoute + AppStoreRoute: typeof AppStoreRouteWithChildren +} + +const AppRouteChildren: AppRouteChildren = { + AppInventoryRoute: AppInventoryRouteWithChildren, + AppMenuRoute: AppMenuRoute, + AppStatsRoute: AppStatsRoute, + AppStoreRoute: AppStoreRouteWithChildren, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AboutRoute: AboutRoute, + AppRoute: AppRouteWithChildren, + LoginRoute: LoginRoute, + PrivpolicyRoute: PrivpolicyRoute, + TosRoute: TosRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000000..3909edba0e --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,33 @@ +import { createRootRoute, Outlet, useRouterState, useNavigate } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { Header } from "../components/layout/Header" +import { Footer } from "../components/layout/Footer" +import { useEffect } from 'react' +import { useAuthStore } from '../store/auth' + +export const Route = createRootRoute({ + component: () => { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const isAppSection = pathname.startsWith('/app') + + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + const navigate = useNavigate() + + useEffect(() => { + if (isAppSection && !isAuthenticated) { + navigate({ to: '/login' }) + } + }, [isAppSection, isAuthenticated, navigate]) + + return ( +
+ {!isAppSection &&
} +
+ +
+ {!isAppSection &&
} + {/* */} +
+ ) + }, +}) \ No newline at end of file diff --git a/frontend/src/routes/about.tsx b/frontend/src/routes/about.tsx new file mode 100644 index 0000000000..76e447b76f --- /dev/null +++ b/frontend/src/routes/about.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const About = () => { + return ( +
+

About

+
+ ) +} + +export const Route = createFileRoute('/about')({ + component: About, +}) \ No newline at end of file diff --git a/frontend/src/routes/app.tsx b/frontend/src/routes/app.tsx new file mode 100644 index 0000000000..5693ceb92d --- /dev/null +++ b/frontend/src/routes/app.tsx @@ -0,0 +1,37 @@ +import { createFileRoute, redirect, Outlet } from '@tanstack/react-router' +import { useAuthStore } from '../store/auth' +import { AppHeader } from '../components/app/AppHeader' +import { LeftPanel } from '../components/app/LeftPanel' + +export const Route = createFileRoute('/app')({ + beforeLoad: ({ location }) => { + const state = useAuthStore.getState() + + if (!state.token) { + throw redirect({ to: '/login' }) + } + + if (location.pathname === '/app' || location.pathname === '/app/') { + throw redirect({ to: '/app/menu' }) + } + }, + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+
+ +
+
+ +
+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/routes/app/inventory.tsx b/frontend/src/routes/app/inventory.tsx new file mode 100644 index 0000000000..5288e8eb10 --- /dev/null +++ b/frontend/src/routes/app/inventory.tsx @@ -0,0 +1,35 @@ +import { createFileRoute, Link, Outlet, useLocation } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/inventory')({ + component: Inventory, +}) + +function Inventory() { + const location = useLocation() + const isFoodRoute = location.pathname.includes('/food') + const isToysRoute = location.pathname.includes('/toys') + const isMedicineRoute = location.pathname.includes('/medicine') + const isPowerupsRoute = location.pathname.includes('/powerups') + + return ( +
+ {!isFoodRoute && !isToysRoute && !isMedicineRoute && !isPowerupsRoute && ( + <> +

+ INVENTORY: +

+ food + toys + medicine + powerups + +
+ ⏎ +
+ + + )} + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/routes/app/inventory/food.$itemId.tsx b/frontend/src/routes/app/inventory/food.$itemId.tsx new file mode 100644 index 0000000000..e7305e1850 --- /dev/null +++ b/frontend/src/routes/app/inventory/food.$itemId.tsx @@ -0,0 +1,97 @@ +import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { usePetStore } from '../../../store/pet' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/inventory/food/$itemId')({ + component: FoodItem, +}) + +function FoodItem() { + const { itemId } = useParams({ from: '/app/inventory/food/$itemId' }) + const { pet, fetchPet, useItem } = usePetStore() + const { items: storeItems, fetchItems } = useStoreStore() + const [using, setUsing] = useState(false) + const [successMsg, setSuccessMsg] = useState('') + + useEffect(() => { + fetchPet() + fetchItems() + }, [fetchPet, fetchItems]) + + if (!pet) return

Loading pet data...

+ + const inventoryItem = pet.inventory.find((i) => i.itemName === itemId) + if (!inventoryItem) { + return ( +
+

Item not currently found in your inventory.

+ +
+ ⏎ +
+ +
+ ) + } + + const storeItem = storeItems.find((i) => i.name === itemId) + if (!storeItem) return

Loading item details...

+ + const handleUse = async () => { + setUsing(true) + setSuccessMsg('') + const result = await useItem(inventoryItem.itemName); + setSuccessMsg(result.message); + setUsing(false); + } + + return ( +
+

<{storeItem.name}>

+

Description:

+

{storeItem.description || 'No description available'}

+

Price:

+

{storeItem.price} coins

+ + {storeItem.effects.length > 0 && ( + <> +

Effects:

+ + + )} + + {storeItem.powerup && ( + <> +

Duration:

+

+ {storeItem.powerup.duration >= 3600000 + ? `${(storeItem.powerup.duration / 3600000)} hours` + : `${storeItem.powerup.duration / 60000} minutes`} +

+ + )} + + {successMsg &&

"{successMsg}"

} +
+ + + +
+ ⏎ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/app/inventory/food.tsx b/frontend/src/routes/app/inventory/food.tsx new file mode 100644 index 0000000000..c6a4462d0e --- /dev/null +++ b/frontend/src/routes/app/inventory/food.tsx @@ -0,0 +1,67 @@ +import { createFileRoute, Link, useLocation, Outlet } from '@tanstack/react-router' +import { useEffect } from 'react' +import { usePetStore } from '../../../store/pet' + +export const Route = createFileRoute('/app/inventory/food')({ + component: Food, +}) + +function Food() { + const { fetchPet, foodItems, loading, error } = usePetStore() + const location = useLocation() + + // Check if user is viewing a specific food item + const isViewingItem = location.pathname.includes('/food/') + + // Fetch pet data on mount + useEffect(() => { + fetchPet() + }, [fetchPet]) + + // If viewing a specific item, render the outlet for the child route + if (isViewingItem) { + return + } + + const foods = foodItems() + + if (loading) return

Loading food items...

+ if (error) return

Error: {error}.

+ + if (!foods.length) { + return ( +
+

Currently no food in inventory.

+ +
+ ⏎ +
+ +
+ ) + } + + return ( + <> +

FOOD:

+ + +
+ ⏎ +
+ + + ) +} \ No newline at end of file diff --git a/frontend/src/routes/app/inventory/medicine.$itemId.tsx b/frontend/src/routes/app/inventory/medicine.$itemId.tsx new file mode 100644 index 0000000000..ce7e820aaa --- /dev/null +++ b/frontend/src/routes/app/inventory/medicine.$itemId.tsx @@ -0,0 +1,97 @@ +import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { usePetStore } from '../../../store/pet' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/inventory/medicine/$itemId')({ + component: MedicineItem, +}) + +function MedicineItem() { + const { itemId } = useParams({ from: '/app/inventory/medicine/$itemId' }) + const { pet, fetchPet, useItem } = usePetStore() + const { items: storeItems, fetchItems } = useStoreStore() + const [using, setUsing] = useState(false) + const [successMsg, setSuccessMsg] = useState('') + + useEffect(() => { + fetchPet() + fetchItems() + }, [fetchPet, fetchItems]) + + if (!pet) return

Loading pet data...

+ + const inventoryItem = pet.inventory.find((i) => i.itemName === itemId) + if (!inventoryItem) { + return ( +
+

Item not currently found in your inventory.

+ +
+ ⏎ +
+ +
+ ) + } + + const storeItem = storeItems.find((i) => i.name === itemId) + if (!storeItem) return

Loading item details...

+ + const handleUse = async () => { + setUsing(true) + setSuccessMsg('') + const result = await useItem(inventoryItem.itemName); + setSuccessMsg(result.message); + setUsing(false); + } + + return ( +
+

<{storeItem.name}>

+

Description:

+

{storeItem.description || 'No description available'}

+

Price:

+

{storeItem.price} coins

+ + {storeItem.effects.length > 0 && ( + <> +

Effects:

+
    + {storeItem.effects.map((effect, idx) => ( +
  • {effect.stat} +{effect.amount}
  • + ))} +
+ + )} + + {storeItem.powerup && ( + <> +

Duration:

+

+ {storeItem.powerup.duration >= 3600000 + ? `${(storeItem.powerup.duration / 3600000)} hours` + : `${storeItem.powerup.duration / 60000} minutes`} +

+ + )} + + {successMsg &&

"{successMsg}"

} +
+ + + +
+ ⏎ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/app/inventory/medicine.tsx b/frontend/src/routes/app/inventory/medicine.tsx new file mode 100644 index 0000000000..3a7f718ff2 --- /dev/null +++ b/frontend/src/routes/app/inventory/medicine.tsx @@ -0,0 +1,67 @@ +import { createFileRoute, Link, useLocation, Outlet } from '@tanstack/react-router' +import { useEffect } from 'react' +import { usePetStore } from '../../../store/pet' + +export const Route = createFileRoute('/app/inventory/medicine')({ + component: Medicine, +}) + +function Medicine() { + const { fetchPet, medicineItems, loading, error } = usePetStore() + const location = useLocation() + + // Check if user is viewing a specific medicine item + const isViewingItem = location.pathname.includes('/medicine/') + + // Fetch pet data on mount + useEffect(() => { + fetchPet() + }, [fetchPet]) + + // If viewing a specific item, render the outlet for the child route + if (isViewingItem) { + return + } + + const medicine = medicineItems() + + if (loading) return

Loading medicine...

+ if (error) return

Error: {error}

+ + if (!medicine.length) { + return ( +
+

Currently no medicine in inventory.

+ +
+ ⏎ +
+ +
+ ) + } + + return ( + <> +

MEDICINE:

+
    + {medicine.map((item) => ( +
  • + +
    +

    {item.itemName}

    +

    ......

    +

    x {item.quantity}

    +
    + +
  • + ))} +
+ +
+ ⏎ +
+ + + ) +} diff --git a/frontend/src/routes/app/inventory/powerups.$itemId.tsx b/frontend/src/routes/app/inventory/powerups.$itemId.tsx new file mode 100644 index 0000000000..dcfa3249c6 --- /dev/null +++ b/frontend/src/routes/app/inventory/powerups.$itemId.tsx @@ -0,0 +1,97 @@ +import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { usePetStore } from '../../../store/pet' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/inventory/powerups/$itemId')({ + component: PowerupItem, +}) + +function PowerupItem() { + const { itemId } = useParams({ from: '/app/inventory/powerups/$itemId' }) + const { pet, fetchPet, useItem } = usePetStore() + const { items: storeItems, fetchItems } = useStoreStore() + const [using, setUsing] = useState(false) + const [successMsg, setSuccessMsg] = useState('') + + useEffect(() => { + fetchPet() + fetchItems() + }, [fetchPet, fetchItems]) + + if (!pet) return

Loading pet data...

+ + const inventoryItem = pet.inventory.find((i) => i.itemName === itemId) + if (!inventoryItem) { + return ( +
+

Item not currently found in your inventory.

+ +
+ ⏎ +
+ +
+ ) + } + + const storeItem = storeItems.find((i) => i.name === itemId) + if (!storeItem) return

Loading item details...

+ + const handleUse = async () => { + setUsing(true) + setSuccessMsg('') + const result = await useItem(inventoryItem.itemName); + setSuccessMsg(result.message); + setUsing(false); + } + + return ( +
+

<{storeItem.name}>

+

Description:

+

{storeItem.description || 'No description available'}

+

Price:

+

{storeItem.price} coins

+ + {storeItem.effects.length > 0 && ( + <> +

Effects:

+
    + {storeItem.effects.map((effect, idx) => ( +
  • {effect.stat} +{effect.amount}
  • + ))} +
+ + )} + + {storeItem.powerup && ( + <> +

Duration:

+

+ {storeItem.powerup.duration >= 3600000 + ? `${(storeItem.powerup.duration / 3600000)} hours` + : `${storeItem.powerup.duration / 60000} minutes`} +

+ + )} + + {successMsg &&

"{successMsg}"

} +
+ + + +
+ ⏎ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/app/inventory/powerups.tsx b/frontend/src/routes/app/inventory/powerups.tsx new file mode 100644 index 0000000000..86083cba8a --- /dev/null +++ b/frontend/src/routes/app/inventory/powerups.tsx @@ -0,0 +1,67 @@ +import { createFileRoute, Link, useLocation, Outlet } from '@tanstack/react-router' +import { useEffect } from 'react' +import { usePetStore } from '../../../store/pet' + +export const Route = createFileRoute('/app/inventory/powerups')({ + component: Powerups, +}) + +function Powerups() { + const { fetchPet, powerupItems, loading, error } = usePetStore() + const location = useLocation() + + // Check if user is viewing a specific food item + const isViewingItem = location.pathname.includes('/powerups/') + + // Fetch pet data on mount + useEffect(() => { + fetchPet() + }, [fetchPet]) + + // If viewing a specific item, render the outlet for the child route + if (isViewingItem) { + return + } + + const powerups = powerupItems() + + if (loading) return

Loading powerups...

+ if (error) return

Error: {error}.

+ + if (!powerups.length) { + return ( +
+

Currently no powerups in inventory.

+ +
+ ⏎ +
+ +
+ ) + } + + return ( + <> +

POWERUPS:

+
    + {powerups.map((item) => ( +
  • + +
    +

    {item.itemName}

    +

    .......

    +

    x {item.quantity}

    +
    + +
  • + ))} +
+ +
+ ⏎ +
+ + + ) +} diff --git a/frontend/src/routes/app/inventory/toys.$itemId.tsx b/frontend/src/routes/app/inventory/toys.$itemId.tsx new file mode 100644 index 0000000000..f46f586184 --- /dev/null +++ b/frontend/src/routes/app/inventory/toys.$itemId.tsx @@ -0,0 +1,97 @@ +import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { usePetStore } from '../../../store/pet' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/inventory/toys/$itemId')({ + component: ToyItem, +}) + +function ToyItem() { + const { itemId } = useParams({ from: '/app/inventory/toys/$itemId' }) + const { pet, fetchPet, useItem } = usePetStore() + const { items: storeItems, fetchItems } = useStoreStore() + const [using, setUsing] = useState(false) + const [successMsg, setSuccessMsg] = useState('') + + useEffect(() => { + fetchPet() + fetchItems() + }, [fetchPet, fetchItems]) + + if (!pet) return

Loading pet data...

+ + const inventoryItem = pet.inventory.find((i) => i.itemName === itemId) + if (!inventoryItem) { + return ( +
+

Item not currently found in your inventory.

+ +
+ ⏎ +
+ +
+ ) + } + + const storeItem = storeItems.find((i) => i.name === itemId) + if (!storeItem) return

Loading item details...

+ + const handleUse = async () => { + setUsing(true) + setSuccessMsg('') + const result = await useItem(inventoryItem.itemName); + setSuccessMsg(result.message); + setUsing(false); + } + + return ( +
+

<{storeItem.name}>

+

Description:

+

{storeItem.description || 'No description available'}

+

Price:

+

{storeItem.price} coins

+ + {storeItem.effects.length > 0 && ( + <> +

Effects:

+
    + {storeItem.effects.map((effect, idx) => ( +
  • {effect.stat} +{effect.amount}
  • + ))} +
+ + )} + + {storeItem.powerup && ( + <> +

Duration:

+

+ {storeItem.powerup.duration >= 3600000 + ? `${(storeItem.powerup.duration / 3600000)} hours` + : `${storeItem.powerup.duration / 60000} minutes`} +

+ + )} + + {successMsg &&

"{successMsg}"

} +
+ + + +
+ ⏎ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/app/inventory/toys.tsx b/frontend/src/routes/app/inventory/toys.tsx new file mode 100644 index 0000000000..6d275d83d1 --- /dev/null +++ b/frontend/src/routes/app/inventory/toys.tsx @@ -0,0 +1,67 @@ +import { createFileRoute, Link, useLocation, Outlet } from '@tanstack/react-router' +import { useEffect } from 'react' +import { usePetStore } from '../../../store/pet' + +export const Route = createFileRoute('/app/inventory/toys')({ + component: Toys, +}) + +function Toys() { + const { fetchPet, toyItems, loading, error } = usePetStore() + const location = useLocation() + + // Check if user is viewing a specific toy item + const isViewingItem = location.pathname.includes('/toys/') + + // Fetch pet data on mount + useEffect(() => { + fetchPet() + }, [fetchPet]) + + // If viewing a specific item, render the outlet for the child route + if (isViewingItem) { + return + } + + const toys = toyItems() + + if (loading) return

Loading toys...

+ if (error) return

Error: {error}.

+ + if (!toys.length) { + return ( +
+

Currently no toys in inventory.

+ +
+ ⏎ +
+ +
+ ) + } + + return ( + <> +

TOYS:

+
    + {toys.map((item) => ( +
  • + +
    +

    {item.itemName}

    +

    ......

    +

    x {item.quantity}

    +
    + +
  • + ))} +
+ +
+ ⏎ +
+ + + ) +} diff --git a/frontend/src/routes/app/menu.tsx b/frontend/src/routes/app/menu.tsx new file mode 100644 index 0000000000..dc91961b08 --- /dev/null +++ b/frontend/src/routes/app/menu.tsx @@ -0,0 +1,18 @@ +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/menu')({ + component: Menu, +}) + +function Menu() { + return ( +
+

+ MAIN MENU: +

+ inventory + store + stats +
+ ) +} diff --git a/frontend/src/routes/app/stats.tsx b/frontend/src/routes/app/stats.tsx new file mode 100644 index 0000000000..b07f2e65d3 --- /dev/null +++ b/frontend/src/routes/app/stats.tsx @@ -0,0 +1,81 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useEffect } from 'react' +import { usePetStore } from '../../store/pet' + +export const Route = createFileRoute('/app/stats')({ + component: Stats, +}) + +function Stats() { + const { pet, fetchPet, loading, error } = usePetStore() + + // Fetch pet data when component mounts or user navigates to this page + useEffect(() => { + fetchPet() + }, [fetchPet]) + + if (loading) { + return
Loading pet stats...
+ } + + if (error) { + return
Error: {error}
+ } + + if (!pet) { + return
No pet found
+ } + + return ( +
+

PET STATS:

+
    +
  • + Name: + {pet.name} +
  • +
  • + Born on: + {new Date(pet.bornAt).toLocaleDateString()} +
  • +
  • + Health: + {pet.health} +
  • +
  • + Happiness: + {pet.happiness} +
  • +
  • + Hunger: + {pet.hunger} +
  • +
  • + Pooped: + {pet.conditions.isPooped ? 'Yes' : 'No'} +
  • +
  • + Sick: + {pet.conditions.isSick ? 'Yes' : 'No'} +
  • +
  • + Coins: x + {pet.coins} +
  • +
  • + Status: + {pet.status} +
  • +
  • + Active Powerups: + {pet.activePowerups.length > 0 ? pet.activePowerups.map(p => p.type).join(', ') : 'None'} +
  • +
+ +
+ ⏎ +
+ +
+ ) +} diff --git a/frontend/src/routes/app/store.tsx b/frontend/src/routes/app/store.tsx new file mode 100644 index 0000000000..28e5666914 --- /dev/null +++ b/frontend/src/routes/app/store.tsx @@ -0,0 +1,35 @@ +import { createFileRoute, Link, Outlet, useLocation } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/store')({ + component: Store, +}) + +function Store() { + const location = useLocation() + const isFoodRoute = location.pathname.includes('/food') + const isToysRoute = location.pathname.includes('/toys') + const isMedicineRoute = location.pathname.includes('/medicine') + const isPowerupsRoute = location.pathname.includes('/powerups') + + return ( +
+ {!isFoodRoute && !isToysRoute && !isMedicineRoute && !isPowerupsRoute && ( + <> +

+ STORE: +

+ food + toys + medicine + powerups + +
+ ⏎ +
+ + + )} + +
+ ) +} diff --git a/frontend/src/routes/app/store/food.$itemId.tsx b/frontend/src/routes/app/store/food.$itemId.tsx new file mode 100644 index 0000000000..e34c936e0d --- /dev/null +++ b/frontend/src/routes/app/store/food.$itemId.tsx @@ -0,0 +1,95 @@ +import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { usePetStore } from '../../../store/pet' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/store/food/$itemId')({ + component: FoodItem, +}) + +function FoodItem() { + const { itemId } = useParams({ from: '/app/store/food/$itemId' }) + const { items: storeItems, fetchItems, buyItem } = useStoreStore() + const { pet, fetchPet } = usePetStore() + const [buying, setBuying] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + fetchItems() + fetchPet() + }, [fetchItems, fetchPet]) + + if (!storeItems.length) return

Loading store items...

+ if (!pet) return

Loading pet data...

+ + const storeItem = storeItems.find((i) => i.name === itemId) + if (!storeItem) { + return ( +
+

Item not found in the store.

+ +
+ ⏎ +
+ +
+ ) + } + + const handleBuy = async () => { + setBuying(true) + setMessage('') + const result = await buyItem(storeItem.name) + setMessage(result.message) + setBuying(false) + } + + return ( +
+

<{storeItem.name}>

+

Description:

+

{storeItem.description || 'No description available'}

+

Price:

+

{storeItem.price} coins

+ + {storeItem.effects.length > 0 && ( + <> +

Effects:

+
    + {storeItem.effects.map((effect, idx) => ( +
  • {effect.stat} +{effect.amount}
  • + ))} +
+ + )} + + {storeItem.powerup && ( + <> +

Duration:

+

+ {storeItem.powerup.duration >= 3600000 + ? `${storeItem.powerup.duration / 3600000} hours` + : `${storeItem.powerup.duration / 60000} minutes`} +

+ + )} + + {message &&

"{message}"

} +
+ + + +
+ ⏎ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/app/store/food.tsx b/frontend/src/routes/app/store/food.tsx new file mode 100644 index 0000000000..26307e3711 --- /dev/null +++ b/frontend/src/routes/app/store/food.tsx @@ -0,0 +1,68 @@ +import { createFileRoute, Link, useLocation, Outlet } from '@tanstack/react-router' +import { useEffect } from 'react' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/store/food')({ + component: Food, +}) + +function Food() { + const { items, fetchItems, loading, error } = useStoreStore() + const location = useLocation() + + // Check if user is viewing a specific store item + const isViewingItem = location.pathname.includes('/food/') + + // Fetch store data on mount + useEffect(() => { + fetchItems() + }, [fetchItems]) + + // If viewing a specific item, render the outlet for the child route + if (isViewingItem) { + return + } + + // Filter for food category + const foods = items.filter((i) => i.category === 'food') + + if (loading) return

Loading food items...

+ if (error) return

Error: {error}.

+ + if (!foods.length) { + return ( +
+

No food items in the store.

+ +
+ ⏎ +
+ +
+ ) + } + + return ( + <> +

FOOD:

+
    + {foods.map((item) => ( +
  • + +
    +

    {item.name}

    +

    ......

    +

    {item.price}¢

    +
    + +
  • + ))} +
+ +
+ ⏎ +
+ + + ) +} diff --git a/frontend/src/routes/app/store/medicine.$itemId.tsx b/frontend/src/routes/app/store/medicine.$itemId.tsx new file mode 100644 index 0000000000..2bbe85f18d --- /dev/null +++ b/frontend/src/routes/app/store/medicine.$itemId.tsx @@ -0,0 +1,95 @@ +import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { usePetStore } from '../../../store/pet' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/store/medicine/$itemId')({ + component: MedicineItem, +}) + +function MedicineItem() { + const { itemId } = useParams({ from: '/app/store/medicine/$itemId' }) + const { items: storeItems, fetchItems, buyItem } = useStoreStore() + const { pet, fetchPet } = usePetStore() + const [buying, setBuying] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + fetchItems() + fetchPet() + }, [fetchItems, fetchPet]) + + if (!storeItems.length) return

Loading store items...

+ if (!pet) return

Loading pet data...

+ + const storeItem = storeItems.find((i) => i.name === itemId) + if (!storeItem) { + return ( +
+

Item not found in the store.

+ +
+ ⏎ +
+ +
+ ) + } + + const handleBuy = async () => { + setBuying(true) + setMessage('') + const result = await buyItem(storeItem.name) + setMessage(result.message) + setBuying(false) + } + + return ( +
+

<{storeItem.name}>

+

Description:

+

{storeItem.description || 'No description available'}

+

Price:

+

{storeItem.price} coins

+ + {storeItem.effects.length > 0 && ( + <> +

Effects:

+
    + {storeItem.effects.map((effect, idx) => ( +
  • {effect.stat} +{effect.amount}
  • + ))} +
+ + )} + + {storeItem.powerup && ( + <> +

Duration:

+

+ {storeItem.powerup.duration >= 3600000 + ? `${storeItem.powerup.duration / 3600000} hours` + : `${storeItem.powerup.duration / 60000} minutes`} +

+ + )} + + {message &&

"{message}"

} +
+ + + +
+ ⏎ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/app/store/medicine.tsx b/frontend/src/routes/app/store/medicine.tsx new file mode 100644 index 0000000000..d43d12c207 --- /dev/null +++ b/frontend/src/routes/app/store/medicine.tsx @@ -0,0 +1,68 @@ +import { createFileRoute, Link, useLocation, Outlet } from '@tanstack/react-router' +import { useEffect } from 'react' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/store/medicine')({ + component: Medicine, +}) + +function Medicine() { + const { items, fetchItems, loading, error } = useStoreStore() + const location = useLocation() + + // Check if user is viewing a specific store item + const isViewingItem = location.pathname.includes('/medicine/') + + // Fetch store data on mount + useEffect(() => { + fetchItems() + }, [fetchItems]) + + // If viewing a specific item, render the outlet for the child route + if (isViewingItem) { + return + } + + // Filter for medicine category + const medicine = items.filter((i) => i.category === 'medicine') + + if (loading) return

Loading medicine...

+ if (error) return

Error: {error}.

+ + if (!medicine.length) { + return ( +
+

No medicines in the store.

+ +
+ ⏎ +
+ +
+ ) + } + + return ( + <> +

MEDICINE:

+
    + {medicine.map((item) => ( +
  • + +
    +

    {item.name}

    +

    ......

    +

    {item.price}¢

    +
    + +
  • + ))} +
+ +
+ ⏎ +
+ + + ) +} diff --git a/frontend/src/routes/app/store/powerups.$itemId.tsx b/frontend/src/routes/app/store/powerups.$itemId.tsx new file mode 100644 index 0000000000..37966884f5 --- /dev/null +++ b/frontend/src/routes/app/store/powerups.$itemId.tsx @@ -0,0 +1,95 @@ +import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { usePetStore } from '../../../store/pet' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/store/powerups/$itemId')({ + component: PowerupItem, +}) + +function PowerupItem() { + const { itemId } = useParams({ from: '/app/store/powerups/$itemId' }) + const { items: storeItems, fetchItems, buyItem } = useStoreStore() + const { pet, fetchPet } = usePetStore() + const [buying, setBuying] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + fetchItems() + fetchPet() + }, [fetchItems, fetchPet]) + + if (!storeItems.length) return

Loading store items...

+ if (!pet) return

Loading pet data...

+ + const storeItem = storeItems.find((i) => i.name === itemId) + if (!storeItem) { + return ( +
+

Item not found in the store.

+ +
+ ⏎ +
+ +
+ ) + } + + const handleBuy = async () => { + setBuying(true) + setMessage('') + const result = await buyItem(storeItem.name) + setMessage(result.message) + setBuying(false) + } + + return ( +
+

<{storeItem.name}>

+

Description:

+

{storeItem.description || 'No description available'}

+

Price:

+

{storeItem.price} coins

+ + {storeItem.effects.length > 0 && ( + <> +

Effects:

+
    + {storeItem.effects.map((effect, idx) => ( +
  • {effect.stat} +{effect.amount}
  • + ))} +
+ + )} + + {storeItem.powerup && ( + <> +

Duration:

+

+ {storeItem.powerup.duration >= 3600000 + ? `${storeItem.powerup.duration / 3600000} hours` + : `${storeItem.powerup.duration / 60000} minutes`} +

+ + )} + + {message &&

"{message}"

} +
+ + + +
+ ⏎ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/app/store/powerups.tsx b/frontend/src/routes/app/store/powerups.tsx new file mode 100644 index 0000000000..fde033d397 --- /dev/null +++ b/frontend/src/routes/app/store/powerups.tsx @@ -0,0 +1,68 @@ +import { createFileRoute, Link, useLocation, Outlet } from '@tanstack/react-router' +import { useEffect } from 'react' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/store/powerups')({ + component: Powerups, +}) + +function Powerups() { + const { items, fetchItems, loading, error } = useStoreStore() + const location = useLocation() + + // Check if user is viewing a specific store item + const isViewingItem = location.pathname.includes('/powerups/') + + // Fetch store data on mount + useEffect(() => { + fetchItems() + }, [fetchItems]) + + // If viewing a specific item, render the outlet for the child route + if (isViewingItem) { + return + } + + // Filter for powerups category + const powerups = items.filter((i) => i.category === 'powerup') + + if (loading) return

Loading powerups...

+ if (error) return

Error: {error}.

+ + if (!powerups.length) { + return ( +
+

No powerups in the store.

+ +
+ ⏎ +
+ +
+ ) + } + + return ( + <> +

POWERUPS:

+
    + {powerups.map((item) => ( +
  • + +
    +

    {item.name}

    +

    ......

    +

    {item.price}¢

    +
    + +
  • + ))} +
+ +
+ ⏎ +
+ + + ) +} diff --git a/frontend/src/routes/app/store/toys.$itemId.tsx b/frontend/src/routes/app/store/toys.$itemId.tsx new file mode 100644 index 0000000000..a5e1174ca0 --- /dev/null +++ b/frontend/src/routes/app/store/toys.$itemId.tsx @@ -0,0 +1,95 @@ +import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { usePetStore } from '../../../store/pet' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/store/toys/$itemId')({ + component: ToyItem, +}) + +function ToyItem() { + const { itemId } = useParams({ from: '/app/store/toys/$itemId' }) + const { items: storeItems, fetchItems, buyItem } = useStoreStore() + const { pet, fetchPet } = usePetStore() + const [buying, setBuying] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + fetchItems() + fetchPet() + }, [fetchItems, fetchPet]) + + if (!storeItems.length) return

Loading store items...

+ if (!pet) return

Loading pet data...

+ + const storeItem = storeItems.find((i) => i.name === itemId) + if (!storeItem) { + return ( +
+

Item not found in the store.

+ +
+ ⏎ +
+ +
+ ) + } + + const handleBuy = async () => { + setBuying(true) + setMessage('') + const result = await buyItem(storeItem.name) + setMessage(result.message) + setBuying(false) + } + + return ( +
+

<{storeItem.name}>

+

Description:

+

{storeItem.description || 'No description available'}

+

Price:

+

{storeItem.price} coins

+ + {storeItem.effects.length > 0 && ( + <> +

Effects:

+
    + {storeItem.effects.map((effect, idx) => ( +
  • {effect.stat} +{effect.amount}
  • + ))} +
+ + )} + + {storeItem.powerup && ( + <> +

Duration:

+

+ {storeItem.powerup.duration >= 3600000 + ? `${storeItem.powerup.duration / 3600000} hours` + : `${storeItem.powerup.duration / 60000} minutes`} +

+ + )} + + {message &&

"{message}"

} +
+ + + +
+ ⏎ +
+ +
+
+ ) +} diff --git a/frontend/src/routes/app/store/toys.tsx b/frontend/src/routes/app/store/toys.tsx new file mode 100644 index 0000000000..1735d116c0 --- /dev/null +++ b/frontend/src/routes/app/store/toys.tsx @@ -0,0 +1,68 @@ +import { createFileRoute, Link, useLocation, Outlet } from '@tanstack/react-router' +import { useEffect } from 'react' +import { useStoreStore } from '../../../store/store' + +export const Route = createFileRoute('/app/store/toys')({ + component: Toys, +}) + +function Toys() { + const { items, fetchItems, loading, error } = useStoreStore() + const location = useLocation() + + // Check if user is viewing a specific store item + const isViewingItem = location.pathname.includes('/toys/') + + // Fetch store data on mount + useEffect(() => { + fetchItems() + }, [fetchItems]) + + // If viewing a specific item, render the outlet for the child route + if (isViewingItem) { + return + } + + // Filter for toys category + const toys = items.filter((i) => i.category === 'toy') + + if (loading) return

Loading toys...

+ if (error) return

Error: {error}.

+ + if (!toys.length) { + return ( +
+

No toys in the store.

+ +
+ ⏎ +
+ +
+ ) + } + + return ( + <> +

TOYS:

+
    + {toys.map((item) => ( +
  • + +
    +

    {item.name}

    +

    ......

    +

    {item.price}¢

    +
    + +
  • + ))} +
+ +
+ ⏎ +
+ + + ) +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 0000000000..0ae0ad2971 --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Features } from "../components/landing/Features" + +export const Index = () => { + return ( +
+ +
+ ) +} + +export const Route = createFileRoute('/')({ + component: Index, +}) \ No newline at end of file diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx new file mode 100644 index 0000000000..bc3b43f8f6 --- /dev/null +++ b/frontend/src/routes/login.tsx @@ -0,0 +1,101 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { useAuthStore } from "../store/auth" + +export const Login = () => { + + const navigate = useNavigate(); + const login = useAuthStore((s) => s.login); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + await login(email, password); + navigate({ to: "/app" }); + } catch (err) { + setError("Invalid email or password"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ {/* Email */} +
+ + setEmail(e.target.value)} + className="py-2 px-4 mt-1 w-full rounded-[25px] border-4 border-ammo-600 bg-ammo-200 text-ammo-800 focus:outline-none focus:ring-0 focus:border-ammo-300 placeholder-customs-400" + placeholder="user@osloskolen.no" + required + /> +
+ + {/* Password */} +
+ + setPassword(e.target.value)} + className="py-2 px-4 mt-1 w-full rounded-[25px] border-4 border-ammo-600 bg-ammo-200 text-ammo-800 focus:outline-none focus:ring-0 focus:border-ammo-300 placeholder-customs-400" + placeholder="••••••••" + required + /> +
+ + {error && ( +

Error: {error}

+ )} + + {/* Submit button */} +
+ +
+
+

+ Don’t have an account?
+ + Sign up here! + +

+
+
+ ) +} + +export const Route = createFileRoute('/login')({ + component: Login, +}) \ No newline at end of file diff --git a/frontend/src/routes/privpolicy.tsx b/frontend/src/routes/privpolicy.tsx new file mode 100644 index 0000000000..bcd67e3b41 --- /dev/null +++ b/frontend/src/routes/privpolicy.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/privpolicy')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/privpolicy"!
+} diff --git a/frontend/src/routes/tos.tsx b/frontend/src/routes/tos.tsx new file mode 100644 index 0000000000..d139f6cc20 --- /dev/null +++ b/frontend/src/routes/tos.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/tos')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/tos"!
+} diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 0000000000..ffd177b268 --- /dev/null +++ b/frontend/src/store/auth.ts @@ -0,0 +1,95 @@ +import { create } from "zustand"; + +// Defines the shape of the user returned by the backend +interface User { + _id: string; + initials: string; + email: string; + classroomCode: string; +} + +// Defines the shape of the auth store +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; + rehydrate: () => void; +} + +// Create Zustand store +export const useAuthStore = create((set) => ({ + user: null, + token: null, + isAuthenticated: false, + + login: async (email, password) => { + try { + const API_URL = import.meta.env.VITE_API_URL; + const res = await fetch(`${API_URL}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || "Login failed"); + } + + const data = await res.json(); + + // Saves token and user to localStorage + localStorage.setItem("authToken", data.token); + localStorage.setItem("authUser", JSON.stringify(data)); + + set({ + user: { + _id: data._id, + initials: data.initials, + email: data.email, + classroomCode: data.classroomCode, + }, + token: data.token, + isAuthenticated: true, + }); + } catch (err) { + console.error("Login error:", err); + throw err; + } + }, + + logout: () => { + + localStorage.removeItem("authToken"); + localStorage.removeItem("authUser"); + + set({ + user: null, + token: null, + isAuthenticated: false, + }); + + }, + // Users stay logged in when refreshing the page, and until logout is clicked + rehydrate: () => { + const token = localStorage.getItem("authToken"); + const storedUser = localStorage.getItem("authUser"); + + if (token && storedUser) { + try { + const user = JSON.parse(storedUser); + set({ + user, + token, + isAuthenticated: true, + }); + } catch (err) { + console.error("Failed to parse stored user:", err); + localStorage.removeItem("authUser"); + localStorage.removeItem("authToken"); + } + } + }, +})); \ No newline at end of file diff --git a/frontend/src/store/pet.ts b/frontend/src/store/pet.ts new file mode 100644 index 0000000000..3a322a5a62 --- /dev/null +++ b/frontend/src/store/pet.ts @@ -0,0 +1,231 @@ +import { create } from "zustand"; + +// Environment variable +const API_URL = import.meta.env.VITE_API_URL || ""; + +const petErrorMessage = (msg?: string) => { + if (msg === "No active pet found") return "Your pet has expired"; + return msg || "We could not load your pet right now"; +}; + +// Types +export type InventoryItem = { + itemName: string; + category: "food" | "toy" | "medicine" | "powerup" | "misc"; + quantity: number; +}; + +export type Pet = { + _id: string; + owner: string; + name: string; + health: number; + happiness: number; + hunger: number; + coins: number; + inventory: InventoryItem[]; + lastUpdated: string; + statTimers: { + hungerLastUpdated: string; + happinessLastUpdated: string; + healthLastUpdated: string; + }; + conditions: { + isPooped: boolean; + isSick: boolean; + nextPoopTime: string; + nextSicknessTime: string; + }; + activePowerups: { + type: "statFreeze" | "doubleCoins"; + expiresAt: string; + }[]; + status: "alive" | "expired"; + bornAt: string; + expiredAt?: string; +}; + +// Store state +type PetState = { + pet: Pet | null; + loading: boolean; + error: string | null; + + // actions + fetchPet: () => Promise; + useItem: (itemName: string) => Promise<{ success: boolean; message: string }>; + addCoins: (amount: number) => Promise; + fetchInventory: () => Promise; + addItem: (item: InventoryItem) => Promise; + removeItem: (itemName: string, quantity?: number) => Promise; + + // computed selectors + isAlive: () => boolean; + foodItems: () => InventoryItem[]; + toyItems: () => InventoryItem[]; + medicineItems: () => InventoryItem[]; + powerupItems: () => InventoryItem[]; +}; + +// Zustand store +export const usePetStore = create((set, get) => ({ + pet: null, + loading: false, + error: null, + + // ----- Actions ----- + fetchPet: async () => { + set({ loading: true, error: null }); + try { + const token = localStorage.getItem("authToken"); + const res = await fetch(`${API_URL}/api/pet`, { + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + }); + if (!res.ok) { + const raw = await res.text(); + let backendMsg: string | undefined; + try { + backendMsg = JSON.parse(raw).message; + } catch { + backendMsg = raw; + } + throw new Error(backendMsg); + } + const data: Pet = await res.json(); + set({ pet: data, loading: false }); + } catch (err: any) { + set({ error: petErrorMessage(err.message), loading: false }); + } + }, + + useItem: async (itemName: string) => { + set({ loading: true, error: null }); + try { + const token = localStorage.getItem("authToken"); + const res = await fetch(`${API_URL}/api/pet/use-item`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ itemName }), + }); + + const data = await res.json(); + + if (!res.ok) { + return { success: false, message: data.message || "Failed to use item." }; + } + + set({ pet: data.pet, loading: false }); + return { success: true, message: data.message || `${itemName} used.` }; + } catch (err: any) { + set({ error: err.message, loading: false }); + return { success: false, message: err.message }; + } finally { + set({ loading: false }); + } + }, + + addCoins: async (amount: number) => { + try { + const token = localStorage.getItem("authToken"); + const res = await fetch(`${API_URL}/api/pet/coins`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ amount }), + }); + if (!res.ok) throw new Error(await res.text()); + const data: Pet = await res.json(); + set({ pet: data }); + } catch (err: any) { + set({ error: err.message }); + } + }, + + fetchInventory: async () => { + try { + const token = localStorage.getItem("authToken"); + const res = await fetch(`${API_URL}/api/pet/inventory`, { + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + }); + if (!res.ok) throw new Error(await res.text()); + const data: InventoryItem[] = await res.json(); + set((state) => + state.pet ? { pet: { ...state.pet, inventory: data } } : {} + ); + } catch (err: any) { + set({ error: err.message }); + } + }, + + addItem: async (item: InventoryItem) => { + try { + const token = localStorage.getItem("authToken"); + const res = await fetch(`${API_URL}/api/pet/inventory/add`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ + itemName: item.itemName, + category: item.category, + quantity: item.quantity, + }), + }); + if (!res.ok) throw new Error(await res.text()); + const data: InventoryItem[] = await res.json(); + set((state) => + state.pet ? { pet: { ...state.pet, inventory: data } } : {} + ); + } catch (err: any) { + set({ error: err.message }); + } + }, + + removeItem: async (itemName: string, quantity?: number) => { + try { + const token = localStorage.getItem("authToken"); + const res = await fetch(`${API_URL}/api/pet/inventory/remove`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ itemName, quantity }), + }); + if (!res.ok) throw new Error(await res.text()); + const data: InventoryItem[] = await res.json(); + set((state) => + state.pet ? { pet: { ...state.pet, inventory: data } } : {} + ); + } catch (err: any) { + set({ error: err.message }); + } + }, + + // ----- Selectors ----- + isAlive: () => { + const pet = get().pet; + return pet?.status === "alive"; + }, + + foodItems: () => + get().pet?.inventory.filter((i) => i.category === "food") ?? [], + toyItems: () => + get().pet?.inventory.filter((i) => i.category === "toy") ?? [], + medicineItems: () => + get().pet?.inventory.filter((i) => i.category === "medicine") ?? [], + powerupItems: () => + get().pet?.inventory.filter((i) => i.category === "powerup") ?? [], +})); \ No newline at end of file diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts new file mode 100644 index 0000000000..e8260c757e --- /dev/null +++ b/frontend/src/store/store.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand' +import { usePetStore } from './pet' + +// Environment variable +const API_URL = import.meta.env.VITE_API_URL || ""; + +export type StoreItem = { + _id: string + name: string + category: 'food' | 'toy' | 'medicine' | 'powerup' | 'misc' + effects: { stat: 'hunger' | 'happiness' | 'health' | 'coins'; amount: number }[] + conditions: { condition: 'isSick' | 'isPooped'; setTo: boolean }[] + powerup?: { type: 'statFreeze' | 'doubleCoins'; duration: number } + price: number + description?: string +} + +type StoreState = { + items: StoreItem[] + loading: boolean + error: string | null + fetchItems: () => Promise + buyItem: (itemId: string) => Promise<{ success: boolean; message: string }> +} + +export const useStoreStore = create((set) => ({ + items: [], + loading: false, + error: null, + + fetchItems: async () => { + set({ loading: true, error: null }) + try { + const token = localStorage.getItem('authToken') + const res = await fetch(`${API_URL}/api/store`, { + headers: { + 'Content-Type': 'application/json', + Authorization: token ? `Bearer ${token}` : '', + }, + }) + if (!res.ok) throw new Error(await res.text()) + const data: StoreItem[] = await res.json() + set({ items: data, loading: false }) + } catch (err: any) { + set({ error: err.message, loading: false }) + } + }, + buyItem: async (itemId: string) => { + set({ loading: true, error: null }); + try { + const token = localStorage.getItem("authToken"); + const res = await fetch(`${API_URL}/api/store/buy`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ itemId }), + }); + + const data = await res.json(); + + if (!res.ok) { + set({ loading: false }); + return { success: false, message: data.message}; + } + + // Update pet in the pet store so inventory reflects purchase + usePetStore.setState({ pet: data.pet }); + + set({ loading: false }); + + return { success: true, message: data.message }; + } catch (err: any) { + set({ error: err.message, loading: false }); + return { success: false, message: err.message }; + } + }, +})) \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000..6d2d0135f6 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "jsx": "react-jsx", // for React 18+ + "moduleResolution": "Node", + "allowJs": true, // let .js files coexist with .ts/.tsx + "checkJs": false, // don’t enforce types in .js files + "strict": true, // turn off strict mode for now + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src", "declarations.d.ts", "./vite-env.d.ts"] +} \ No newline at end of file diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts new file mode 100644 index 0000000000..2e528ed220 --- /dev/null +++ b/frontend/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5a33944a9b..668c4132e5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,16 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' +import tailwindcss from '@tailwindcss/vite' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + }), + react(), + tailwindcss(), + ], })