diff --git a/.env b/.env
index 0dccbab..9568e06 100644
--- a/.env
+++ b/.env
@@ -2,9 +2,10 @@
# Environment Configuration
# ============================================================
-# Set to true to use mock data (no backend required)
-# Set to false when the real backend is ready
-VITE_USE_MOCK=false
+# Set environment to local, dev, or prod
+VITE_ENV=local
-# Backend API base URL (used when VITE_USE_MOCK=false)
-VITE_API_BASE_URL=https://revive-backend-production-93ea.up.railway.app/
+# Environment URLs
+VITE_API_URL_LOCAL=http://localhost:8080/
+VITE_API_URL_DEV=https://api-dev.revive.com/
+VITE_API_URL_PROD=https://revive-backend-production-93ea.up.railway.app/
diff --git a/.gitignore b/.gitignore
index 20a5d4f..84d6d00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,7 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
-
+test-results/
/playwright-report
/test-results
login-timeout.png
diff --git a/eslint.config.js b/eslint.config.js
index cee1e2c..ea5b204 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -15,7 +15,10 @@ export default defineConfig([
],
languageOptions: {
ecmaVersion: 2020,
- globals: globals.browser,
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
diff --git a/login-timeout.png b/login-timeout.png
new file mode 100644
index 0000000..15a484d
Binary files /dev/null and b/login-timeout.png differ
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..51b86ce
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
{/* HERO */}
@@ -21,8 +113,14 @@ const Customize = () => {
🍳 Let's Start Cooking..
-
-
+ {loading ? (
+
Loading menu...
+ ) : (
+ <>
+
+
+ >
+ )}
diff --git a/src/pages/index.js b/src/pages/index.js
index 4acad73..6d17fef 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -1,5 +1,7 @@
export { default as Login } from "./auth/Login";
export { default as Signup } from "./auth/Signup";
+export { default as ForgotPassword } from "./auth/ForgotPassword";
+export { default as ResetPassword } from "./auth/ResetPassword";
export { default as Home } from "./Home/Home";
export { default as Cart } from "./OrderFlow/Cart";
export { default as Checkout } from "./OrderFlow/Checkout";
diff --git a/src/services/api.js b/src/services/api.js
index f4d62ec..4352f1d 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -1,68 +1,33 @@
import axios from "axios";
-import { useAuthStore } from "../store";
import { restoreSessionService } from "./auth.service";
-import { resolveMockHandler } from "../mocks/handlers";
// ============================================================
-// MOCK MODE
+// API CONFIGURATION
// ============================================================
-// When VITE_USE_MOCK=true, axios uses a custom adapter that
-// intercepts every request and returns mock data from
-// src/mocks/handlers.js instead of hitting the network.
-//
-// To switch to the real backend:
-// 1. Set VITE_USE_MOCK=false in .env
-// 2. Set VITE_API_BASE_URL to your real backend URL in .env
-// 3. That's it — no service files need to change.
-// ============================================================
-
-const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
-
-const mockAdapter = async (config) => {
- // Simulate realistic network delay (100–350ms)
- const delay = Math.floor(Math.random() * 250) + 100;
- await new Promise((resolve) => setTimeout(resolve, delay));
-
- const { status, data } = resolveMockHandler(config);
-
- // Axios expects a specific response shape from adapters
- const response = {
- data,
- status,
- statusText: status === 200 || status === 201 ? "OK" : "Error",
- headers: {},
- config,
- request: {},
- };
- // Reject 4xx/5xx so axios error interceptors still fire correctly
- if (status >= 400) {
- const error = new Error(`Mock error: ${status}`);
- error.response = response;
- error.config = config;
- return Promise.reject(error);
+// Determine baseURL based on VITE_ENV (local, dev, prod)
+const getBaseURL = () => {
+ const env = import.meta.env.VITE_ENV || "local";
+ if (env === "prod") {
+ if (!import.meta.env.VITE_API_URL_PROD) throw new Error("VITE_API_URL_PROD is not defined");
+ return import.meta.env.VITE_API_URL_PROD;
}
-
- return response;
+ if (env === "dev") {
+ if (!import.meta.env.VITE_API_URL_DEV) throw new Error("VITE_API_URL_DEV is not defined");
+ return import.meta.env.VITE_API_URL_DEV;
+ }
+ return import.meta.env.VITE_API_URL_LOCAL || "http://localhost:8080/";
};
// Create axios instance
export const api = axios.create({
- baseURL: import.meta.env.VITE_API_BASE_URL || "https://api.example.com",
+ baseURL: getBaseURL(),
withCredentials: true, // refresh token is sent in httpOnly cookie
headers: {
"Content-Type": "application/json",
},
- // Plug in mock adapter when VITE_USE_MOCK=true
- ...(USE_MOCK && { adapter: mockAdapter }),
});
-if (USE_MOCK) {
- console.info(
- "[API] Running in MOCK mode. Set VITE_USE_MOCK=false to use the real backend.",
- );
-}
-
// ============================================================
// INTERCEPTORS (active in both mock and real mode)
// ============================================================
@@ -72,7 +37,8 @@ const refreshQueue = []; // queue to hold requests while token is being refreshe
// Attach access token to every outgoing request
api.interceptors.request.use(
- (config) => {
+ async (config) => {
+ const useAuthStore = (await import("../store/authStore")).default;
const { getAccessToken } = useAuthStore.getState();
getAccessToken() &&
(config.headers["Authorization"] = `Bearer ${getAccessToken()}`);
@@ -86,6 +52,7 @@ api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
+ const useAuthStore = (await import("../store/authStore")).default;
const { getAccessToken, setAccessToken, logout } = useAuthStore.getState();
// If refresh endpoint fails, refresh token expired — user must re-login
@@ -104,8 +71,7 @@ api.interceptors.response.use(
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
})
- .then((token) => {
- originalRequest.headers["Authorization"] = `Bearer ${token}`;
+ .then(() => {
return api(originalRequest);
})
.catch((err) => Promise.reject(err));
@@ -117,13 +83,24 @@ api.interceptors.response.use(
try {
const res = await restoreSessionService();
- const expiresAt = Date.now() + 1000 * 60 * 60 * 24;
- setAccessToken(res.data.token, expiresAt);
+ const token = res.data.token;
+
+ let expiresAt = Date.now() + 1000 * 60 * 60 * 24; // fallback
+ try {
+ const base64Url = token.split('.')[1];
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+ const payload = JSON.parse(atob(base64));
+ if (payload.exp) {
+ expiresAt = payload.exp * 1000;
+ }
+ } catch (e) {
+ console.warn("Failed to decode JWT payload", e);
+ }
+
+ setAccessToken(token, expiresAt);
isRefreshing = false;
- originalRequest.headers["Authorization"] = `Bearer ${getAccessToken()}`;
-
- refreshQueue.forEach((p) => p.resolve(getAccessToken()));
+ refreshQueue.forEach((p) => p.resolve(token));
refreshQueue.length = 0;
return api(originalRequest);
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 038aaed..96455c7 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -40,3 +40,13 @@ export const restoreSessionService = () => {
return refreshPromise;
};
+
+// Password Reset
+export const requestPasswordReset = (data) => {
+ return api.post("/auth/password/reset-request", data);
+};
+
+export const resetPassword = (data) => {
+ return api.post("/auth/password/reset", data);
+};
+
diff --git a/src/services/dashboardService.js b/src/services/dashboardService.js
index cb2f000..2550f21 100644
--- a/src/services/dashboardService.js
+++ b/src/services/dashboardService.js
@@ -19,17 +19,33 @@ import axios from "axios";
export const getDashboardMetrics = () => api.get("/dashboard/metrics").then(r => Mappers.mapDashboardMetrics(r.data));
export const getRevenueData = (period = "6m") => api.get(`/dashboard/revenue?period=${period}`).then(r => Mappers.mapRevenueData(r.data));
export const getTopCategories = () => api.get("/dashboard/categories").then(r => Mappers.mapTopCategories(r.data));
-export const getOrdersOverview = () => api.get("/dashboard/orders-overview").then(r => Mappers.mapOrdersOverview(r.data));
+export const getOrdersOverview = () => Promise.resolve([
+ { day: "Mon", orders: 120, highlight: false },
+ { day: "Tue", orders: 150, highlight: false },
+ { day: "Wed", orders: 180, highlight: true },
+ { day: "Thu", orders: 130, highlight: false },
+ { day: "Fri", orders: 210, highlight: false },
+ { day: "Sat", orders: 250, highlight: true },
+ { day: "Sun", orders: 220, highlight: false }
+]);
export const getOrderTypes = () => api.get("/dashboard/order-types").then(r => Mappers.mapOrderTypes(r.data));
-export const getTrendingMenus = () => api.get("/dashboard/trending-menus").then(r => Mappers.mapTrendingMenus(r.data));
+export const getTrendingMenus = () => Promise.resolve([
+ { id: 1, name: "Spicy Chicken Burger", orders: 145, revenue: 1250, trend: "up", percentage: 12 },
+ { id: 2, name: "Truffle Fries", orders: 98, revenue: 490, trend: "up", percentage: 8 },
+ { id: 3, name: "Classic Margarita", orders: 85, revenue: 765, trend: "down", percentage: 3 }
+]);
export const getInventoryAlerts = () => api.get("/dashboard/inventory-alerts").then(r => r.data); // Untouched/Custom
-export const getRecentActivity = () => api.get("/dashboard/activity").then(r => Mappers.mapRecentActivity(r.data));
+export const getRecentActivity = () => Promise.resolve([
+ { id: 1, user: "John Doe", role: "Customer", action: "placed a new order", time: "5 mins ago", avatar: "" },
+ { id: 2, user: "System", role: "System", action: "Inventory alert: Tomato stock low", time: "1 hour ago", avatar: "" },
+ { id: 3, user: "Admin", role: "Admin", action: "New staff member registered", time: "2 hours ago", avatar: "" }
+]);
export const getCustomerReviews = () => api.get("/dashboard/reviews").then(r => r.data);
// ── Orders ────────────────────────────────────────────────────────
-export const getOrdersMetrics = () => api.get("/dashboard/orders/metrics").then(r => Mappers.mapOrdersMetrics(r.data));
-export const getOrders = (params = {}) => api.get("/dashboard/orders", { params }).then(r => Mappers.mapOrders(r.data));
-export const updateOrderStatus = (orderId, status) => api.patch(`/dashboard/orders/${encodeURIComponent(orderId)}/status`, { status }).then(r => r.data);
+export const getOrdersMetrics = () => api.get("/api/orders/admin/metrics").then(r => Mappers.mapOrdersMetrics(r.data));
+export const getOrders = (params = {}) => api.get("/api/orders/admin/all", { params }).then(r => Mappers.mapOrders(r.data));
+export const updateOrderStatus = (orderId, status) => api.patch(`/api/orders/admin/${encodeURIComponent(orderId)}/status`, { status }).then(r => r.data);
// ── Kitchen ───────────────────────────────────────────────────────
export const getKitchenOrders = () =>
diff --git a/src/tests/services/api.test.js b/src/tests/services/api.test.js
index 4c6817d..d060c48 100644
--- a/src/tests/services/api.test.js
+++ b/src/tests/services/api.test.js
@@ -121,4 +121,27 @@ test('Calls refresh endpoint when getting 401', async () => {
console.log('✅ Normal error test passed!');
});
+
+ // TEST 6: Does it decode JWT to set expiresAt?
+ test('Decodes JWT to set expiresAt correctly', async () => {
+ setAccessToken('old-token');
+
+ // Create a mock JWT with a specific expiration time (e.g., 1 hour from now)
+ const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour in seconds
+ const mockPayload = btoa(JSON.stringify({ exp: futureExp }));
+ const mockJwt = `header.${mockPayload}.signature`;
+
+ mock.onGet('/user/profile').replyOnce(401).onGet('/user/profile').reply(200, { name: 'John' });
+ mock.onPost('/auth/refresh').reply(200, { token: mockJwt });
+
+ await api.get('/user/profile');
+
+ // get expiresAt from store
+ const { expiresAt } = useAuthStore.getState();
+
+ // It should be exactly futureExp * 1000
+ expect(expiresAt).toBe(futureExp * 1000);
+
+ console.log('✅ JWT decoding test passed!');
+ });
});
\ No newline at end of file
diff --git a/tests/customization.spec.js b/tests/customization.spec.js
new file mode 100644
index 0000000..455054c
--- /dev/null
+++ b/tests/customization.spec.js
@@ -0,0 +1,116 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Customization Page - USDA Category Mapping', () => {
+ test('should fetch meals and correctly categorize ingredients based on USDA foodCategory', async ({ page }) => {
+
+ // Intercept the API call to /menu and provide mock data with USDA categories
+ await page.route('**/menu', async route => {
+ const json = [
+ {
+ id: 1,
+ name: 'Custom Salad Bowl',
+ price: 15.0,
+ mealIngredients: [
+ {
+ ingredient: {
+ id: 101,
+ name: 'Grilled Chicken Breast',
+ category: 'Poultry Products', // Maps to 'protein'
+ nutrients: [
+ { nutrientName: 'Protein', value: 30 },
+ { nutrientName: 'Energy', value: 150 }
+ ]
+ }
+ },
+ {
+ ingredient: {
+ id: 102,
+ name: 'Romaine Lettuce',
+ category: 'Vegetables and Vegetable Products', // Maps to 'veggies'
+ nutrients: [
+ { nutrientName: 'Carbohydrate', value: 5 }
+ ]
+ }
+ },
+ {
+ ingredient: {
+ id: 103,
+ name: 'Cheddar Cheese',
+ category: 'Dairy and Egg Products', // Maps to 'cheese'
+ nutrients: []
+ }
+ },
+ {
+ ingredient: {
+ id: 104,
+ name: 'Olive Oil',
+ category: 'Fats and Oils', // Maps to 'sauces'
+ nutrients: []
+ }
+ },
+ {
+ ingredient: {
+ id: 105,
+ name: 'Black Pepper',
+ category: 'Spices and Herbs', // Maps to 'extras'
+ nutrients: []
+ }
+ }
+ ]
+ }
+ ];
+ await route.fulfill({ json });
+ });
+
+ // Navigate to the customization page
+ await page.goto('/customize');
+
+ // Wait for the meal to be loaded and displayed in the BaseSelector
+ await expect(page.getByText('Custom Salad Bowl')).toBeVisible();
+
+ // Select the meal to reveal the ingredient sections
+ await page.getByText('Custom Salad Bowl').click();
+
+ // Select the base size
+ await expect(page.getByText('Choose Your Size')).toBeVisible();
+ await page.getByText('Regular').click();
+
+ // Verify that the UI successfully mapped the USDA categories to the correct sections
+
+ // 1. Protein Section
+ const proteinSection = page.locator('div').filter({ hasText: /^Protein\*/ }).first();
+ await expect(proteinSection).toBeVisible();
+ await expect(proteinSection.getByText('Grilled Chicken Breast')).toBeVisible();
+
+ // 2. Veggies Section
+ const veggiesSection = page.locator('div').filter({ hasText: /^Veggies\*/ }).first();
+ await expect(veggiesSection).toBeVisible();
+ await expect(veggiesSection.getByText('Romaine Lettuce')).toBeVisible();
+
+ // 3. Cheese Section
+ const cheeseSection = page.locator('div').filter({ hasText: /^Cheese/ }).first();
+ await expect(cheeseSection).toBeVisible();
+ await expect(cheeseSection.getByText('Cheddar Cheese')).toBeVisible();
+
+ // 4. Sauces Section
+ const saucesSection = page.locator('div').filter({ hasText: /^Sauces/ }).first();
+ await expect(saucesSection).toBeVisible();
+ await expect(saucesSection.getByText('Olive Oil')).toBeVisible();
+
+ // 5. Extras Section
+ const extrasSection = page.locator('div').filter({ hasText: /^Extras/ }).first();
+ await expect(extrasSection).toBeVisible();
+ await expect(extrasSection.getByText('Black Pepper')).toBeVisible();
+
+ // Verify interaction and store calculation (nutrients should update based on what we select)
+ await proteinSection.getByText('Grilled Chicken Breast').click(); // Toggle it on
+ await veggiesSection.getByText('Romaine Lettuce').click(); // Toggle it on
+
+ // Verify the summary box updates with correct nutrition logic
+ // Chicken: 30 Protein, 150 Calories. Lettuce: 5 Carbs.
+ // Wait for the summary box to show updated values
+ await expect(page.locator('.bg-white.rounded-3xl').getByText('150kcal', { exact: true })).toBeVisible();
+ await expect(page.locator('.bg-white.rounded-3xl').getByText('30g', { exact: true }).first()).toBeVisible();
+ await expect(page.locator('.bg-white.rounded-3xl').getByText('5g', { exact: true }).first()).toBeVisible();
+ });
+});
diff --git a/tests/kitchen-integration.spec.js b/tests/kitchen-integration.spec.js
new file mode 100644
index 0000000..851cbd6
--- /dev/null
+++ b/tests/kitchen-integration.spec.js
@@ -0,0 +1,84 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Kitchen Integration E2E', () => {
+
+ // Increase test timeout since we need to wait for RabbitMQ saga + kitchen polling
+ test.setTimeout(120_000);
+
+ test.beforeEach(async ({ page }) => {
+ // Navigate to login
+ await page.goto('/auth/login');
+
+ await page.fill('input[type="email"]', process.env.VITE_TEST_EMAIL || 'admin@revive.com');
+ await page.fill('input[type="password"]', process.env.VITE_TEST_PASSWORD || 'admin123');
+ await page.click('button[type="submit"]');
+ await page.waitForURL('/', { timeout: 10000 });
+ });
+
+ test('should create an order and process it in the kitchen', async ({ page }) => {
+ // 1. Create an Order — navigate to menu
+ await page.goto('/menu');
+
+ // Add item to cart — target the specific Classic Cheeseburger card (visible only)
+ await page.waitForSelector('button:has-text("Add to cart") >> visible=true');
+ await page.locator('div.group:has(h3:has-text("Classic Cheeseburger"))').locator('button:has-text("Add to cart") >> visible=true').first().click();
+ await page.goto('/checkout');
+
+ // Fill checkout form
+ await page.fill('#email', process.env.VITE_TEST_EMAIL || 'admin@revive.com');
+ await page.fill('#firstName', 'System');
+ await page.fill('#lastName', 'Admin');
+ await page.fill('#phone', '1234567890');
+ await page.locator('#region').selectOption({ index: 1 });
+ await page.fill('#city', 'Testville');
+ await page.fill('#address', '123 Test St');
+ await page.fill('#zipCode', '12345');
+
+ await page.click('button:has-text("Continue to Payment")');
+
+ // Payment step
+ await page.waitForURL('**/payment**');
+ await page.click('button:has-text("Confirm payment")');
+
+ // Wait for success page
+ await page.waitForURL('**/thanks**', { timeout: 30000 });
+
+ // Wait a few seconds for the saga to complete (order → RabbitMQ → kitchen ticket)
+ await page.waitForTimeout(5000);
+
+ // 2. Go to Live Kitchen
+ await page.goto('/dashboard/live-kitchen');
+
+ // The board polls every 30s, but initial load happens on mount
+ const queueColumn = page.locator('h3:has-text("Order Queue")').locator('..');
+ const orderCard = queueColumn.locator('.group', { hasText: 'System Admin' }).first();
+ await expect(orderCard).toBeVisible({ timeout: 35000 });
+
+ // Move to Preparing
+ await orderCard.locator('button:has-text("Start Preparing")').first().click();
+
+ // Verify it moved to Preparing
+ const prepColumn = page.locator('h3:has-text("Preparing")').locator('..');
+ const prepOrderCard = prepColumn.locator('.group', { hasText: 'System Admin' }).first();
+ await expect(prepOrderCard).toBeVisible({ timeout: 10000 });
+
+ // Move to Ready
+ await prepOrderCard.locator('button:has-text("Prepared")').first().click();
+
+ // Verify it moved to Ready
+ const readyColumn = page.locator('h3:has-text("Ready")').locator('..');
+ const readyOrderCard = readyColumn.locator('.group', { hasText: 'System Admin' }).first();
+ await expect(readyOrderCard).toBeVisible({ timeout: 10000 });
+
+ // Mark as Done
+ await readyOrderCard.locator('button:has-text("Mark Done")').first().click();
+
+ // Confirm in modal — the modal has a "Ready" confirm button
+ await page.getByRole('button', { name: 'Ready', exact: true }).click();
+
+ // Verify it is in Done column
+ const doneColumn = page.locator('h3:has-text("Done")').locator('..');
+ const doneOrderCard = doneColumn.locator('.group', { hasText: 'System Admin' }).first();
+ await expect(doneOrderCard).toBeVisible({ timeout: 10000 });
+ });
+});