diff --git a/.gitignore b/.gitignore index 3d70248ba2..053370cc3a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,11 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -package-lock.json \ No newline at end of file +package-lock.json +# env files +backend/.env +frontend/.env + +# env files +backend/.env +frontend/.env diff --git a/README.md b/README.md index 31466b54c2..75915c1c95 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,42 @@ # Final Project -Replace this readme with your own information about your project. +Pocket Oracle + +A small full-stack tarot app where you can draw cards, save readings, and revisit them later. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. ## The problem -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? +Planning: wrote a few user stories (“draw one card”, “save a 3-card reading”, “see my history”), sketched the pages, then built backend → frontend → deploy in that order. + +Tech stack: + +Frontend: React (Vite), React Router, axios, react-hook-form + Zod, styled-components for theme tokens. + +Backend: Node + Express, MongoDB (Mongoose), JWT auth, bcrypt. + +Deployment: Netlify (frontend) and Render (backend). + +How I solved it: + +Created REST endpoints for auth and readings (CRUD). + +Used a shared axios instance with a request interceptor to attach the JWT to every call. + +Set up CORS to allow Netlify + localhost. +Kept colors/spacing in CSS variables so the hero image + palette were easy to tune. + +If I had more time: find /make better readings, implement AI, fix so all tarot cards show, daily card with history - calenderview, search/filter for readings, better a11y & loading states, overall visual clean-up - make it more user logical and a small test suite (Vitest/RTL). + ## 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 +App: https://pocketoracle.netlify.app + +API (Render): https://pocket-oracle.onrender.com + + + +Thanks to: +https://github.com/krates98/tarotcardapi for images and more. +https://commons.wikimedia.org/wiki/File:Sebastian_Pether_(1790-1844)_-_Moonlit_Landscape_with_a_Gothic_Ruin_-_1449048_-_National_Trust.jpg for hero image \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..226a9fcc6c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,15 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "axios": "^1.11.0", + "bcrypt": "^6.0.0", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "dotenv": "^17.2.1", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.10" } -} \ No newline at end of file +} diff --git a/backend/server.js b/backend/server.js index 070c875189..b2bdf52d7a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,91 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +// backend/server.js +// force .env to override any OS env var +import dotenv from 'dotenv'; +dotenv.config({ override: true }); +import express from 'express'; +import cors from 'cors'; +import mongoose from 'mongoose'; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +import authRouter from './src/routes/auth.js'; +import secretRouter from './src/routes/secret.js'; +import readingsRouter from './src/routes/readings.js'; +import tarotRouter from './src/routes/tarot.js'; + +const PORT = process.env.PORT || 5000; +const MONGO_URI = process.env.MONGODB_URI; + +if (!MONGO_URI) { + console.error('❌ MONGODB_URI missing in backend/.env'); + process.exit(1); +} -const port = process.env.PORT || 8080; const app = express(); -app.use(cors()); +// --- CORS (dev + production) ----------------------------------------------- +const DEV_ORIGINS = [ + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:5175', + 'http://localhost:5176', +]; + +// Allow comma-separated env (ex: "https://pocketoracle.netlify.app,http://localhost:5173") +const ENV_ORIGINS = (process.env.CLIENT_ORIGIN || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + +const ALLOWED_ORIGINS = ENV_ORIGINS.length ? ENV_ORIGINS : DEV_ORIGINS; + +const corsOptions = { + origin(origin, cb) { + // allow curl/health checks (no Origin) and any listed origin + if (!origin || ALLOWED_ORIGINS.includes(origin)) return cb(null, true); + cb(new Error(`CORS: origin not allowed -> ${origin}`)); + }, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: false, + maxAge: 86400, + optionsSuccessStatus: 204 +}; + +app.use(cors(corsOptions)); +// Make sure every route responds to preflight +app.options('*', cors(corsOptions)); +// --------------------------------------------------------------------------- + app.use(express.json()); -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); +// Health check +app.get('/health', (_req, res) => res.json({ status: 'ok' })); + +// Routes +app.use('/api/auth', authRouter); +app.use('/api/secret', secretRouter); +app.use('/api/readings', readingsRouter); +app.use('/api/tarot', tarotRouter); -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); +// Demo root +app.get('/', (_req, res) => { + res.send('Hello Technigo!'); }); + +async function start() { + try { + await mongoose.connect(MONGO_URI); + console.log('✓ MongoDB connected'); + + app.listen(PORT, () => { + console.log(`✓ Server running on http://localhost:${PORT}`); + console.log('✓ CORS allowed origins:', (ALLOWED_ORIGINS || []).join(', ')); + }); + + } catch (err) { + console.error('DB connection failed:', err.message); + process.exit(1); + } +} + +start(); + diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000000..180f1e7b78 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,13 @@ +import jwt from 'jsonwebtoken'; + +export default function auth(req, res, next) { + const h = req.headers.authorization || ''; + const token = h.startsWith('Bearer ') ? h.slice(7) : null; + if (!token) return res.status(401).json({ error: 'Missing token' }); + try { + req.user = jwt.verify(token, process.env.JWT_SECRET); // { id, email, name } + next(); + } catch { + res.status(401).json({ error: 'Invalid token' }); + } +} diff --git a/backend/src/models/Reading.js b/backend/src/models/Reading.js new file mode 100644 index 0000000000..a311665251 --- /dev/null +++ b/backend/src/models/Reading.js @@ -0,0 +1,19 @@ +// backend/src/models/Reading.js +import mongoose from 'mongoose'; + +const CardSchema = new mongoose.Schema({ + id: { type: String, required: true }, // e.g., "XVI" or "The Tower" + reversed: { type: Boolean, default: false }, + position: { type: String }, // e.g., "Past", "Present", "Future" +}, { _id: false }); + +const ReadingSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true }, + spread: { type: String, enum: ['one', 'three', 'custom'], required: true }, + title: { type: String }, + notes: { type: String }, + tags: { type: [String], default: [] }, + cards: { type: [CardSchema], default: [] }, +}, { timestamps: true }); + +export default mongoose.model('Reading', ReadingSchema); diff --git a/backend/src/models/User.js b/backend/src/models/User.js new file mode 100644 index 0000000000..64c59c64cd --- /dev/null +++ b/backend/src/models/User.js @@ -0,0 +1,10 @@ +import mongoose from 'mongoose'; +const { Schema, model } = mongoose; + +const userSchema = new Schema({ + name: { type: String, required: true }, + email: { type: String, required: true, unique: true }, + passwordHash: { type: String, required: true } +}, { timestamps: true }); + +export default model('User', userSchema); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000000..145afa8b55 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import User from '../models/User.js'; +import auth from '../middleware/auth.js'; + +const router = Router(); + +router.post('/register', async (req, res) => { + const { name, email, password } = req.body || {}; + if (!name || !email || !password) return res.status(400).json({ error: 'Missing fields' }); + const exists = await User.findOne({ email }); + if (exists) return res.status(409).json({ error: 'Email already in use' }); + const passwordHash = await bcrypt.hash(password, 10); + await User.create({ name, email, passwordHash }); + res.status(201).json({ message: 'User created' }); +}); + +router.post('/login', async (req, res) => { + const { email, password } = req.body || {}; + const user = await User.findOne({ email }); + if (!user) return res.status(401).json({ error: 'Invalid credentials' }); + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) return res.status(401).json({ error: 'Invalid credentials' }); + const token = jwt.sign({ id: user._id, email: user.email, name: user.name }, process.env.JWT_SECRET, { expiresIn: '7d' }); + res.json({ token, user: { id: user._id, name: user.name, email: user.email } }); +}); + +router.get('/me', auth, (req, res) => res.json({ user: req.user })); + +export default router; diff --git a/backend/src/routes/readings.js b/backend/src/routes/readings.js new file mode 100644 index 0000000000..d0c4aef655 --- /dev/null +++ b/backend/src/routes/readings.js @@ -0,0 +1,131 @@ +// backend/src/routes/readings.js +import { Router } from 'express'; +import mongoose from 'mongoose'; +import auth from '../middleware/auth.js'; +import Reading from '../models/Reading.js'; + +const router = Router(); +const { isValidObjectId } = mongoose; + +// All routes below require JWT +router.use(auth); + +/** + * CREATE a reading + */ +router.post('/', async (req, res) => { + try { + const { spread, title, notes, tags = [], cards = [] } = req.body; + if (!spread) return res.status(400).json({ error: 'spread is required' }); + + const reading = await Reading.create({ + userId: req.user.id, + spread, + title, + notes, + tags, + cards, + }); + + res.status(201).json(reading); + } catch (e) { + res.status(400).json({ error: e.message || 'Invalid payload' }); + } +}); + +/** + * LIST your readings (paginated) + * GET /api/readings?page=1&limit=10 + */ +router.get('/', async (req, res) => { + try { + const page = Math.max(parseInt(req.query.page || '1', 10), 1); + const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10), 1), 50); + const skip = (page - 1) * limit; + + const [items, total] = await Promise.all([ + Reading.find({ userId: req.user.id }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), + Reading.countDocuments({ userId: req.user.id }), + ]); + + res.json({ + page, + limit, + total, + pages: Math.max(1, Math.ceil(total / limit)), + items, + }); + } catch (e) { + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * READ ONE (owner check) + */ +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + if (!isValidObjectId(id)) return res.status(400).json({ error: 'Bad id' }); + + const reading = await Reading.findById(id); + if (!reading || String(reading.userId) !== req.user.id) { + return res.status(404).json({ error: 'Not found' }); + } + + res.json(reading); + } catch (e) { + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * UPDATE (partial, owner check) + */ +router.patch('/:id', async (req, res) => { + try { + const { id } = req.params; + if (!isValidObjectId(id)) return res.status(400).json({ error: 'Bad id' }); + + const allowed = ['spread', 'title', 'notes', 'tags', 'cards']; + const updates = {}; + for (const k of allowed) if (k in req.body) updates[k] = req.body[k]; + + const reading = await Reading.findById(id); + if (!reading || String(reading.userId) !== req.user.id) { + return res.status(404).json({ error: 'Not found' }); + } + + Object.assign(reading, updates); + await reading.save(); + res.json(reading); + } catch (e) { + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * DELETE (owner check) + */ +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + if (!isValidObjectId(id)) return res.status(400).json({ error: 'Bad id' }); + + const reading = await Reading.findById(id); + if (!reading || String(reading.userId) !== req.user.id) { + return res.status(404).json({ error: 'Not found' }); + } + + await reading.deleteOne(); + res.json({ message: 'Deleted' }); + } catch (e) { + res.status(500).json({ error: 'Server error' }); + } +}); + +export default router; + diff --git a/backend/src/routes/secret.js b/backend/src/routes/secret.js new file mode 100644 index 0000000000..547afe6c4d --- /dev/null +++ b/backend/src/routes/secret.js @@ -0,0 +1,12 @@ +// backend/src/routes/secret.js +import { Router } from 'express'; +import auth from '../middleware/auth.js'; + +const router = Router(); + +// GET /api/secret (requires valid JWT) +router.get('/', auth, (req, res) => { + res.json({ message: `Hello ${req.user.name}, here’s your secret ✨` }); +}); + +export default router; diff --git a/backend/src/routes/tarot.js b/backend/src/routes/tarot.js new file mode 100644 index 0000000000..ebc74533ea --- /dev/null +++ b/backend/src/routes/tarot.js @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import axios from 'axios'; + +const router = Router(); +const BASE = process.env.TAROT_API_BASE || 'https://tarotapi.dev/api/v1'; + +router.get('/draw', async (req, res) => { + try { + const n = Number(req.query.n || 3); + const { data } = await axios.get(`${BASE}/cards/random`, { params: { n } }); + res.json(data); + } catch (e) { + res.status(502).json({ error: 'Tarot API failed', detail: e.message }); + } +}); + +router.get('/cards', async (_req, res) => { + try { + const { data } = await axios.get(`${BASE}/cards`); + res.json(data); + } catch (e) { + res.status(502).json({ error: 'Tarot API failed', detail: e.message }); + } +}); + +export default router; diff --git a/frontend/index.html b/frontend/index.html index 664410b5b9..fa44fe08aa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,46 @@ -
- - - -