Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8439741
Backend connected to Mongo + basic server running
UlrikaRakkaBrant Aug 24, 2025
ae29a5e
Backend auth working (register/login/me)
UlrikaRakkaBrant Aug 24, 2025
0188840
Frontend + backend wired; register/login working
UlrikaRakkaBrant Aug 25, 2025
f47cea1
Add new files
UlrikaRakkaBrant Aug 25, 2025
d70771a
Add Zod validation to Register & Login
UlrikaRakkaBrant Aug 26, 2025
d247578
Axios helper added; auth context uses auto Bearer token
UlrikaRakkaBrant Aug 26, 2025
9178ffb
Home facelift + light/dark mode toggle
UlrikaRakkaBrant Aug 26, 2025
c02360a
modified: backend/server.js, modified: frontend/src/pages/Secret.jsx
UlrikaRakkaBrant Aug 27, 2025
a5555dd
Card images showing
UlrikaRakkaBrant Aug 27, 2025
86e5d1b
feat: tarot draws & UI polish
UlrikaRakkaBrant Aug 27, 2025
e0810d8
feat(home): hero section with public-domain background, new fonts, an…
UlrikaRakkaBrant Aug 28, 2025
7ca1d22
feat(ui): move 'What's next?' info into Login & Register forms; remov…
UlrikaRakkaBrant Aug 28, 2025
f444e65
feat(secret): save 1-card reading from Draw options; remove manual lo…
UlrikaRakkaBrant Aug 28, 2025
a20de3e
fix(cors): allow Netlify origin + no-origin preflights
UlrikaRakkaBrant Aug 29, 2025
0354c4b
fix(netlify): SPA redirect so /secret and deep links work
UlrikaRakkaBrant Aug 30, 2025
006b0fe
Update Home layout: Welcome card above Guidance card
UlrikaRakkaBrant Aug 30, 2025
e353514
fix: use ALLOWED_ORIGINS in CORS log (was ORIGINS)
UlrikaRakkaBrant Aug 30, 2025
0551b7f
fix(frontend): remove JSON quotes from stored token before setting Au…
UlrikaRakkaBrant Aug 31, 2025
e73f274
fix(auth): parse stored token before sending; auto-logout on 401/403
UlrikaRakkaBrant Sep 1, 2025
24a8c55
chore: remove unused health.js and db.js
UlrikaRakkaBrant Sep 1, 2025
da7eaf4
fix(auth): attach token via axios request interceptor; style(home): w…
UlrikaRakkaBrant Sep 1, 2025
8c196e3
feat: warm API on app start to reduce cold-start delay
UlrikaRakkaBrant Sep 6, 2025
5267036
fix(frontend): only attach JWT to private requests; handle expired se…
UlrikaRakkaBrant Sep 6, 2025
1b01e4c
feat(frontend): add withRetry helper and wrap tarot draws to handle t…
UlrikaRakkaBrant Sep 6, 2025
9520cab
fix(api): harden readings routes
UlrikaRakkaBrant Sep 6, 2025
30a9dc3
chore(api): robust auth header (unquote token, only attach to protect…
UlrikaRakkaBrant Sep 7, 2025
7387c15
style(home): add vertical gap between welcome and guidance cards
UlrikaRakkaBrant Sep 7, 2025
6534126
ui(secret): center draw panel; center 'My Readings' header but keep l…
UlrikaRakkaBrant Sep 7, 2025
8afa26b
working on navbar
UlrikaRakkaBrant Sep 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,11 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*

package-lock.json
package-lock.json
# env files
backend/.env
frontend/.env

# env files
backend/.env
frontend/.env
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
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
14 changes: 10 additions & 4 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
97 changes: 83 additions & 14 deletions backend/server.js
Original file line number Diff line number Diff line change
@@ -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();

13 changes: 13 additions & 0 deletions backend/src/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -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' });
}
}
19 changes: 19 additions & 0 deletions backend/src/models/Reading.js
Original file line number Diff line number Diff line change
@@ -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);
10 changes: 10 additions & 0 deletions backend/src/models/User.js
Original file line number Diff line number Diff line change
@@ -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);
31 changes: 31 additions & 0 deletions backend/src/routes/auth.js
Original file line number Diff line number Diff line change
@@ -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;
131 changes: 131 additions & 0 deletions backend/src/routes/readings.js
Original file line number Diff line number Diff line change
@@ -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;

Loading