Bug Fixes

This commit is contained in:
Sidharth Prabhu
2026-08-04 14:10:10 +05:30
parent d07ae358eb
commit f876fa9239
43 changed files with 1985 additions and 259 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

@@ -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};

View File

@@ -1,9 +0,0 @@
import{m as e,t,u as n}from"./index-Cw6Vy2ED.js";import{a as r,c as i,i as a,n as o,o as s,r as c,s as l}from"./proxy-D2DvKtgS.js";var u=t(),d=e(n(),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(a(t)&&e.isPresent&&!this.props.isPresent&&this.props.pop!==!1){let e=t.offsetParent,n=a(e)&&e.offsetWidth||0,r=a(e)&&e.offsetHeight||0,i=getComputedStyle(t),o=this.props.sizeRef.current;o.height=parseFloat(i.height),o.width=parseFloat(i.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),l=(0,d.useRef)({width:0,height:0,top:0,left:0,right:0,bottom:0}),{nonce:f}=(0,d.useContext)(c),p=m(s,e.props?.ref??e?.ref);return(0,d.useInsertionEffect)(()=>{let{width:e,height:c,top:u,left:d,right:p,bottom:m}=l.current;if(t||a===!1||!s.current||!e||!c)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: ${c}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:l,pop:a,children:a===!1?e:d.cloneElement(e,{ref:p})})}var _=({children:e,initial:t,isPresent:n,onExitComplete:i,custom:a,presenceAffectsLayout:o,mode:s,anchorX:c,anchorY:f,root:p})=>{let m=l(v),h=(0,d.useId)(),_=!0,y=(0,d.useMemo)(()=>(_=!1,{id:h,initial:t,isPresent:n,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))}),[n,m,i]);return o&&_&&(y={...y}),(0,d.useMemo)(()=>{m.forEach((e,t)=>m.set(t,!1))},[n]),d.useEffect(()=>{!n&&!m.size&&i&&i()},[n]),e=(0,u.jsx)(g,{pop:s===`popLayout`,isPresent:n,anchorX:c,anchorY:f,root:p,children:e}),(0,u.jsx)(r.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:e,custom:t,initial:n=!0,onExitComplete:r,presenceAffectsLayout:a=!0,mode:c=`sync`,propagate:f=!1,anchorX:p=`left`,anchorY:m=`top`,root:h})=>{let[g,v]=o(f),x=(0,d.useMemo)(()=>b(e),[e]),S=f&&!g?[]:x.map(y),C=(0,d.useRef)(!0),w=(0,d.useRef)(x),T=l(()=>new Map),E=(0,d.useRef)(new Set),[D,O]=(0,d.useState)(x),[k,A]=(0,d.useState)(x);s(()=>{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 c===`wait`&&j.length&&(e=j),A(b(e)),O(x),null}let{forceRender:M}=(0,d.useContext)(i);return(0,u.jsx)(u.Fragment,{children:k.map(e=>{let i=y(e),o=f&&!g?!1:x===k||S.includes(i);return(0,u.jsx)(_,{isPresent:o,initial:!C.current||n?void 0:!1,custom:t,presenceAffectsLayout:a,mode:c,root:h,onExitComplete:o?void 0:()=>{if(E.current.has(i))return;if(T.has(i))E.current.add(i),T.set(i,!0);else return;let e=!0;T.forEach(t=>{t||(e=!1)}),e&&(M?.(),A(w.current),f&&v?.(),r&&r())},anchorX:p,anchorY:m,children:e},i)})})};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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{c as e,t,u as n}from"./index-Cw6Vy2ED.js";var r=e(`chevron-left`,[[`path`,{d:`m15 18-6-6 6-6`,key:`1wnfg3`}]]),i=e(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]);n();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};

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

View File

@@ -1 +0,0 @@
import{c as e}from"./index-Cw6Vy2ED.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(`minus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}]]);export{t};

View File

@@ -1 +0,0 @@
import{c as e}from"./index-Cw6Vy2ED.js";var t=e(`plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]);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

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{c as e}from"./index-Cw6Vy2ED.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};
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

@@ -1 +1 @@
import{c as e}from"./index-Cw6Vy2ED.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};
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,7 +5,7 @@
<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-Cw6Vy2ED.js"></script>
<script type="module" crossorigin src="/assets/index-B-Yfb7Zz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-HWj_Y8q4.css">
</head>
<body>

View File

@@ -1,4 +1,5 @@
import React, { lazy, Suspense } from 'react';
import { MotionConfig } from 'framer-motion';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
@@ -24,24 +25,26 @@ const LoadingFallback = () => (
function App() {
return (
<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 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>
);
}

View File

@@ -1,5 +1,6 @@
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';
// Lazy load pages to decrease initial bundle size
@@ -47,66 +48,68 @@ const LoadingFallback = () => (
function App() {
return (
<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 />} />
<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>
</Suspense>
</Router>
{/* Settings */}
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</Router>
</MotionConfig>
);
}

View File

@@ -1,5 +1,6 @@
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';
@@ -38,81 +39,83 @@ function App() {
<FoodProvider>
<CartProvider>
<StockAlert />
<Router>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/login" element={<LoginScreen />} />
<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>
</Suspense>
</Router>
<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>

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

@@ -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

@@ -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,221 @@
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';
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(`http://${window.location.hostname}:8080/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(`http://${window.location.hostname}:8080/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(`http://${window.location.hostname}:8080/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,192 @@
/**
* 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;
};
}
}
const API_HOST = () => `http://${window.location.hostname}:8080`;
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,
});
}