Compare commits

...

9 Commits

Author SHA1 Message Date
Sidharth Prabhu
5fa3a57e84 Unrated orders error fixed 2026-08-04 14:21:14 +05:30
Sidharth Prabhu
f898adff22 Stash added 2026-08-04 14:15:35 +05:30
Sidharth Prabhu
2bf455f53f Stash added 2026-08-04 14:14:50 +05:30
Sidharth Prabhu
f876fa9239 Bug Fixes 2026-08-04 14:10:10 +05:30
Sidharth Prabhu
d07ae358eb Resource conserved in the frontend login 2026-08-04 14:05:42 +05:30
Sidharth Prabhu
c1b6d7a7cb Light weighted update 2026-08-04 14:00:46 +05:30
Sidharth Prabhu
6750c5456f Site resource conservation added, for faster loading time. Only for Admin Frontend 2026-08-04 13:54:34 +05:30
Sidharth Prabhu
1a000a3be2 Removed the old Canteen db 2026-08-04 13:09:55 +05:30
Sidharth Prabhu
a330844138 JWT Key added 2026-08-04 12:58:30 +05:30
66 changed files with 2131 additions and 359 deletions

2
.gitignore vendored
View File

@@ -3,5 +3,3 @@
backend/razorpay.env
backend/src/main/resources/application-local.properties
counter-frontend/.env
ordering-site/*
ordering_site/

View File

@@ -4,7 +4,7 @@ server.port=8080
# ============================================================
# DATABASE — REQUIRED environment variables
# Set these in your environment or a .env file
# Set these in your environment or a .env file.
# ============================================================
spring.datasource.url=jdbc:postgresql://localhost:5432/rit_cms
spring.datasource.username=postgres
@@ -20,7 +20,7 @@ spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
# JWT — REQUIRED environment variables
# MUST provide a secure random key (min 256-bit)
# ============================================================
app.jwt.secret=${JWT_SECRET:default_jwt_secret_key_which_is_at_least_32_bytes_long}
app.jwt.secret=${JWT_SECRET:f4d9b0d81b6d6ef8c8d6f35f4fb2bb2b9e2fd6f7d20a0c4d0b6d97c93e4f6f7a9d2b8f4a7e1c5d9f3b6a2c8e4f7d1b9c0a6e3d8f5b2c7a1e9d4f6c8b0a3e7f1}
app.jwt.expiration-ms=86400000
# ============================================================

View File

@@ -0,0 +1,9 @@
import{a as e,i as t,n,o as r,r as i}from"./proxy-cLldRXEc.js";import{g as a,h as o,m as s,p as c,u as l}from"./index-B-Yfb7Zz.js";var u=s(),d=a(o(),1);function f(e,t){if(typeof e==`function`)return e(t);e!=null&&(e.current=t)}function p(...e){return t=>{let n=!1,r=e.map(e=>{let r=f(e,t);return!n&&typeof r==`function`&&(n=!0),r});if(n)return()=>{for(let t=0;t<r.length;t++){let n=r[t];typeof n==`function`?n():f(e[t],null)}}}}function m(...e){return d.useCallback(p(...e),e)}var h=class extends d.Component{getSnapshotBeforeUpdate(e){let t=this.props.childRef.current;if(i(t)&&e.isPresent&&!this.props.isPresent&&this.props.pop!==!1){let e=t.offsetParent,n=i(e)&&e.offsetWidth||0,r=i(e)&&e.offsetHeight||0,a=getComputedStyle(t),o=this.props.sizeRef.current;o.height=parseFloat(a.height),o.width=parseFloat(a.width),o.top=t.offsetTop,o.left=t.offsetLeft,o.right=n-o.width-o.left,o.bottom=r-o.height-o.top}return null}componentDidUpdate(){}render(){return this.props.children}};function g({children:e,isPresent:t,anchorX:n,anchorY:r,root:i,pop:a}){let o=(0,d.useId)(),s=(0,d.useRef)(null),c=(0,d.useRef)({width:0,height:0,top:0,left:0,right:0,bottom:0}),{nonce:f}=(0,d.useContext)(l),p=m(s,e.props?.ref??e?.ref);return(0,d.useInsertionEffect)(()=>{let{width:e,height:l,top:u,left:d,right:p,bottom:m}=c.current;if(t||a===!1||!s.current||!e||!l)return;let h=n===`left`?`left: ${d}`:`right: ${p}`,g=r===`bottom`?`bottom: ${m}`:`top: ${u}`;s.current.dataset.motionPopId=o;let _=document.createElement(`style`);f&&(_.nonce=f);let v=i??document.head;return v.appendChild(_),_.sheet&&_.sheet.insertRule(`
[data-motion-pop-id="${o}"] {
position: absolute !important;
width: ${e}px !important;
height: ${l}px !important;
${h}px !important;
${g}px !important;
}
`),()=>{s.current?.removeAttribute(`data-motion-pop-id`),v.contains(_)&&v.removeChild(_)}},[t]),(0,u.jsx)(h,{isPresent:t,childRef:s,sizeRef:c,pop:a,children:a===!1?e:d.cloneElement(e,{ref:p})})}var _=({children:e,initial:n,isPresent:r,onExitComplete:i,custom:a,presenceAffectsLayout:o,mode:s,anchorX:l,anchorY:f,root:p})=>{let m=c(v),h=(0,d.useId)(),_=!0,y=(0,d.useMemo)(()=>(_=!1,{id:h,initial:n,isPresent:r,custom:a,onExitComplete:e=>{m.set(e,!0);for(let e of m.values())if(!e)return;i&&i()},register:e=>(m.set(e,!1),()=>m.delete(e))}),[r,m,i]);return o&&_&&(y={...y}),(0,d.useMemo)(()=>{m.forEach((e,t)=>m.set(t,!1))},[r]),d.useEffect(()=>{!r&&!m.size&&i&&i()},[r]),e=(0,u.jsx)(g,{pop:s===`popLayout`,isPresent:r,anchorX:l,anchorY:f,root:p,children:e}),(0,u.jsx)(t.Provider,{value:y,children:e})};function v(){return new Map}var y=e=>e.key||``;function b(e){let t=[];return d.Children.forEach(e,e=>{(0,d.isValidElement)(e)&&t.push(e)}),t}var x=({children:t,custom:i,initial:a=!0,onExitComplete:o,presenceAffectsLayout:s=!0,mode:l=`sync`,propagate:f=!1,anchorX:p=`left`,anchorY:m=`top`,root:h})=>{let[g,v]=n(f),x=(0,d.useMemo)(()=>b(t),[t]),S=f&&!g?[]:x.map(y),C=(0,d.useRef)(!0),w=(0,d.useRef)(x),T=c(()=>new Map),E=(0,d.useRef)(new Set),[D,O]=(0,d.useState)(x),[k,A]=(0,d.useState)(x);e(()=>{C.current=!1,w.current=x;for(let e=0;e<k.length;e++){let t=y(k[e]);S.includes(t)?(T.delete(t),E.current.delete(t)):T.get(t)!==!0&&T.set(t,!1)}},[k,S.length,S.join(`-`)]);let j=[];if(x!==D){let e=[...x];for(let t=0;t<k.length;t++){let n=k[t],r=y(n);S.includes(r)||(e.splice(t,0,n),j.push(n))}return l===`wait`&&j.length&&(e=j),A(b(e)),O(x),null}let{forceRender:M}=(0,d.useContext)(r);return(0,u.jsx)(u.Fragment,{children:k.map(e=>{let t=y(e),n=f&&!g?!1:x===k||S.includes(t);return(0,u.jsx)(_,{isPresent:n,initial:!C.current||a?void 0:!1,custom:i,presenceAffectsLayout:s,mode:l,root:h,onExitComplete:n?void 0:()=>{if(E.current.has(t))return;if(T.has(t))E.current.add(t),T.set(t,!0);else return;let e=!0;T.forEach(t=>{t||(e=!1)}),e&&(M?.(),A(w.current),f&&v?.(),o&&o())},anchorX:p,anchorY:m,children:e},t)})})};export{x as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{h as e,m as t,s as n}from"./index-B-Yfb7Zz.js";var r=n(`chevron-left`,[[`path`,{d:`m15 18-6-6 6-6`,key:`1wnfg3`}]]),i=n(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]);e();var a=t(),o=({currentPage:e,pageSize:t,totalElements:n,onPageChange:o,onPageSizeChange:s})=>{let c=Math.ceil(n/t),l=e*t+1,u=Math.min((e+1)*t,n);return n===0?null:(0,a.jsxs)(`div`,{className:`px-8 py-6 border-t border-slate-100 flex flex-col sm:flex-row items-center justify-between gap-6 bg-white shrink-0`,children:[(0,a.jsxs)(`div`,{className:`flex items-center gap-4`,children:[(0,a.jsx)(`span`,{className:`text-xs font-black text-slate-400 uppercase tracking-widest leading-none`,children:`Show`}),(0,a.jsx)(`select`,{value:t,onChange:e=>s(Number(e.target.value)),className:`bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-xs font-black text-slate-900 outline-none focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all appearance-none pr-8 relative`,style:{backgroundImage:`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,backgroundRepeat:`no-repeat`,backgroundPosition:`right 0.5rem center`,backgroundSize:`1rem`},children:[5,10,25,50].map(e=>(0,a.jsxs)(`option`,{value:e,children:[e,` items`]},e))}),(0,a.jsxs)(`span`,{className:`text-[10px] font-bold text-slate-300 uppercase tracking-widest whitespace-nowrap`,children:[l,`-`,u,` of `,n,` units`]})]}),(0,a.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,a.jsx)(`button`,{disabled:e===0,onClick:()=>o(e-1),className:`w-10 h-10 flex items-center justify-center rounded-xl border border-slate-100 text-slate-400 hover:text-primary hover:bg-primary/5 disabled:opacity-30 disabled:hover:bg-transparent transition-all`,children:(0,a.jsx)(r,{size:18})}),(0,a.jsx)(`div`,{className:`flex items-center gap-1`,children:Array.from({length:c},(t,n)=>(0,a.jsx)(`button`,{onClick:()=>o(n),className:`w-10 h-10 font-bold text-xs rounded-xl transition-all ${e===n?`bg-primary text-white shadow-lg shadow-primary/20`:`text-slate-400 hover:bg-slate-50`}`,children:n+1},n)).slice(Math.max(0,e-1),Math.min(c,e+2))}),(0,a.jsx)(`button`,{disabled:e>=c-1,onClick:()=>o(e+1),className:`w-10 h-10 flex items-center justify-center rounded-xl border border-slate-100 text-slate-400 hover:text-primary hover:bg-primary/5 disabled:opacity-30 disabled:hover:bg-transparent transition-all`,children:(0,a.jsx)(i,{size:18})})]})]})};export{o as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{s as e}from"./index-B-Yfb7Zz.js";var t=e(`minus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}]]);export{t};

View File

@@ -0,0 +1 @@
import{s as e}from"./index-B-Yfb7Zz.js";var t=e(`plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]);export{t};

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

View File

@@ -0,0 +1 @@
import{s as e}from"./index-B-Yfb7Zz.js";var t=e(`trash-2`,[[`path`,{d:`M10 11v6`,key:`nco0om`}],[`path`,{d:`M14 11v6`,key:`outv1u`}],[`path`,{d:`M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6`,key:`miytrc`}],[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2`,key:`e791ji`}]]);export{t};

View File

@@ -0,0 +1 @@
import{s as e}from"./index-B-Yfb7Zz.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),n=e(`pen`,[[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}]]),r=e(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]);export{n,t as r,r as t};

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Counter POS</title>
<script type="module" crossorigin src="/assets/index-C3UP9F-T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8tt0cn-G.css">
<script type="module" crossorigin src="/assets/index-B-Yfb7Zz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-HWj_Y8q4.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,35 +1,50 @@
import React from 'react';
import React, { lazy, Suspense } from 'react';
import { MotionConfig } from 'framer-motion';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import POS from './pages/POS';
import Orders from './pages/Orders';
import Categories from './pages/Categories';
import Products from './pages/Products';
import Layout from './components/Layout';
// Lazy load pages to decrease initial bundle size
const Login = lazy(() => import('./pages/Login'));
const POS = lazy(() => import('./pages/POS'));
const Orders = lazy(() => import('./pages/Orders'));
const Categories = lazy(() => import('./pages/Categories'));
const Products = lazy(() => import('./pages/Products'));
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isLoggedIn = sessionStorage.getItem('isCounterLoggedIn') === 'true';
return isLoggedIn ? <>{children}</> : <Navigate to="/login" replace />;
};
const LoadingFallback = () => (
<div className="min-h-screen flex items-center justify-center bg-[#f4fbf7]">
<div className="text-primary font-black uppercase tracking-widest text-sm animate-pulse">
Loading...
</div>
</div>
);
function App() {
return (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}>
<Route index element={<Navigate to="/pos" replace />} />
<Route path="pos" element={<POS />} />
<Route path="orders" element={<Orders />} />
<Route path="categories" element={<Categories />} />
<Route path="products" element={<Products />} />
</Route>
</Routes>
</Router>
<MotionConfig skipAnimations={true}>
<Router>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}>
<Route index element={<Navigate to="/pos" replace />} />
<Route path="pos" element={<POS />} />
<Route path="orders" element={<Orders />} />
<Route path="categories" element={<Categories />} />
<Route path="products" element={<Products />} />
</Route>
</Routes>
</Suspense>
</Router>
</MotionConfig>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -26,7 +26,7 @@ const Login = () => {
e.preventDefault();
try {
const host = window.location.hostname;
const response = await fetch(`https://cmsapi.rit-services.in/api/system/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,102 +1,115 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { MotionConfig } from 'framer-motion';
import Layout from './components/Layout.tsx';
import Dashboard from './pages/Dashboard.tsx';
import BaseMenu from './pages/BaseMenu.tsx';
import Products from './pages/Products.tsx';
import Orders from './pages/Orders.tsx';
import ArchivedOrders from './pages/ArchivedOrders.tsx';
import Customers from './pages/Customers.tsx';
import Login from './pages/Login.tsx';
import Managers from './pages/Managers.tsx';
import Stalls from './pages/Stalls.tsx';
import Staff from './pages/Staff.tsx';
import Terminals from './pages/Terminals.tsx';
import Vendors from './pages/Vendors.tsx';
import Purchases from './pages/Purchases.tsx';
import VendorDashboard from './pages/VendorDashboard.tsx';
import PurchaseAnalytics from './pages/PurchaseAnalytics.tsx';
import Bills from './pages/Bills.tsx';
import PurchaseSummary from './pages/PurchaseSummary.tsx';
import IntentDashboard from './pages/IntentDashboard.tsx';
import IntentList from './pages/IntentList.tsx';
import NewArrivals from './pages/NewArrivals.tsx';
import Reports from './pages/Reports.tsx';
import Feedback from './pages/Feedback.tsx';
import Ritz from './pages/Ritz.tsx';
import RitzCirculation from './pages/RitzCirculation.tsx';
import ManageWallets from './pages/ManageWallets.tsx';
import ManageCoupons from './pages/ManageCoupons.tsx';
import Settings from './pages/Settings.tsx';
import NotFound from './pages/NotFound.tsx';
// Lazy load pages to decrease initial bundle size
const Dashboard = lazy(() => import('./pages/Dashboard.tsx'));
const BaseMenu = lazy(() => import('./pages/BaseMenu.tsx'));
const Products = lazy(() => import('./pages/Products.tsx'));
const Orders = lazy(() => import('./pages/Orders.tsx'));
const ArchivedOrders = lazy(() => import('./pages/ArchivedOrders.tsx'));
const Customers = lazy(() => import('./pages/Customers.tsx'));
const Login = lazy(() => import('./pages/Login.tsx'));
const Managers = lazy(() => import('./pages/Managers.tsx'));
const Stalls = lazy(() => import('./pages/Stalls.tsx'));
const Staff = lazy(() => import('./pages/Staff.tsx'));
const Terminals = lazy(() => import('./pages/Terminals.tsx'));
const Vendors = lazy(() => import('./pages/Vendors.tsx'));
const Purchases = lazy(() => import('./pages/Purchases.tsx'));
const VendorDashboard = lazy(() => import('./pages/VendorDashboard.tsx'));
const PurchaseAnalytics = lazy(() => import('./pages/PurchaseAnalytics.tsx'));
const Bills = lazy(() => import('./pages/Bills.tsx'));
const PurchaseSummary = lazy(() => import('./pages/PurchaseSummary.tsx'));
const IntentDashboard = lazy(() => import('./pages/IntentDashboard.tsx'));
const IntentList = lazy(() => import('./pages/IntentList.tsx'));
const NewArrivals = lazy(() => import('./pages/NewArrivals.tsx'));
const Reports = lazy(() => import('./pages/Reports.tsx'));
const Feedback = lazy(() => import('./pages/Feedback.tsx'));
const Ritz = lazy(() => import('./pages/Ritz.tsx'));
const RitzCirculation = lazy(() => import('./pages/RitzCirculation.tsx'));
const ManageWallets = lazy(() => import('./pages/ManageWallets.tsx'));
const ManageCoupons = lazy(() => import('./pages/ManageCoupons.tsx'));
const Settings = lazy(() => import('./pages/Settings.tsx'));
const NotFound = lazy(() => import('./pages/NotFound.tsx'));
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
return isLoggedIn ? <>{children}</> : <Navigate to="/login" replace />;
};
const LoadingFallback = () => (
<div className="min-h-screen flex items-center justify-center bg-slate-50/50">
<div className="text-primary font-black uppercase tracking-widest text-sm animate-pulse">
Loading...
</div>
</div>
);
function App() {
return (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<MotionConfig skipAnimations={true}>
<Router>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
{/* Sale */}
<Route path="sale/orders" element={<Orders />} />
<Route path="sale/archived-bills" element={<ArchivedOrders />} />
{/* Sale */}
<Route path="sale/orders" element={<Orders />} />
<Route path="sale/archived-bills" element={<ArchivedOrders />} />
{/* Customers */}
<Route path="customers/orders" element={<Customers />} />
{/* Customers */}
<Route path="customers/orders" element={<Customers />} />
{/* Purchases */}
<Route path="purchases/dashboard" element={<VendorDashboard />} />
<Route path="purchases/orders" element={<Purchases />} />
<Route path="purchases/summary" element={<PurchaseSummary />} />
<Route path="purchases/bills" element={<Bills />} />
<Route path="purchases/vendor" element={<Vendors />} />
<Route path="purchases/analytics" element={<PurchaseAnalytics />} />
{/* Purchases */}
<Route path="purchases/dashboard" element={<VendorDashboard />} />
<Route path="purchases/orders" element={<Purchases />} />
<Route path="purchases/summary" element={<PurchaseSummary />} />
<Route path="purchases/bills" element={<Bills />} />
<Route path="purchases/vendor" element={<Vendors />} />
<Route path="purchases/analytics" element={<PurchaseAnalytics />} />
{/* Intent */}
<Route path="purchases/intent/orders-dashboard" element={<IntentDashboard title="ORDER DASHBOARD" />} />
<Route path="purchases/intent/receives-dashboard" element={<IntentDashboard title="RECEIVABLE DASHBOARD" />} />
<Route path="purchases/intent/orders" element={<IntentList title="ORDERS" />} />
<Route path="purchases/intent/receives" element={<IntentList title="RECEIVES" />} />
{/* Intent */}
<Route path="purchases/intent/orders-dashboard" element={<IntentDashboard title="ORDER DASHBOARD" />} />
<Route path="purchases/intent/receives-dashboard" element={<IntentDashboard title="RECEIVABLE DASHBOARD" />} />
<Route path="purchases/intent/orders" element={<IntentList title="ORDERS" />} />
<Route path="purchases/intent/receives" element={<IntentList title="RECEIVES" />} />
{/* Inventory */}
<Route path="inventory/new-arrivals" element={<NewArrivals />} />
<Route path="inventory/base" element={<BaseMenu />} />
<Route path="inventory/products" element={<Products />} />
<Route path="inventory/online" element={<Navigate to="/inventory/products" replace />} />
{/* Inventory */}
<Route path="inventory/new-arrivals" element={<NewArrivals />} />
<Route path="inventory/base" element={<BaseMenu />} />
<Route path="inventory/products" element={<Products />} />
<Route path="inventory/online" element={<Navigate to="/inventory/products" replace />} />
{/* Others */}
<Route path="reports" element={<Reports />} />
<Route path="feedback" element={<Feedback />} />
<Route path="ritz/overview" element={<Ritz />} />
<Route path="ritz/circulation" element={<RitzCirculation />} />
<Route path="ritz/wallets" element={<ManageWallets />} />
<Route path="ritz/coupons" element={<ManageCoupons />} />
{/* Others */}
<Route path="reports" element={<Reports />} />
<Route path="feedback" element={<Feedback />} />
<Route path="ritz/overview" element={<Ritz />} />
<Route path="ritz/circulation" element={<RitzCirculation />} />
<Route path="ritz/wallets" element={<ManageWallets />} />
<Route path="ritz/coupons" element={<ManageCoupons />} />
{/* Stores */}
<Route path="stores/terminals" element={<Terminals />} />
<Route path="stores/managers" element={<Managers />} />
<Route path="stores/staffs" element={<Staff />} />
<Route path="stores/stalls" element={<Stalls />} />
{/* Stores */}
<Route path="stores/terminals" element={<Terminals />} />
<Route path="stores/managers" element={<Managers />} />
<Route path="stores/staffs" element={<Staff />} />
<Route path="stores/stalls" element={<Stalls />} />
{/* Settings */}
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
{/* Settings */}
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</Router>
</MotionConfig>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -19,7 +19,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import collegeLogo from '../assets/college-logo.png';
import colorVector from '../assets/color-vector.png';
import colorVector from '../assets/color-vector.webp';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
User,
Lock,
@@ -11,10 +10,10 @@ import {
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
// Import Assets
// Import Assets (optimized WebP formats)
import collegeLogo from '../assets/college-logo.png';
import foodCourtPosBg from '../assets/food-court-pos.png';
import ritLogo from '../assets/RIT_Logo.png';
import foodCourtPosBg from '../assets/food-court-pos.webp';
import ritLogo from '../assets/RIT_Logo.webp';
const Login = () => {
const [role, setRole] = useState<'manager' | 'master'>('manager');
@@ -76,25 +75,19 @@ const Login = () => {
{/* Subtle light overlay to match the clean aesthetic and improve card contrast */}
<div className="absolute inset-0 bg-white/20 backdrop-blur-[1px] z-0"></div>
{/* Login Card */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
{/* Login Card (Static layout, instant render) */}
<div
className="w-full max-w-md bg-white/90 backdrop-blur-md border border-white/20 rounded-3xl p-6 md:p-10 shadow-2xl flex flex-col relative z-10"
>
{/* Top Logo - Centered */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
<div
className="flex flex-col items-center justify-center mb-8"
>
<img src={collegeLogo} alt="College Logo" className="h-16 md:h-20 object-contain filter drop-shadow-sm" />
<span className="text-xs font-semibold text-[#001828]/60 mt-2.5 italic tracking-wide font-sans">
Till yo tummy is full...
</span>
</motion.div>
</div>
{/* Login Container */}
<div className="w-full">
@@ -107,14 +100,13 @@ const Login = () => {
{/* Role Toggle */}
<div className="bg-slate-100/80 p-1 rounded-2xl flex gap-1 mb-6 relative">
<motion.div
className="absolute h-[calc(100%-8px)] top-1 bg-white rounded-xl shadow-sm z-0"
initial={false}
animate={{
{/* Lightweight CSS transition for slider */}
<div
className="absolute h-[calc(100%-8px)] top-1 bg-white rounded-xl shadow-sm z-0 transition-all duration-200 ease-out"
style={{
left: role === 'manager' ? '4px' : '50%',
width: 'calc(50% - 6px)'
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
<button
onClick={() => setRole('manager')}
@@ -194,11 +186,9 @@ const Login = () => {
>
<span className="relative z-10">CONNECT TO PORTAL</span>
<ArrowRight size={18} className="relative z-10 group-hover:translate-x-1 transition-transform" />
<motion.div
className="absolute inset-0 bg-white/10"
initial={{ x: '-100%' }}
whileHover={{ x: '100%' }}
transition={{ duration: 0.6 }}
{/* Lightweight CSS transition for button hover shine */}
<div
className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-500 ease-out z-0"
/>
</button>
</form>
@@ -210,20 +200,17 @@ const Login = () => {
© 2026 TILLO SYSTEM PORTAL
</p>
</div>
</motion.div>
</div>
{/* Merchant Section Card */}
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
{/* Merchant Section Card (Static rendering) */}
<div
className="w-full max-w-md bg-white/80 backdrop-blur-md border border-white/20 rounded-2xl p-4 shadow-lg flex items-center justify-between gap-4 relative z-10"
>
<div className="flex flex-col">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Merchant Partner</span>
</div>
<img src={ritLogo} alt="RIT Logo" className="h-12 w-auto object-contain filter drop-shadow-sm" />
</motion.div>
</div>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { apiFetch } from '../api';
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react';
import {
Building2,
@@ -14,7 +14,7 @@ import {
Fingerprint
} from 'lucide-react';
import { motion } from 'framer-motion';
import colorVector from '../assets/color-vector.png';
import colorVector from '../assets/color-vector.webp';
interface TokenTransaction {
id: number;

View File

@@ -1,4 +1,4 @@
import { apiFetch } from '../api';
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react';
import {
CircleDollarSign,
@@ -15,7 +15,7 @@ import {
ChevronRight
} from 'lucide-react';
import { motion } from 'framer-motion';
import colorVector from '../assets/color-vector.png';
import colorVector from '../assets/color-vector.webp';
interface TokenUnit {
id: number;

View File

@@ -1,110 +1,124 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { MotionConfig } from 'framer-motion';
import { CartProvider } from './contexts/CartContext';
import { AuthProvider } from './contexts/AuthContext';
import { FoodProvider } from './contexts/FoodContext';
import { ThemeProvider } from './contexts/ThemeContext';
import ProtectedRoute from './components/ProtectedRoute';
import HomeScreen from './pages/HomeScreen';
import CategoryScreen from './pages/CategoryScreen';
import ItemDetailScreen from './pages/ItemDetailScreen';
import CartScreen from './pages/CartScreen';
import CheckoutScreen from './pages/CheckoutScreen';
import SuccessScreen from './pages/SuccessScreen';
import MyOrdersScreen from './pages/MyOrdersScreen';
import LoginScreen from './pages/LoginScreen';
import ProfileScreen from './pages/ProfileScreen';
import ChangePinScreen from './pages/ChangePinScreen';
import StallDetailScreen from './pages/StallDetailScreen';
import WalletScreen from './pages/WalletScreen';
import TopUpScreen from './pages/TopUpScreen';
import PreferencesScreen from './pages/PreferencesScreen';
import NotFoundScreen from './pages/NotFoundScreen';
import StockAlert from './components/StockAlert';
import './App.css';
// Lazy load screens
const HomeScreen = lazy(() => import('./pages/HomeScreen'));
const CategoryScreen = lazy(() => import('./pages/CategoryScreen'));
const ItemDetailScreen = lazy(() => import('./pages/ItemDetailScreen'));
const CartScreen = lazy(() => import('./pages/CartScreen'));
const CheckoutScreen = lazy(() => import('./pages/CheckoutScreen'));
const SuccessScreen = lazy(() => import('./pages/SuccessScreen'));
const MyOrdersScreen = lazy(() => import('./pages/MyOrdersScreen'));
const LoginScreen = lazy(() => import('./pages/LoginScreen'));
const ProfileScreen = lazy(() => import('./pages/ProfileScreen'));
const ChangePinScreen = lazy(() => import('./pages/ChangePinScreen'));
const StallDetailScreen = lazy(() => import('./pages/StallDetailScreen'));
const WalletScreen = lazy(() => import('./pages/WalletScreen'));
const TopUpScreen = lazy(() => import('./pages/TopUpScreen'));
const PreferencesScreen = lazy(() => import('./pages/PreferencesScreen'));
const NotFoundScreen = lazy(() => import('./pages/NotFoundScreen'));
const LoadingFallback = () => (
<div className="container" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<div className="loading-spinner">Loading...</div>
</div>
);
function App() {
return (
<ThemeProvider>
<AuthProvider>
<FoodProvider>
<CartProvider>
<StockAlert />
<Router>
<Routes>
<Route path="/login" element={<LoginScreen />} />
<StockAlert />
<MotionConfig skipAnimations={true}>
<Router>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/login" element={<LoginScreen />} />
<Route path="/" element={
<ProtectedRoute>
<HomeScreen />
</ProtectedRoute>
} />
<Route path="/orders" element={
<ProtectedRoute>
<MyOrdersScreen />
</ProtectedRoute>
} />
<Route path="/category/:categoryId" element={
<ProtectedRoute>
<CategoryScreen />
</ProtectedRoute>
} />
<Route path="/item/:itemId" element={
<ProtectedRoute>
<ItemDetailScreen />
</ProtectedRoute>
} />
<Route path="/stall/:id" element={
<ProtectedRoute>
<StallDetailScreen />
</ProtectedRoute>
} />
<Route path="/" element={
<ProtectedRoute>
<HomeScreen />
</ProtectedRoute>
} />
<Route path="/orders" element={
<ProtectedRoute>
<MyOrdersScreen />
</ProtectedRoute>
} />
<Route path="/category/:categoryId" element={
<ProtectedRoute>
<CategoryScreen />
</ProtectedRoute>
} />
<Route path="/item/:itemId" element={
<ProtectedRoute>
<ItemDetailScreen />
</ProtectedRoute>
} />
<Route path="/stall/:id" element={
<ProtectedRoute>
<StallDetailScreen />
</ProtectedRoute>
} />
<Route path="/cart" element={
<ProtectedRoute>
<CartScreen />
</ProtectedRoute>
} />
<Route path="/checkout" element={
<ProtectedRoute>
<CheckoutScreen />
</ProtectedRoute>
} />
<Route path="/success" element={
<ProtectedRoute>
<SuccessScreen />
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<ProfileScreen />
</ProtectedRoute>
} />
<Route path="/change-pin" element={
<ProtectedRoute>
<ChangePinScreen />
</ProtectedRoute>
} />
<Route path="/wallet" element={
<ProtectedRoute>
<WalletScreen />
</ProtectedRoute>
} />
<Route path="/topup" element={
<ProtectedRoute>
<TopUpScreen />
</ProtectedRoute>
} />
<Route path="/preferences" element={
<ProtectedRoute>
<PreferencesScreen />
</ProtectedRoute>
} />
<Route path="*" element={<NotFoundScreen />} />
</Routes>
</Router>
</CartProvider>
</FoodProvider>
</AuthProvider>
<Route path="/cart" element={
<ProtectedRoute>
<CartScreen />
</ProtectedRoute>
} />
<Route path="/checkout" element={
<ProtectedRoute>
<CheckoutScreen />
</ProtectedRoute>
} />
<Route path="/success" element={
<ProtectedRoute>
<SuccessScreen />
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<ProfileScreen />
</ProtectedRoute>
} />
<Route path="/change-pin" element={
<ProtectedRoute>
<ChangePinScreen />
</ProtectedRoute>
} />
<Route path="/wallet" element={
<ProtectedRoute>
<WalletScreen />
</ProtectedRoute>
} />
<Route path="/topup" element={
<ProtectedRoute>
<TopUpScreen />
</ProtectedRoute>
} />
<Route path="/preferences" element={
<ProtectedRoute>
<PreferencesScreen />
</ProtectedRoute>
} />
<Route path="*" element={<NotFoundScreen />} />
</Routes>
</Suspense>
</Router>
</MotionConfig>
</CartProvider>
</FoodProvider>
</AuthProvider>
</ThemeProvider>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,68 @@
import React, { useEffect } from 'react';
import { AlertCircle, X } from 'lucide-react';
import { useCart } from '../contexts/CartContext';
import { motion, AnimatePresence } from 'framer-motion';
const StockAlert: React.FC = () => {
const { stockError, clearStockError } = useCart();
useEffect(() => {
if (stockError) {
const timer = setTimeout(() => {
clearStockError();
}, 3000);
return () => clearTimeout(timer);
}
}, [stockError, clearStockError]);
return (
<AnimatePresence>
{stockError && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 16, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
style={{
position: 'fixed',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
width: 'calc(100% - 32px)',
maxWidth: '400px',
backgroundColor: '#FF4D4F',
color: '#ffffff',
padding: '12px 16px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(255, 77, 79, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<AlertCircle size={20} style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>{stockError}</span>
</div>
<button
onClick={clearStockError}
style={{
border: 'none',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
padding: '4px',
borderRadius: '50%',
backgroundColor: 'rgba(255, 255, 255, 0.15)'
}}
>
<X size={14} />
</button>
</motion.div>
)}
</AnimatePresence>
);
};
export default StockAlert;

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { AlertTriangle, Trash2, Edit2, X } from 'lucide-react';
import { motion } from 'framer-motion';
interface StockConflict {
productId: number;
productName: string;
requestedQty: number;
availableQty: number;
}
interface StockConflictModalProps {
conflicts: StockConflict[];
onRemoveItem: (productId: number) => void;
onAdjustQuantity: (productId: number, newQty: number) => void;
onClose: () => void;
}
const StockConflictModal: React.FC<StockConflictModalProps> = ({
conflicts,
onRemoveItem,
onAdjustQuantity,
onClose,
}) => {
return (
<div style={{
position: 'fixed',
inset: 0,
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
pointerEvents: 'auto'
}}>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(4px)'
}}
/>
{/* Modal Container */}
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
style={{
position: 'relative',
width: '100%',
maxWidth: '450px',
backgroundColor: 'var(--surface)',
borderRadius: '24px',
boxShadow: 'var(--shadow)',
border: '1px solid var(--border)',
overflow: 'hidden',
padding: '24px'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#ff4d4f' }}>
<AlertTriangle size={24} />
<h3 style={{ fontSize: '1.2rem', fontWeight: 800 }}>Stock Issues Detected</h3>
</div>
<button onClick={onClose} style={{ cursor: 'pointer', border: 'none', background: 'none', color: 'var(--text-light)' }}>
<X size={20} />
</button>
</div>
<p style={{ color: 'var(--text-mid)', fontSize: '0.9rem', marginBottom: '20px' }}>
Some items in your cart do not have enough stock available to complete the order:
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginBottom: '24px' }}>
{conflicts.map((conflict) => (
<div
key={conflict.productId}
style={{
padding: '16px',
borderRadius: '16px',
backgroundColor: 'var(--input-bg)',
border: '1px solid var(--border)'
}}
>
<div style={{ fontWeight: 700, fontSize: '0.95rem', color: 'var(--text-dark)', marginBottom: '8px' }}>
{conflict.productName}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', color: 'var(--text-light)', marginBottom: '16px' }}>
<span>Requested: {conflict.requestedQty}</span>
<span style={{ color: '#ff4d4f', fontWeight: 600 }}>{conflict.availableQty > 0 ? 'In Stock (Limited)' : 'Out of Stock'}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
{conflict.availableQty > 0 ? (
<button
onClick={() => onAdjustQuantity(conflict.productId, conflict.availableQty)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px',
borderRadius: '10px',
backgroundColor: 'var(--primary-light)',
color: 'var(--primary)',
border: 'none',
fontWeight: 700,
fontSize: '0.85rem',
cursor: 'pointer'
}}
>
<Edit2 size={14} />
<span>Reduce Quantity</span>
</button>
) : (
<div style={{ color: '#ff4d4f', fontSize: '0.85rem', fontWeight: 700, display: 'flex', alignItems: 'center' }}>
Out of Stock
</div>
)}
<button
onClick={() => onRemoveItem(conflict.productId)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px',
borderRadius: '10px',
backgroundColor: '#FFF1F2',
color: '#FF4D4F',
border: 'none',
fontWeight: 700,
fontSize: '0.85rem',
cursor: 'pointer'
}}
>
<Trash2 size={14} />
<span>Remove Item</span>
</button>
</div>
</div>
))}
</div>
<button
onClick={onClose}
style={{
width: '100%',
padding: '14px',
borderRadius: '12px',
backgroundColor: 'var(--primary)',
color: '#ffffff',
border: 'none',
fontWeight: 800,
fontSize: '0.95rem',
cursor: 'pointer'
}}
>
Return to Cart
</button>
</motion.div>
</div>
);
};
export default StockConflictModal;

View File

@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light' || savedTheme === 'dark') {
return savedTheme;
}
return 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setThemeState((prev) => (prev === 'light' ? 'dark' : 'light'));
};
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@@ -0,0 +1,16 @@
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
const firebaseConfig = {
apiKey: "AIzaSyDOEiKtH-gs2nAx8Di45wJf8CY5nPm4xPE",
authDomain: "tillo-c8de9.firebaseapp.com",
projectId: "tillo-c8de9",
storageBucket: "tillo-c8de9.firebasestorage.app",
messagingSenderId: "200333262782",
appId: "1:200333262782:web:0d988352ee6a44d7a943ef",
measurementId: "G-YB01EQT2VW"
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();

View File

@@ -8,7 +8,7 @@ import {
PlusCircle,
CreditCard
} from 'lucide-react';
import tokenImage from '../assets/display_ritz.png';
import tokenImage from '../assets/display_ritz.webp';
import { motion, AnimatePresence } from 'framer-motion';
import Header from '../components/Header';
import StockConflictModal from '../components/StockConflictModal';
@@ -16,6 +16,7 @@ import { useCart } from '../contexts/CartContext';
import { useAuth } from '../contexts/AuthContext';
import { useFood } from '../contexts/FoodContext';
import { payAndVerify, authHeaders } from '../utils/razorpay';
import { getApiBaseUrl } from '../utils/api';
import './CheckoutScreen.css';
type PaymentMethod = 'RITZ_TOKEN' | 'RAZORPAY';
@@ -50,7 +51,7 @@ const CheckoutScreen: React.FC = () => {
const fetchBalance = async () => {
if (!user) return;
try {
const response = await fetch(`http://${window.location.hostname}:8080/api/wallet/balance/${user.id}`, {
const response = await fetch(`${getApiBaseUrl()}/api/wallet/balance/${user.id}`, {
headers: authHeaders()
});
const data = await response.json();
@@ -101,7 +102,7 @@ const CheckoutScreen: React.FC = () => {
user: { id: user.id }
};
const response = await fetch(`http://${window.location.hostname}:8080/api/orders`, {
const response = await fetch(`${getApiBaseUrl()}/api/orders`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(orderData),

View File

@@ -9,7 +9,8 @@ import FeedbackModal from '../components/FeedbackModal';
import { useFood } from '../contexts/FoodContext';
import { useAuth } from '../contexts/AuthContext';
import { motion, AnimatePresence } from 'framer-motion';
import walletIcon from '../assets/front.png';
import walletIcon from '../assets/front.webp';
import { getApiBaseUrl } from '../utils/api';
import './HomeScreen.css';
const HomeScreen: React.FC = () => {
@@ -36,7 +37,7 @@ const HomeScreen: React.FC = () => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/latest-unrated/${user?.id}`, {
const response = await fetch(`${getApiBaseUrl()}/api/feedback/latest-unrated/${user?.id}`, {
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
@@ -59,7 +60,7 @@ const HomeScreen: React.FC = () => {
try {
const token = localStorage.getItem('token');
await fetch(`http://${window.location.hostname}:8080/api/feedback/skip/${unratedOrder.id}`, {
await fetch(`${getApiBaseUrl()}/api/feedback/skip/${unratedOrder.id}`, {
method: 'POST',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
@@ -80,7 +81,7 @@ const HomeScreen: React.FC = () => {
const handleFeedbackSubmit = async (feedbackData: any) => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/submit`, {
const response = await fetch(`${getApiBaseUrl()}/api/feedback/submit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -146,7 +147,7 @@ const HomeScreen: React.FC = () => {
const fetchSearchResults = async () => {
setIsSearching(true);
try {
const response = await fetch(`http://${window.location.hostname}:8080/api/products?search=${encodeURIComponent(debouncedSearch)}&size=100`);
const response = await fetch(`${getApiBaseUrl()}/api/products?search=${encodeURIComponent(debouncedSearch)}&size=100`);
if (response.ok) {
const data = await response.json();
const products = data.content || data || [];

View File

@@ -8,6 +8,7 @@ import { useFood } from '../contexts/FoodContext';
import CartTab from '../components/CartTab';
import { VegNonVegIcon } from '../components/ItemCard';
import './ItemDetailScreen.css';
import { getApiBaseUrl } from '../utils/api';
const ItemDetailScreen: React.FC = () => {
const { itemId } = useParams<{ itemId: string }>();
@@ -32,7 +33,7 @@ const ItemDetailScreen: React.FC = () => {
const fetchItem = async () => {
setIsFetching(true);
try {
const response = await fetch(`http://${window.location.hostname}:8080/api/products/${itemId}`);
const response = await fetch(`${getApiBaseUrl()}/api/products/${itemId}`);
if (response.ok) {
const data = await response.json();
@@ -47,7 +48,7 @@ const ItemDetailScreen: React.FC = () => {
let finalRatingCount = 0;
try {
const token = localStorage.getItem('token');
const statsRes = await fetch(`http://${window.location.hostname}:8080/api/feedback/item-stats?productName=${encodeURIComponent(data.name)}`, {
const statsRes = await fetch(`${getApiBaseUrl()}/api/feedback/item-stats?productName=${encodeURIComponent(data.name)}`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
});
if (statsRes.ok) {

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Lock } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import tilloLogo from '../assets/Tillo.png';
import ritLogo from '../assets/RIT_Logo.png';
import ritLogo from '../assets/RIT_Logo.webp';
import { auth, googleProvider } from '../firebase';
import { signInWithPopup } from 'firebase/auth';
import './LoginScreen.css';

View File

@@ -10,6 +10,7 @@ import { useFood } from '../contexts/FoodContext';
import { useCart } from '../contexts/CartContext';
import type { FoodItem } from '../types';
import './MyOrdersScreen.css';
import { getApiBaseUrl } from '../utils/api';
interface Order {
id: number;
@@ -82,7 +83,7 @@ const MyOrdersScreen: React.FC = () => {
const fetchOrders = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/user/${user?.id}`, {
const response = await fetch(`${getApiBaseUrl()}/api/orders/user/${user?.id}`, {
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
@@ -143,7 +144,7 @@ const MyOrdersScreen: React.FC = () => {
setIsCancelling(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/cancel`, {
const response = await fetch(`${getApiBaseUrl()}/api/orders/${orderId}/cancel`, {
method: 'POST',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, Home } from 'lucide-react';
const NotFoundScreen: React.FC = () => {
const navigate = useNavigate();
return (
<div className="container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', padding: '20px', textAlign: 'center' }}>
<div style={{ width: '80px', height: '80px', borderRadius: '50%', backgroundColor: 'var(--primary-light)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--primary)', marginBottom: '24px' }}>
<AlertTriangle size={40} />
</div>
<h1 style={{ fontSize: '2rem', fontWeight: 900, color: 'var(--text-dark)', marginBottom: '12px' }}>
Page Not Found
</h1>
<p style={{ color: 'var(--text-light)', fontSize: '0.95rem', maxWidth: '320px', marginBottom: '32px' }}>
The page you are looking for doesn't exist or has been moved to another URL.
</p>
<button
onClick={() => navigate('/')}
style={{
backgroundColor: 'var(--primary)',
color: '#ffffff',
padding: '12px 24px',
borderRadius: '12px',
fontWeight: 700,
fontSize: '0.95rem',
display: 'flex',
alignItems: 'center',
gap: '8px',
border: 'none',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(35, 22, 81, 0.15)'
}}
>
<Home size={18} />
<span>Go to Home</span>
</button>
</div>
);
};
export default NotFoundScreen;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
import { useTheme } from '../contexts/ThemeContext';
import { Moon, Sun, Globe, ChevronRight } from 'lucide-react';
const PreferencesScreen: React.FC = () => {
const { theme, toggleTheme } = useTheme();
return (
<div className="container preferences-page">
<Header title="Preferences" showCart={false} />
<main className="safe-area-bottom">
<div style={{ padding: '20px' }}>
<div
className="preference-item"
onClick={toggleTheme}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
backgroundColor: 'var(--surface)',
borderRadius: '12px',
marginBottom: '16px',
cursor: 'pointer',
boxShadow: 'var(--shadow)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{theme === 'dark' ? <Moon size={20} color="var(--primary)" /> : <Sun size={20} color="var(--primary)" />}
<div>
<div style={{ fontWeight: 600, color: 'var(--text-dark)' }}>Dark Mode</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-light)' }}>
{theme === 'dark' ? 'Enabled' : 'Disabled'}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--text-light)', marginRight: '8px' }}>
{theme === 'dark' ? 'On' : 'Off'}
</span>
<ChevronRight size={18} color="var(--text-light)" />
</div>
</div>
<div
className="preference-item"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
backgroundColor: 'var(--surface)',
borderRadius: '12px',
marginBottom: '16px',
boxShadow: 'var(--shadow)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Globe size={20} color="var(--primary)" />
<div>
<div style={{ fontWeight: 600, color: 'var(--text-dark)' }}>Language</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-light)' }}>Change application language</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '0.9rem', color: 'var(--text-light)', marginRight: '8px' }}>English</span>
<ChevronRight size={18} color="var(--text-light)" />
</div>
</div>
</div>
</main>
<BottomNav />
</div>
);
};
export default PreferencesScreen;

View File

@@ -6,6 +6,7 @@ import CartTab from '../components/CartTab';
import BottomNav from '../components/BottomNav';
import type { Stall } from '../types';
import './StallDetailScreen.css';
import { getApiBaseUrl } from '../utils/api';
const StallDetailScreen: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -18,7 +19,7 @@ const StallDetailScreen: React.FC = () => {
const fetchStallDetails = async () => {
setLoading(true);
try {
const response = await fetch(`http://${window.location.hostname}:8080/api/stalls/${id}`);
const response = await fetch(`${getApiBaseUrl()}/api/stalls/${id}`);
if (!response.ok) throw new Error('Stall not found');
const data = await response.json();
@@ -61,8 +62,7 @@ const StallDetailScreen: React.FC = () => {
fetchStallDetails();
// SSE Real-time stock updates
const host = window.location.hostname;
const eventSource = new EventSource(`http://${host}:8080/api/stock/stream`);
const eventSource = new EventSource(`${getApiBaseUrl()}/api/stock/stream`);
eventSource.addEventListener('stockUpdate', (event: any) => {
try {

View File

@@ -0,0 +1,358 @@
/* ═══════════════════════════════════════════════
TopUp Screen — Premium Razorpay Recharge UI
═══════════════════════════════════════════════ */
.topup-page main {
padding-bottom: 40px;
}
/* ── Back Button ─────────────────────────────── */
.topup-back-btn {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-mid);
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 20px;
border: none;
background: none;
cursor: pointer;
padding: 4px 0;
transition: color 0.2s;
}
.topup-back-btn:hover {
color: var(--text-dark);
}
/* ── Balance Card ────────────────────────────── */
.topup-balance-card {
background: linear-gradient(135deg, #001828 0%, #4a3e7a 100%);
color: #ffffff;
padding: 24px;
border-radius: 20px;
margin-bottom: 20px;
position: relative;
overflow: hidden;
box-shadow: 0 8px 32px rgba(35, 22, 81, 0.18);
}
.topup-balance-card::before {
content: '';
position: absolute;
top: -30px;
right: -30px;
width: 100px;
height: 100px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
}
.topup-balance-card::after {
content: '';
position: absolute;
bottom: -20px;
left: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
}
.balance-label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.8;
margin-bottom: 8px;
}
.balance-amount {
font-size: 2rem;
font-weight: 900;
letter-spacing: -0.5px;
}
/* ── Razorpay Info Banner ────────────────────── */
.razorpay-info-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(var(--primary-rgb), 0.08);
border: 1px solid rgba(var(--primary-rgb), 0.2);
margin-bottom: 20px;
}
.razorpay-info-banner .banner-icon {
color: var(--primary);
flex-shrink: 0;
}
.razorpay-info-banner .banner-text {
font-size: 0.85rem;
color: var(--text-mid);
line-height: 1.4;
}
.razorpay-info-banner .banner-text strong {
color: var(--text-dark);
display: block;
margin-bottom: 2px;
}
/* ── Form Styles ─────────────────────────────── */
.topup-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.topup-form label {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-mid);
margin-bottom: 8px;
display: block;
}
.amount-input-wrapper {
position: relative;
}
.amount-input-wrapper .currency-symbol {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
font-size: 1.5rem;
font-weight: 800;
color: var(--primary);
pointer-events: none;
}
.amount-input-wrapper input {
width: 100%;
padding: 16px 16px 16px 40px;
font-size: 1.5rem;
font-weight: 800;
border-radius: 16px;
border: 2px solid var(--border);
background-color: var(--surface);
color: var(--text-dark);
outline: none;
transition: border-color 0.25s, box-shadow 0.25s;
font-family: inherit;
}
.amount-input-wrapper input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(var(--primary-rgb), 0.1);
}
.amount-input-wrapper input::placeholder {
color: var(--text-light);
font-weight: 600;
}
/* ── Quick Presets ────────────────────────────── */
.presets-label {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-light);
margin-bottom: 10px;
display: block;
}
.presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.preset-btn {
padding: 12px 6px;
border-radius: 12px;
border: 1.5px solid var(--border);
background-color: var(--surface);
color: var(--text-dark);
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
font-family: inherit;
}
.preset-btn:hover {
border-color: var(--primary);
transform: translateY(-1px);
}
.preset-btn.active {
background-color: var(--primary);
color: #ffffff;
border-color: var(--primary);
transform: scale(1.03);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25);
}
/* ── Error Message ───────────────────────────── */
.topup-error {
color: #FF4D4F;
background-color: #FFF1F2;
padding: 12px 16px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid #fecaca;
display: flex;
align-items: center;
gap: 8px;
animation: topup-error-shake 0.35s ease;
}
@keyframes topup-error-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(2px); }
}
/* ── Pay Button ──────────────────────────────── */
.topup-submit-btn {
background-color: var(--primary);
color: #ffffff;
padding: 16px;
border-radius: 16px;
font-weight: 800;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
margin-top: 10px;
font-family: inherit;
position: relative;
overflow: hidden;
}
.topup-submit-btn:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(var(--primary-rgb), 0.3);
}
.topup-submit-btn:not(:disabled):active {
transform: scale(0.98);
}
.topup-submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.topup-submit-btn.processing {
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 70%, #000));
}
/* ── Shimmer animation for processing state ── */
.topup-submit-btn.processing::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
/* ── Security Footer ─────────────────────────── */
.topup-security-footer {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-light);
font-size: 0.8rem;
justify-content: center;
margin-top: 24px;
}
/* ── Success Overlay ─────────────────────────── */
.topup-success-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
text-align: center;
}
.topup-success-icon {
animation: topup-success-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
margin-bottom: 24px;
}
@keyframes topup-success-pop {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
.topup-success-title {
font-size: 1.5rem;
font-weight: 800;
color: var(--text-dark);
margin-bottom: 12px;
}
.topup-success-amount {
color: var(--text-light);
font-size: 0.95rem;
margin-bottom: 24px;
}
.topup-success-redirect {
color: var(--primary);
font-weight: 600;
font-size: 0.85rem;
animation: topup-pulse 1.5s ease-in-out infinite;
}
@keyframes topup-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── Conversion hint ─────────────────────────── */
.conversion-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--text-light);
margin-top: 4px;
padding-left: 2px;
}
.conversion-hint .equals-sign {
color: var(--primary);
font-weight: 700;
}

View File

@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Wallet, ShieldCheck, CheckCircle, CreditCard, Zap } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import Header from '../components/Header';
import { payAndVerify } from '../utils/razorpay';
import './TopUpScreen.css';
const TopUpScreen: React.FC = () => {
const navigate = useNavigate();
const { user, refreshUser } = useAuth();
const [amount, setAmount] = useState<string>('');
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [creditedAmount, setCreditedAmount] = useState<number>(0);
const presets = [50, 100, 200, 500];
const handleTopUp = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount < 50) {
setError('Minimum top up amount is ₹50.');
return;
}
if (parsedAmount > 5000) {
setError('Maximum top up limit per transaction is ₹5,000.');
return;
}
if (!Number.isInteger(parsedAmount)) {
setError('Please enter a whole number amount (no paise).');
return;
}
setIsProcessing(true);
setError(null);
try {
const result = await payAndVerify({
purpose: 'WALLET_TOPUP',
amount: parsedAmount
});
if (result.success && (result.type === 'WALLET_TOPUP' || result.purpose === 'WALLET_TOPUP')) {
setCreditedAmount(parsedAmount);
setSuccess(true);
await refreshUser();
setTimeout(() => {
navigate('/wallet');
}, 2200);
} else {
setError(result.message || 'Payment succeeded but wallet was not credited. Contact support with your payment ID.');
}
} catch (err: any) {
console.error('Top up error:', err);
if (err?.message === 'Payment cancelled') {
setError('Payment was cancelled. No tokens were added.');
} else {
setError(err?.message || 'Connection error. Please try again.');
}
} finally {
setIsProcessing(false);
}
};
if (success) {
return (
<div className="container topup-page">
<div className="topup-success-overlay">
<div className="topup-success-icon">
<CheckCircle size={64} color="var(--primary)" />
</div>
<h2 className="topup-success-title">
Top Up Successful!
</h2>
<p className="topup-success-amount">
🅡{creditedAmount.toLocaleString()} Ritz tokens have been added to your wallet.
</p>
<div className="topup-success-redirect">
Redirecting to your wallet...
</div>
</div>
</div>
);
}
return (
<div className="container topup-page">
<Header title="Top Up Tokens" showCart={false} />
<main style={{ padding: '20px' }}>
<button
onClick={() => navigate(-1)}
className="topup-back-btn"
>
<ArrowLeft size={16} />
<span>Back</span>
</button>
{/* Balance Card */}
<div className="topup-balance-card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<div className="balance-label">Current Balance</div>
<Wallet size={20} style={{ opacity: 0.7 }} />
</div>
<div className="balance-amount">🅡{(user?.ritzTokenBalance || 0).toLocaleString()}</div>
</div>
{/* Razorpay Info */}
<div className="razorpay-info-banner">
<CreditCard size={20} className="banner-icon" />
<div className="banner-text">
<strong>Pay securely with Razorpay</strong>
UPI, cards &amp; netbanking. Tokens are credited only after payment succeeds.
</div>
</div>
<form onSubmit={handleTopUp} className="topup-form">
<div>
<label>Enter Top Up Amount ()</label>
<div className="amount-input-wrapper">
<span className="currency-symbol"></span>
<input
type="number"
value={amount}
onChange={(e) => {
setAmount(e.target.value);
setError(null);
}}
placeholder="0"
min="50"
max="5000"
step="1"
required
/>
</div>
{amount && Number(amount) > 0 && (
<div className="conversion-hint">
<Zap size={12} />
<span>{Number(amount).toLocaleString()}</span>
<span className="equals-sign">=</span>
<span>🅡{Number(amount).toLocaleString()} Ritz Tokens</span>
</div>
)}
</div>
<div>
<span className="presets-label">Quick Add Presets</span>
<div className="presets-grid">
{presets.map((preset) => (
<button
key={preset}
type="button"
onClick={() => {
setAmount(preset.toString());
setError(null);
}}
className={`preset-btn ${amount === preset.toString() ? 'active' : ''}`}
>
+{preset}
</button>
))}
</div>
</div>
{error && (
<div className="topup-error">
{error}
</div>
)}
<button
type="submit"
disabled={isProcessing || !amount}
className={`topup-submit-btn ${isProcessing ? 'processing' : ''}`}
>
{isProcessing ? 'Processing Payment...' : `Pay ₹${amount || '0'} & Top Up`}
</button>
</form>
<div className="topup-security-footer">
<ShieldCheck size={14} />
<span>Secured by Razorpay · 1 Ritz Token = 1.00</span>
</div>
</main>
</div>
);
};
export default TopUpScreen;

View File

@@ -0,0 +1,322 @@
/* ═══════════════════════════════════════════════
Wallet Screen — Premium Ritz Wallet UI
═══════════════════════════════════════════════ */
.wallet-page main {
padding: 20px;
padding-bottom: 100px;
}
/* ── Gradient Balance Card ───────────────────── */
.wallet-balance-card {
background: linear-gradient(135deg, #001828 0%, #4a3e7a 100%);
color: #ffffff;
padding: 24px;
borderRadius: 24px;
box-shadow: 0 8px 32px rgba(35, 22, 81, 0.15);
margin-bottom: 24px;
position: relative;
overflow: hidden;
border-radius: 24px;
}
.wallet-balance-card::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 130px;
height: 130px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
pointer-events: none;
}
.wallet-balance-card::after {
content: '';
position: absolute;
bottom: -30px;
left: 30px;
width: 90px;
height: 90px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.03);
pointer-events: none;
}
.wallet-balance-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.wallet-balance-label {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.8;
}
.wallet-balance-icon {
opacity: 0.8;
}
.wallet-balance-display {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.wallet-token-image {
width: 40px;
height: 40px;
object-fit: contain;
}
.wallet-balance-value {
font-size: 2.5rem;
font-weight: 900;
letter-spacing: -1px;
}
/* ── Top Up Button (inside balance card) ─────── */
.wallet-topup-btn {
width: 100%;
background-color: #ffffff;
color: #001828;
padding: 12px;
border-radius: 12px;
font-weight: 700;
font-size: 0.95rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
font-family: inherit;
}
.wallet-topup-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.wallet-topup-btn:active {
transform: scale(0.98);
}
/* ── Coupon Card ─────────────────────────────── */
.wallet-coupon-card {
background-color: var(--surface);
padding: 20px;
border-radius: 24px;
box-shadow: var(--shadow);
margin-bottom: 24px;
border: 1px solid var(--border);
}
.wallet-coupon-card h4 {
font-size: 0.95rem;
font-weight: 800;
color: var(--text-dark);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wallet-coupon-card p {
font-size: 0.8rem;
color: var(--text-light);
margin-bottom: 16px;
}
.coupon-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.coupon-input-row {
display: flex;
gap: 8px;
}
.coupon-input {
flex: 1;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background-color: var(--input-bg);
color: var(--text-dark);
font-weight: 600;
font-size: 0.9rem;
outline: none;
text-transform: uppercase;
font-family: inherit;
transition: border-color 0.2s;
}
.coupon-input:focus {
border-color: var(--primary);
}
.coupon-apply-btn {
background-color: var(--primary);
color: #ffffff;
padding: 0 20px;
border-radius: 12px;
font-weight: 700;
font-size: 0.9rem;
border: none;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
font-family: inherit;
}
.coupon-apply-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.coupon-apply-btn:not(:disabled):active {
transform: scale(0.96);
}
.coupon-status {
padding: 10px 14px;
border-radius: 10px;
font-size: 0.8rem;
font-weight: 600;
animation: coupon-status-slide 0.3s ease;
}
.coupon-status.success {
background-color: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.coupon-status.error {
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
@keyframes coupon-status-slide {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Transactions Section ────────────────────── */
.wallet-transactions-header {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-dark);
margin-bottom: 16px;
}
.wallet-transactions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.wallet-tx-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: var(--surface);
border-radius: 16px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: transform 0.15s;
}
.wallet-tx-card:hover {
transform: translateY(-1px);
}
.wallet-tx-left {
display: flex;
align-items: center;
gap: 12px;
}
.wallet-tx-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.wallet-tx-icon.credit {
background-color: #E6FFFA;
color: #00B894;
}
.wallet-tx-icon.debit {
background-color: #FFF1F2;
color: #FF4D4F;
}
.wallet-tx-description {
font-weight: 700;
color: var(--text-dark);
font-size: 0.95rem;
}
.wallet-tx-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--text-light);
margin-top: 2px;
}
.wallet-tx-ref {
opacity: 0.7;
}
.wallet-tx-amount {
font-weight: 800;
font-size: 1.05rem;
white-space: nowrap;
}
.wallet-tx-amount.credit {
color: #00B894;
}
.wallet-tx-amount.debit {
color: #FF4D4F;
}
.wallet-empty-state {
text-align: center;
padding: 40px 20px;
background-color: var(--surface);
border-radius: 16px;
color: var(--text-light);
box-shadow: var(--shadow);
}
.wallet-loading-state {
text-align: center;
padding: 40px;
color: var(--text-light);
}

View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wallet, PlusCircle, ArrowUpRight, ArrowDownLeft, Calendar } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { authHeaders } from '../utils/razorpay';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
import tokenImage from '../assets/display_ritz.webp';
import './WalletScreen.css';
import { getApiBaseUrl } from '../utils/api';
interface Transaction {
id: number;
amount: number;
type: 'TOPUP' | 'SPEND' | 'REFUND';
description: string;
timestamp: string;
referenceId: string;
}
const WalletScreen: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [balance, setBalance] = useState<number>(user?.ritzTokenBalance || 0);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Coupon States
const [couponCode, setCouponCode] = useState('');
const [redeemStatus, setRedeemStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
const [isRedeeming, setIsRedeeming] = useState(false);
useEffect(() => {
if (user) {
fetchWalletData();
}
}, [user]);
const fetchWalletData = async () => {
if (!user) return;
try {
setIsLoading(true);
// Fetch balance
const balanceRes = await fetch(`${getApiBaseUrl()}/api/wallet/balance/${user.id}`, {
headers: authHeaders()
});
if (balanceRes.ok) {
const balanceData = await balanceRes.json();
setBalance(balanceData.balance || 0);
}
// Fetch transactions
const txRes = await fetch(`${getApiBaseUrl()}/api/wallet/transactions/${user.id}`, {
headers: authHeaders()
});
if (txRes.ok) {
const txData = await txRes.json();
setTransactions(txData || []);
}
} catch (error) {
console.error('Error fetching wallet data:', error);
} finally {
setIsLoading(false);
}
};
const handleRedeemCoupon = async (e: React.FormEvent) => {
e.preventDefault();
if (!couponCode.trim()) return;
try {
setIsRedeeming(true);
setRedeemStatus({ type: null, message: '' });
const response = await fetch(`${getApiBaseUrl()}/api/coupons/redeem`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
code: couponCode.trim(),
userId: user?.id
})
});
const data = await response.json();
if (response.ok && data.success) {
setRedeemStatus({
type: 'success',
message: data.message || `Successfully redeemed coupon code!`
});
setCouponCode('');
// Refresh balance and transactions
fetchWalletData();
} else {
setRedeemStatus({
type: 'error',
message: data.message || 'Failed to redeem coupon.'
});
}
} catch (error) {
console.error('Error redeeming coupon:', error);
setRedeemStatus({
type: 'error',
message: 'Something went wrong. Please try again.'
});
} finally {
setIsRedeeming(false);
}
};
return (
<div className="container wallet-page">
<Header title="My Wallet" showCart={false} />
<main className="safe-area-bottom" style={{ padding: '20px' }}>
{/* Balance Card */}
<div className="wallet-balance-card">
<div className="wallet-balance-header">
<span className="wallet-balance-label">Ritz Token Balance</span>
<Wallet size={20} className="wallet-balance-icon" />
</div>
<div className="wallet-balance-display">
<img src={tokenImage} alt="Ritz Token" className="wallet-token-image" />
<span className="wallet-balance-value">
🅡{balance.toLocaleString()}
</span>
</div>
<button
onClick={() => navigate('/topup')}
className="wallet-topup-btn"
>
<PlusCircle size={18} />
<span>Top Up Tokens via Razorpay</span>
</button>
</div>
{/* Redeem Coupon Card */}
<div className="wallet-coupon-card">
<h4>Redeem Coupon Code</h4>
<p>Claim promo codes or reward vouchers to instantly credit tokens to your wallet.</p>
<form onSubmit={handleRedeemCoupon} className="coupon-form">
<div className="coupon-input-row">
<input
type="text"
placeholder="Enter Coupon Code"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
disabled={isRedeeming}
className="coupon-input"
/>
<button
type="submit"
disabled={isRedeeming || !couponCode.trim()}
className="coupon-apply-btn"
>
{isRedeeming ? 'Redeeming...' : 'Apply'}
</button>
</div>
{redeemStatus.type && (
<div className={`coupon-status ${redeemStatus.type}`}>
{redeemStatus.message}
</div>
)}
</form>
</div>
{/* Transactions Section */}
<div>
<h3 className="wallet-transactions-header">
Transaction History
</h3>
{isLoading ? (
<div className="wallet-loading-state">
Loading transactions...
</div>
) : transactions.length === 0 ? (
<div className="wallet-empty-state">
No transactions yet. Top up your wallet to get started!
</div>
) : (
<div className="wallet-transactions-list">
{transactions.map((tx) => {
const isCredit = tx.type === 'TOPUP' || tx.type === 'REFUND';
return (
<div key={tx.id} className="wallet-tx-card">
<div className="wallet-tx-left">
<div className={`wallet-tx-icon ${isCredit ? 'credit' : 'debit'}`}>
{isCredit ? <ArrowDownLeft size={20} /> : <ArrowUpRight size={20} />}
</div>
<div>
<div className="wallet-tx-description">
{tx.description || (tx.type === 'TOPUP' ? 'Wallet Top Up' : tx.type === 'REFUND' ? 'Refund' : 'Order Payment')}
</div>
<div className="wallet-tx-meta">
<Calendar size={12} />
<span>{new Date(tx.timestamp).toLocaleString()}</span>
{tx.referenceId && <span className="wallet-tx-ref"> {tx.referenceId}</span>}
</div>
</div>
</div>
<div className={`wallet-tx-amount ${isCredit ? 'credit' : 'debit'}`}>
{isCredit ? '+' : '-'}🅡{tx.amount.toLocaleString()}
</div>
</div>
);
})}
</div>
)}
</div>
</main>
<BottomNav />
</div>
);
};
export default WalletScreen;

View File

@@ -0,0 +1,6 @@
export const getApiBaseUrl = (): string => {
if (window.location.protocol === 'https:') {
return 'https://cmsapi.rit-services.in';
}
return `http://${window.location.hostname}:8080`;
};

View File

@@ -0,0 +1,194 @@
/**
* Razorpay Checkout helpers for the ordering site.
* Secret key never touches the browser — only key_id + order_id from the backend.
*/
export type RazorpayPrefill = {
name?: string;
email?: string;
contact?: string;
};
export type CreatePaymentResponse = {
success: boolean;
paymentSessionId: number;
keyId: string;
razorpayOrderId: string;
amount: number; // paise
amountInr: number;
currency: string;
purpose: string;
name: string;
description: string;
prefill?: RazorpayPrefill;
notes?: Record<string, string>;
message?: string;
errorType?: string;
conflicts?: unknown[];
};
export type VerifyPaymentResponse = {
success: boolean;
type?: string;
purpose?: string;
status?: string;
orderNumber?: string;
displayOrderId?: string;
newBalance?: number;
message?: string;
creditedAmount?: number;
razorpayPaymentId?: string;
};
export type RazorpaySuccessResponse = {
razorpay_order_id: string;
razorpay_payment_id: string;
razorpay_signature: string;
};
declare global {
interface Window {
Razorpay?: new (options: Record<string, unknown>) => {
open: () => void;
on: (event: string, handler: (response: unknown) => void) => void;
};
}
}
import { getApiBaseUrl } from './api';
const API_HOST = () => getApiBaseUrl();
export function authHeaders(): HeadersInit {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
let razorpayScriptPromise: Promise<void> | null = null;
export function loadRazorpayScript(): Promise<void> {
if (window.Razorpay) return Promise.resolve();
if (razorpayScriptPromise) return razorpayScriptPromise;
razorpayScriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector('script[data-razorpay="true"]');
if (existing) {
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () => reject(new Error('Failed to load Razorpay SDK')));
return;
}
const script = document.createElement('script');
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
script.async = true;
script.dataset.razorpay = 'true';
script.onload = () => resolve();
script.onerror = () => {
razorpayScriptPromise = null;
reject(new Error('Failed to load Razorpay Checkout. Check your network connection.'));
};
document.body.appendChild(script);
});
return razorpayScriptPromise;
}
export async function createPayment(body: Record<string, unknown>): Promise<CreatePaymentResponse> {
const response = await fetch(`${API_HOST()}/api/payments/create`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok && data.success !== false) {
throw new Error(data.message || 'Failed to create payment');
}
return data;
}
export async function verifyPayment(payload: {
razorpayOrderId: string;
razorpayPaymentId: string;
razorpaySignature: string;
}): Promise<VerifyPaymentResponse> {
const response = await fetch(`${API_HOST()}/api/payments/verify`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Payment verification failed');
}
return data;
}
/**
* Opens Razorpay Checkout and resolves only after the user completes payment.
* Does NOT place the order — caller must call verifyPayment next.
*/
export async function openRazorpayCheckout(
checkout: CreatePaymentResponse
): Promise<RazorpaySuccessResponse> {
await loadRazorpayScript();
const RazorpayCtor = window.Razorpay;
if (!RazorpayCtor) {
throw new Error('Razorpay SDK is not available');
}
return new Promise((resolve, reject) => {
const options: Record<string, unknown> = {
key: checkout.keyId,
amount: checkout.amount,
currency: checkout.currency || 'INR',
name: checkout.name || 'Tillo Canteen',
description: checkout.description || 'Payment',
order_id: checkout.razorpayOrderId,
prefill: checkout.prefill || {},
notes: checkout.notes || {},
theme: { color: '#00B894' },
modal: {
ondismiss: () => {
reject(new Error('Payment cancelled'));
},
},
handler: (response: RazorpaySuccessResponse) => {
resolve(response);
},
};
const rzp = new RazorpayCtor(options);
rzp.on('payment.failed', (resp: unknown) => {
const err = resp as { error?: { description?: string; reason?: string } };
reject(new Error(err?.error?.description || err?.error?.reason || 'Payment failed'));
});
rzp.open();
});
}
/**
* Full pay → verify flow used by checkout and wallet top-up.
*/
export async function payAndVerify(
createBody: Record<string, unknown>
): Promise<VerifyPaymentResponse> {
const created = await createPayment(createBody);
if (!created.success) {
const err = new Error(created.message || 'Could not start payment') as Error & {
errorType?: string;
conflicts?: unknown[];
};
err.errorType = created.errorType;
err.conflicts = created.conflicts;
throw err;
}
const payment = await openRazorpayCheckout(created);
return verifyPayment({
razorpayOrderId: payment.razorpay_order_id,
razorpayPaymentId: payment.razorpay_payment_id,
razorpaySignature: payment.razorpay_signature,
});
}