metric f ton

This commit is contained in:
KS Jannette
2026-03-29 07:46:56 -04:00
parent 0920985a74
commit 0412ea3e99
20 changed files with 553 additions and 206 deletions

View File

@@ -1,31 +1,28 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext";
import { useContext } from 'react'
import Navbar from "./components/Navbar";
import Login from "./pages/login/Login";
import Signup from "./pages/Signup";
import Subscribe from "./pages/subscribe/Subscribe";
import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts";
import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account";
import { AuthProvider } from "./ShopContext";
import useAuth from "./ShopContext";
export default function App() {
const { context } = useContext(ShopContext)
const { isAuthenticated, isSubscribed, user } = useAuth();
if (!currentUser) {
// Unauthenticated: show login + onboarding routes only
if (!isAuthenticated) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
);
}
// Authenticated but no active subscription: force through subscribe flow
if (!isSubscribed) {
return (
<Routes>
@@ -36,6 +33,7 @@ export default function App() {
);
}
// Fully authenticated + subscribed: main app
return (
<div>
<Navbar />

27
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,27 @@
import { API_BASE } from "./config";
export async function loginUser(email, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Login failed");
}
return data;
}
export async function registerAfterCheckout(email, password, sessionId) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, session_id: sessionId || "" }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Registration failed");
}
return data;
}

View File

@@ -1,46 +1,31 @@
/**
* Auth Headers Helper
*
* Provides authentication headers for API calls
* Includes Firebase ID token in Authorization header
* Provides authentication headers for API calls.
* Reads the JWT from localStorage (set by AuthContext on login).
*/
import { auth } from "../firebase/config";
export function getAuthHeaders() {
const token = localStorage.getItem("kp_token");
/**
* Get headers with authentication token
* @returns {Promise<Object>} Headers object with Authorization
*/
export async function getAuthHeaders() {
const currentUser = auth.currentUser;
if (!token) {
throw new Error("No authenticated user");
}
if (!currentUser) {
throw new Error("No authenticated user");
}
// Get Firebase ID token
const token = await currentUser.getIdToken();
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
}
/**
* Get headers for non-JSON requests (e.g., DELETE with no body)
* @returns {Promise<Object>} Headers object with Authorization
*/
export async function getAuthHeadersSimple() {
const currentUser = auth.currentUser;
export function getAuthHeadersSimple() {
const token = localStorage.getItem("kp_token");
if (!currentUser) {
throw new Error("No authenticated user");
}
if (!token) {
throw new Error("No authenticated user");
}
const token = await currentUser.getIdToken();
return {
Authorization: `Bearer ${token}`,
};
return {
Authorization: `Bearer ${token}`,
};
}

View File

@@ -11,11 +11,11 @@ const navLinks = [
];
export default function Navbar() {
const { currentUser, logout } = useAuth();
const { user, logout } = useAuth();
const location = useLocation();
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
if (!currentUser) return null;
if (!user) return null;
return (
<>
@@ -24,7 +24,7 @@ export default function Navbar() {
<div className="navbar__brand-group">
<span className="navbar__brand">Koin Ping</span>
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
{currentUser.email}
{user.email}
</Link>
</div>
<div className="navbar__links">
@@ -45,7 +45,7 @@ export default function Navbar() {
<div className="navbar__right">
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
{currentUser.email}
{user.email}
</Link>
<button onClick={logout} className="navbar__logout">
Logout

View File

@@ -1,54 +1,77 @@
import { createContext, useReducer, useContext } from "react";
import shopReducer, { initialState } from "./shopReducer";
import { createContext, useReducer, useContext, useEffect } from "react";
import authReducer, { initialState, ACTION_TYPES } from "../reducers/authReducer";
import { loginUser, registerAfterCheckout } from "../api/auth";
import { getAccount } from "../api/account";
const AuthContext = createContext(initialState);
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(shopReducer, initialState);
export function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, initialState);
const addAuthUser = (args) => {
const updatedUser = state.user.concat(product);
updatePrice(updatedCart);
dispatch({
type: "ADD_AUTH_USER",
payload: {
args: newUser
}
});
};
// On mount, if we have a token in localStorage, hydrate the user object
// by fetching the account from the backend.
useEffect(() => {
if (!state.token || state.user) return;
const logoutAuthUser = (args) => {
const updatedUser = state.user.filter(
(currentUser) => currentUser.name !== args.name
);
updateUser(updatedCart);
getAccount()
.then((account) => {
dispatch({
type: ACTION_TYPES.SET_USER,
payload: {
id: account.user_id,
email: account.email,
subscriptionStatus: account.subscription_status,
subscriptionTier: account.subscription_tier,
},
});
})
.catch(() => {
dispatch({ type: ACTION_TYPES.LOGOUT });
});
}, [state.token, state.user]);
dispatch({
type: "REMOVE_AUTH_USER",
payload: {
args: updatedCart
}
});
};
const value = {
authUser: state.name,
authUserTier: state.tier,
addAuthUser,
logoutAuthUser
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
const useAuth = () => {
const context = useContext(ShopContext);
if (context === undefined) {
throw new Error("authUser must be used within AuthContext");
async function login(email, password) {
const data = await loginUser(email, password);
dispatch({ type: ACTION_TYPES.LOGIN_SUCCESS, payload: data });
return data;
}
async function register(email, password, sessionId) {
const data = await registerAfterCheckout(email, password, sessionId);
dispatch({ type: ACTION_TYPES.LOGIN_SUCCESS, payload: data });
return data;
}
function logout() {
dispatch({ type: ACTION_TYPES.LOGOUT });
}
const isAuthenticated = state.isAuthenticated && !!state.token;
const isSubscribed =
state.user?.subscriptionStatus === "active" ||
state.user?.subscriptionStatus === "trialing";
const value = {
user: state.user,
token: state.token,
isAuthenticated,
isSubscribed,
login,
register,
logout,
dispatch,
ACTION_TYPES,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
}
export default useAuth;

View File

@@ -2,18 +2,15 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import { UserPropertiesProvider } from "./contexts/UserPropertiesContext";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<UserPropertiesProvider>
<AuthProvider>
<App />
</AuthProvider>
</UserPropertiesProvider>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -27,7 +27,11 @@ export default function Login() {
await login(email, password);
navigate("/addresses");
} catch (err) {
setError("Failed to log in: " + err.message);
if (err.message.includes("Invalid email or password")) {
setError("Invalid email or password. Check your credentials or create a new account.");
} else {
setError("Failed to log in: " + err.message);
}
} finally {
setLoading(false);
}
@@ -80,7 +84,7 @@ export default function Login() {
<div className="login-footer">
<p className="text-muted">
Don't have an account?{" "}
<Link to="/signup" className="login-signup-link">
<Link to="/subscribe" className="login-signup-link">
Sign up here
</Link>
</p>

View File

@@ -1,12 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import { useUserProperties } from "../../contexts/UserPropertiesContext";
import {
createOnboardingCheckout,
verifyCheckoutSession,
activateFreeTier,
} from "../../api/stripe";
import { createOnboardingCheckout, activateFreeTier } from "../../api/stripe";
import Input from "../../components/Input";
import Button from "../../components/Button";
import TierPicker from "../../components/TierPicker";
@@ -15,13 +10,12 @@ import "./Subscribe.css";
const STEPS = ["Create Account", "Choose Plan"];
export default function Subscribe() {
const { currentUser, signup } = useAuth();
const { state: userProps, dispatch, ACTION_TYPES } = useUserProperties();
const { isAuthenticated, register } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const hasPaymentReturn = searchParams.get("payment") === "success";
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
const [step, setStep] = useState(hasPaymentReturn || isAuthenticated ? 2 : 1);
const [loading, setLoading] = useState(hasPaymentReturn);
const [error, setError] = useState("");
@@ -36,7 +30,7 @@ export default function Subscribe() {
setData((prev) => ({ ...prev, [field]: value }));
}
// Handle Stripe payment returns
// Handle return from Stripe checkout redirect
useEffect(() => {
const payment = searchParams.get("payment");
const sessionId = searchParams.get("session_id");
@@ -50,41 +44,33 @@ export default function Subscribe() {
if (payment !== "success" || !sessionId) return;
// Phase 1: no account yet — create it. Auth state change remounts the
// component (App.jsx swaps route trees) and Phase 2 runs on the next mount.
if (!currentUser) {
if (!userProps.email || !userProps.password) {
setSearchParams({}, { replace: true });
setError("Session expired. Please start the signup process again.");
setStep(1);
setLoading(false);
return;
}
setLoading(true);
signup(userProps.email, userProps.password).catch((err) => {
setSearchParams({}, { replace: true });
setError("Account creation failed: " + err.message);
setStep(1);
setLoading(false);
});
// Recover credentials stashed before the Stripe redirect
const savedEmail = sessionStorage.getItem("kp_onboard_email");
const savedPassword = sessionStorage.getItem("kp_onboard_password");
if (!savedEmail || !savedPassword) {
setSearchParams({}, { replace: true });
setError("Session expired. Please start the signup process again.");
setStep(1);
setLoading(false);
return;
}
// Phase 2: authenticated — verify the checkout and go to the dashboard.
setSearchParams({}, { replace: true });
setLoading(true);
dispatch({ type: ACTION_TYPES.CLEAR_USER_PROPERTIES });
verifyCheckoutSession(sessionId)
register(savedEmail, savedPassword, sessionId)
.then(() => {
sessionStorage.removeItem("kp_onboard_email");
sessionStorage.removeItem("kp_onboard_password");
navigate("/addresses", { replace: true });
})
.catch((err) => {
setError("Payment verification failed: " + err.message);
setStep(2);
setError("Registration failed: " + err.message);
setStep(1);
})
.finally(() => setLoading(false));
}, [currentUser, searchParams, setSearchParams, signup, navigate, userProps, dispatch, ACTION_TYPES]);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Step handlers ─────────────────────────────────────────────────────────
@@ -114,31 +100,28 @@ export default function Subscribe() {
try {
setLoading(true);
// Free tier: register immediately, then activate free tier
if (data.selectedTier === "free") {
if (!currentUser) {
await signup(data.email, data.password);
if (!isAuthenticated) {
await register(data.email, data.password, "");
}
await activateFreeTier();
navigate("/addresses", { replace: true });
return;
}
dispatch({
type: ACTION_TYPES.SET_USER_PROPERTIES,
payload: { email: data.email, password: data.password },
});
// Paid tier: stash credentials in sessionStorage, then redirect to Stripe
sessionStorage.setItem("kp_onboard_email", data.email);
sessionStorage.setItem("kp_onboard_password", data.password);
const { url } = await createOnboardingCheckout(
data.email,
data.selectedTier,
);
window.location.href = url;
} catch (err) {
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
if (err.message.includes("already exists")) {
setError("An account with this email already exists. Try logging in instead.");
} else {
setError("Failed to process plan selection: " + err.message);
}
@@ -269,7 +252,7 @@ export default function Subscribe() {
return (
<div className="subscribe__footer">
<div>
{step === 2 && (
{step === 2 && !isAuthenticated && (
<Button onClick={handleBack} disabled={loading} variant="ghost">
Back
</Button>

View File

@@ -1,7 +1,5 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { updatePassword } from "firebase/auth";
import { auth } from "../../firebase/config";
import { getAccount, createPortalSession } from "../../api/account";
import "./Account.css";
@@ -55,7 +53,8 @@ export default function Account() {
try {
setChangingPassword(true);
await updatePassword(auth.currentUser, newPassword);
// TODO: implement password change endpoint on backend
throw new Error("Password change not yet implemented");
setPasswordMsg("Password updated successfully");
setNewPassword("");
setConfirmPassword("");

View File

@@ -1,32 +1,56 @@
import { useReducer } from "react"
const UserAuthContext = createContext();
export const initialState = {
authUser: null,
AuthTier: null
export const ACTION_TYPES = {
LOGIN_SUCCESS: "LOGIN_SUCCESS",
SET_USER: "SET_USER",
LOGOUT: "LOGOUT",
AUTH_ERROR: "AUTH_ERROR",
};
const authReducer = (state, action) => {
const { type, payload = "FREE_TIER" } = action
export const initialState = {
user: null,
token: localStorage.getItem("kp_token") || null,
isAuthenticated: !!localStorage.getItem("kp_token"),
error: null,
};
switch (type) {
case "ADD_AUTH_USER":
console.log("ADD_AUTH_USER", payload);
export default function authReducer(state, action) {
switch (action.type) {
case ACTION_TYPES.LOGIN_SUCCESS:
localStorage.setItem("kp_token", action.payload.token);
return {
...state,
auth: payload
user: {
id: action.payload.user_id,
email: action.payload.email,
subscriptionStatus: action.payload.subscription_status,
subscriptionTier: action.payload.subscription_tier,
},
token: action.payload.token,
isAuthenticated: true,
error: null,
};
case ACTION_TYPES.SET_USER:
return {
...state,
user: action.payload,
error: null,
};
case ACTION_TYPES.LOGOUT:
localStorage.removeItem("kp_token");
return {
...initialState,
token: null,
isAuthenticated: false,
};
case ACTION_TYPES.AUTH_ERROR:
return {
...state,
error: action.payload,
};
default:
throw new Error(`No case for type ${type} found.`);
}
switch (type) {
case "DELETE_AUTH_USER":
return {
...state,
users: state.users.filter(user => user.id !== action.userIdToDelete)
};
return state;
}
}
export default shopReducer