Google Sign in added
This commit is contained in:
1026
api-dashboard/package-lock.json
generated
1026
api-dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"firebase": "^12.16.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import logo from './assets/logo.png';
|
import logo from './assets/logo.png';
|
||||||
|
import { auth, googleProvider } from './firebase';
|
||||||
|
import { signInWithPopup } from 'firebase/auth';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
interface DeveloperApiKey {
|
interface DeveloperApiKey {
|
||||||
@@ -32,11 +34,15 @@ function App() {
|
|||||||
// Auth State
|
// Auth State
|
||||||
const [session, setSession] = useState<UserSession | null>(null);
|
const [session, setSession] = useState<UserSession | null>(null);
|
||||||
const [loginType, setLoginType] = useState<'CUSTOMER' | 'SYSTEM'>('SYSTEM');
|
const [loginType, setLoginType] = useState<'CUSTOMER' | 'SYSTEM'>('SYSTEM');
|
||||||
const [loginMobile, setLoginMobile] = useState('');
|
|
||||||
const [loginPin, setLoginPin] = useState('');
|
|
||||||
const [loginEmail, setLoginEmail] = useState('');
|
const [loginEmail, setLoginEmail] = useState('');
|
||||||
const [loginPassword, setLoginPassword] = useState('');
|
const [loginPassword, setLoginPassword] = useState('');
|
||||||
|
|
||||||
|
// First-time registration flow states (Student Google login)
|
||||||
|
const [showCustomerMobilePrompt, setShowCustomerMobilePrompt] = useState(false);
|
||||||
|
const [customerPendingEmail, setCustomerPendingEmail] = useState('');
|
||||||
|
const [customerPendingName, setCustomerPendingName] = useState('');
|
||||||
|
const [customerMobileNumber, setCustomerMobileNumber] = useState('');
|
||||||
|
|
||||||
// Key Management State
|
// Key Management State
|
||||||
const [keys, setKeys] = useState<DeveloperApiKey[]>([]);
|
const [keys, setKeys] = useState<DeveloperApiKey[]>([]);
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
@@ -102,13 +108,85 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCustomerGoogleSignIn = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
const result = await signInWithPopup(auth, googleProvider);
|
||||||
|
const user = result.user;
|
||||||
|
if (user.email) {
|
||||||
|
const checkRes = await fetch(`${BACKEND_URL}/api/auth/check-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: user.email }),
|
||||||
|
});
|
||||||
|
const checkData = await checkRes.json();
|
||||||
|
|
||||||
|
if (checkData.exists) {
|
||||||
|
await executeCustomerFirebaseLogin(user.email, user.displayName || "Google User");
|
||||||
|
} else {
|
||||||
|
setCustomerPendingEmail(user.email);
|
||||||
|
setCustomerPendingName(user.displayName || "Google User");
|
||||||
|
setShowCustomerMobilePrompt(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('No email found in Google Account.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotification('error', err.message || 'Failed to authenticate with Google');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeCustomerFirebaseLogin = async (email: string, name: string, mobileNumber?: string) => {
|
||||||
|
const loginRes = await fetch(`${BACKEND_URL}/api/auth/firebase-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, name, mobileNumber }),
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
if (!loginRes.ok || !loginData.success) {
|
||||||
|
throw new Error(loginData.message || 'Google Canteen Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData: UserSession = {
|
||||||
|
token: loginData.token,
|
||||||
|
id: loginData.user.id,
|
||||||
|
name: loginData.user.name || 'Customer',
|
||||||
|
emailOrMobile: loginData.user.mobileNumber,
|
||||||
|
roleOrType: 'CUSTOMER',
|
||||||
|
};
|
||||||
|
|
||||||
|
setSession(sessionData);
|
||||||
|
localStorage.setItem('developer_session', JSON.stringify(sessionData));
|
||||||
|
showNotification('success', `Welcome back, ${sessionData.name}!`);
|
||||||
|
setShowCustomerMobilePrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomerMobileSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!/^[0-9]{10}$/.test(customerMobileNumber)) {
|
||||||
|
showNotification('error', 'Please enter a valid 10-digit mobile number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
clearMessages();
|
||||||
|
try {
|
||||||
|
await executeCustomerFirebaseLogin(customerPendingEmail, customerPendingName, customerMobileNumber);
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotification('error', err.message || 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clearMessages();
|
clearMessages();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (loginType === 'SYSTEM') {
|
|
||||||
const res = await fetch(`${BACKEND_URL}/api/system/login`, {
|
const res = await fetch(`${BACKEND_URL}/api/system/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -129,29 +207,6 @@ function App() {
|
|||||||
setSession(sessionData);
|
setSession(sessionData);
|
||||||
localStorage.setItem('developer_session', JSON.stringify(sessionData));
|
localStorage.setItem('developer_session', JSON.stringify(sessionData));
|
||||||
showNotification('success', `Welcome back, ${data.name}!`);
|
showNotification('success', `Welcome back, ${data.name}!`);
|
||||||
} else {
|
|
||||||
// Customer login
|
|
||||||
const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ mobileNumber: loginMobile, pin: loginPin }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || !data.success) throw new Error(data.message || 'Customer Login failed');
|
|
||||||
|
|
||||||
const sessionData: UserSession = {
|
|
||||||
token: data.token,
|
|
||||||
id: data.user.id,
|
|
||||||
name: data.user.name || 'Customer',
|
|
||||||
emailOrMobile: data.user.mobileNumber,
|
|
||||||
roleOrType: 'CUSTOMER',
|
|
||||||
};
|
|
||||||
|
|
||||||
setSession(sessionData);
|
|
||||||
localStorage.setItem('developer_session', JSON.stringify(sessionData));
|
|
||||||
showNotification('success', `Welcome back, ${sessionData.name}!`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showNotification('error', err.message || 'An error occurred during authentication');
|
showNotification('error', err.message || 'An error occurred during authentication');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -517,13 +572,12 @@ function App() {
|
|||||||
className={loginType === 'CUSTOMER' ? 'active' : ''}
|
className={loginType === 'CUSTOMER' ? 'active' : ''}
|
||||||
onClick={() => { setLoginType('CUSTOMER'); clearMessages(); }}
|
onClick={() => { setLoginType('CUSTOMER'); clearMessages(); }}
|
||||||
>
|
>
|
||||||
Customer Portal
|
Student Portal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="login-form">
|
|
||||||
{loginType === 'SYSTEM' ? (
|
{loginType === 'SYSTEM' ? (
|
||||||
<>
|
<form onSubmit={handleLogin} className="login-form">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label htmlFor="username">Username</label>
|
<label htmlFor="username">Username</label>
|
||||||
<input
|
<input
|
||||||
@@ -546,48 +600,100 @@ function App() {
|
|||||||
onChange={(e) => setLoginPassword(e.target.value)}
|
onChange={(e) => setLoginPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="input-group">
|
|
||||||
<label htmlFor="mobile">10-Digit Mobile Number</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
id="mobile"
|
|
||||||
required
|
|
||||||
pattern="[0-9]{10}"
|
|
||||||
placeholder="9876543210"
|
|
||||||
value={loginMobile}
|
|
||||||
onChange={(e) => setLoginMobile(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="input-group">
|
|
||||||
<label htmlFor="pin">6-Digit Security PIN</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="pin"
|
|
||||||
required
|
|
||||||
pattern="[0-9]{6}"
|
|
||||||
placeholder="••••••"
|
|
||||||
value={loginPin}
|
|
||||||
maxLength={6}
|
|
||||||
onChange={(e) => setLoginPin(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||||
{loading ? 'Authenticating...' : 'Sign In to Dashboard'}
|
{loading ? 'Authenticating...' : 'Sign In to Dashboard'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="customer-login-container">
|
||||||
|
{showCustomerMobilePrompt ? (
|
||||||
|
<form onSubmit={handleCustomerMobileSubmit} className="login-form">
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="customerMobile">10-Digit Mobile Number</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="customerMobile"
|
||||||
|
required
|
||||||
|
pattern="[0-9]{10}"
|
||||||
|
placeholder="9876543210"
|
||||||
|
value={customerMobileNumber}
|
||||||
|
onChange={(e) => setCustomerMobileNumber(e.target.value.replace(/[^0-9]/g, '').slice(0, 10))}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-primary w-full" disabled={loading || customerMobileNumber.length !== 10}>
|
||||||
|
{loading ? 'Registering...' : 'Register & Enter Dashboard'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-revoke w-full"
|
||||||
|
onClick={() => { setShowCustomerMobilePrompt(false); clearMessages(); }}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ marginTop: '0.5rem', border: '1px solid var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="google-signin-wrapper" style={{ marginTop: '0.5rem', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="google-login-btn"
|
||||||
|
onClick={handleCustomerGoogleSignIn}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
border: '1px solid #cbd5e1',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#1e293b',
|
||||||
|
fontWeight: '700',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span>Connecting...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="google-icon" viewBox="0 0 24 24" width="20" height="20">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Sign in with Google</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="demo-credentials-box">
|
<div className="demo-credentials-box">
|
||||||
<h4>💡 Dev Sandbox Access:</h4>
|
<h4>💡 Dev Sandbox Access:</h4>
|
||||||
{loginType === 'SYSTEM' ? (
|
{loginType === 'SYSTEM' ? (
|
||||||
<p>Default Admin: <strong>admin</strong> / Password: <strong>admin123</strong></p>
|
<p>Default Admin: <strong>admin</strong> / Password: <strong>admin123</strong></p>
|
||||||
) : (
|
) : (
|
||||||
<p>Create / use any customer login registered on the POS client. (e.g. mobile <strong>9876543210</strong>, pin <strong>123456</strong>)</p>
|
<p>Sign in with your Google account. First-time users will be asked for a mobile number to complete registration.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
api-dashboard/src/firebase.ts
Normal file
16
api-dashboard/src/firebase.ts
Normal 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();
|
||||||
@@ -44,6 +44,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/auth/firebase-login").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/auth/check-email").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
||||||
|
|
||||||
// ── PUBLIC: Real-time stock updates (SSE — read-only, ordering app listens) ──
|
// ── PUBLIC: Real-time stock updates (SSE — read-only, ordering app listens) ──
|
||||||
|
|||||||
@@ -126,6 +126,40 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/check-email")
|
||||||
|
public ResponseEntity<?> checkEmailExists(@RequestBody Map<String, String> body) {
|
||||||
|
String email = body.get("email");
|
||||||
|
if (email == null || email.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Email is required"));
|
||||||
|
}
|
||||||
|
boolean exists = userService.existsByEmail(email);
|
||||||
|
return ResponseEntity.ok(Map.of("exists", exists));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/firebase-login")
|
||||||
|
public ResponseEntity<?> firebaseLogin(@RequestBody Map<String, String> body) {
|
||||||
|
String email = body.get("email");
|
||||||
|
String name = body.get("name");
|
||||||
|
String mobileNumber = body.get("mobileNumber"); // optional, supplied on first login
|
||||||
|
|
||||||
|
if (email == null || email.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Email is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginResponse response = userService.firebaseLoginOrCreate(email, name, mobileNumber);
|
||||||
|
if (response.isSuccess()) {
|
||||||
|
Long userId = response.getUser() != null ? response.getUser().getId() : null;
|
||||||
|
String userMobile = response.getUser() != null ? response.getUser().getMobileNumber() : "";
|
||||||
|
if (userId != null) {
|
||||||
|
String token = jwtUtil.generateUserToken(userId, userMobile);
|
||||||
|
response.setToken(token);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.badRequest().body(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
|
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
|
||||||
LoginResponse response = userService.logout(request.getMobileNumber());
|
LoginResponse response = userService.logout(request.getMobileNumber());
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public class LoginResponse {
|
|||||||
private boolean isLoggedIn;
|
private boolean isLoggedIn;
|
||||||
private boolean isSuspended;
|
private boolean isSuspended;
|
||||||
private java.math.BigDecimal ritzTokenBalance;
|
private java.math.BigDecimal ritzTokenBalance;
|
||||||
|
private String membership;
|
||||||
|
|
||||||
public UserDto() {}
|
public UserDto() {}
|
||||||
|
|
||||||
@@ -61,6 +62,16 @@ public class LoginResponse {
|
|||||||
this.ritzTokenBalance = ritzTokenBalance;
|
this.ritzTokenBalance = ritzTokenBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UserDto(Long id, String mobileNumber, String name, boolean isLoggedIn, boolean isSuspended, java.math.BigDecimal ritzTokenBalance, String membership) {
|
||||||
|
this.id = id;
|
||||||
|
this.mobileNumber = mobileNumber;
|
||||||
|
this.name = name;
|
||||||
|
this.isLoggedIn = isLoggedIn;
|
||||||
|
this.isSuspended = isSuspended;
|
||||||
|
this.ritzTokenBalance = ritzTokenBalance;
|
||||||
|
this.membership = membership;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
public void setId(Long id) { this.id = id; }
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
@@ -74,9 +85,12 @@ public class LoginResponse {
|
|||||||
public void setLoggedIn(boolean loggedIn) { this.isLoggedIn = loggedIn; }
|
public void setLoggedIn(boolean loggedIn) { this.isLoggedIn = loggedIn; }
|
||||||
|
|
||||||
public boolean isSuspended() { return isSuspended; }
|
public boolean isSuspended() { return isSuspended; }
|
||||||
public void setSuspended(boolean suspended) { isSuspended = suspended; }
|
public void setSuspended(boolean suspended) { this.isSuspended = suspended; }
|
||||||
|
|
||||||
public java.math.BigDecimal getRitzTokenBalance() { return ritzTokenBalance; }
|
public java.math.BigDecimal getRitzTokenBalance() { return ritzTokenBalance; }
|
||||||
public void setRitzTokenBalance(java.math.BigDecimal ritzTokenBalance) { this.ritzTokenBalance = ritzTokenBalance; }
|
public void setRitzTokenBalance(java.math.BigDecimal ritzTokenBalance) { this.ritzTokenBalance = ritzTokenBalance; }
|
||||||
|
|
||||||
|
public String getMembership() { return membership; }
|
||||||
|
public void setMembership(String membership) { this.membership = membership; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ public class User {
|
|||||||
@Column(nullable = false, columnDefinition = "boolean default false")
|
@Column(nullable = false, columnDefinition = "boolean default false")
|
||||||
private boolean isSuspended = false;
|
private boolean isSuspended = false;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = true)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
private String membership;
|
||||||
|
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
private LocalDateTime lastLoginAt;
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
@@ -75,6 +81,12 @@ public class User {
|
|||||||
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
|
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
|
||||||
public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; }
|
public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; }
|
||||||
|
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
|
||||||
|
public String getMembership() { return membership; }
|
||||||
|
public void setMembership(String membership) { this.membership = membership; }
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import java.util.Optional;
|
|||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
Optional<User> findByMobileNumber(String mobileNumber);
|
Optional<User> findByMobileNumber(String mobileNumber);
|
||||||
boolean existsByMobileNumber(String mobileNumber);
|
boolean existsByMobileNumber(String mobileNumber);
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
@Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')) " +
|
@Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')) " +
|
||||||
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
|
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public class UserService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
||||||
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()
|
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership()
|
||||||
);
|
);
|
||||||
|
|
||||||
return new LoginResponse(true, "Registration successful. You are now logged in.", userDto);
|
return new LoginResponse(true, "Registration successful. You are now logged in.", userDto);
|
||||||
@@ -81,7 +81,7 @@ public class UserService {
|
|||||||
|
|
||||||
if (user.isSuspended()) {
|
if (user.isSuspended()) {
|
||||||
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
||||||
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()
|
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership()
|
||||||
);
|
);
|
||||||
return new LoginResponse(false, "Your account has been suspended. Please contact the administrator.", userDto);
|
return new LoginResponse(false, "Your account has been suspended. Please contact the administrator.", userDto);
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ public class UserService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
||||||
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()
|
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership()
|
||||||
);
|
);
|
||||||
|
|
||||||
return new LoginResponse(true, "Login successful.", userDto);
|
return new LoginResponse(true, "Login successful.", userDto);
|
||||||
@@ -161,7 +161,7 @@ public class UserService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
User user = userOpt.get();
|
User user = userOpt.get();
|
||||||
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
|
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,7 +181,8 @@ public class UserService {
|
|||||||
user.getName(),
|
user.getName(),
|
||||||
user.isLoggedIn(),
|
user.isLoggedIn(),
|
||||||
user.isSuspended(),
|
user.isSuspended(),
|
||||||
user.getRitzTokenBalance()
|
user.getRitzTokenBalance(),
|
||||||
|
user.getMembership()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +221,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
|
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,7 +242,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
|
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,5 +265,60 @@ public class UserService {
|
|||||||
public void deleteUser(Long userId) {
|
public void deleteUser(Long userId) {
|
||||||
userRepository.deleteById(userId);
|
userRepository.deleteById(userId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate or create a customer user via Firebase/Google login.
|
||||||
|
*/
|
||||||
|
public boolean existsByEmail(String email) {
|
||||||
|
if (email == null) return false;
|
||||||
|
return userRepository.existsByEmail(email.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoginResponse firebaseLoginOrCreate(String email, String name, String mobileNumber) {
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
return new LoginResponse(false, "Email is required for Google authentication.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String cleanEmail = email.trim().toLowerCase();
|
||||||
|
String finalName = name != null ? name : "Google User";
|
||||||
|
String membership = "Member"; // Everyone is classified as a normal Member now
|
||||||
|
|
||||||
|
Optional<User> userOpt = userRepository.findByEmail(cleanEmail);
|
||||||
|
User user;
|
||||||
|
|
||||||
|
if (userOpt.isPresent()) {
|
||||||
|
user = userOpt.get();
|
||||||
|
if (user.isSuspended()) {
|
||||||
|
return new LoginResponse(false, "Your account has been suspended. Please contact the administrator.");
|
||||||
|
}
|
||||||
|
user.setName(finalName);
|
||||||
|
user.setMembership(membership);
|
||||||
|
user.setLoggedIn(true);
|
||||||
|
user.setLastLoginAt(LocalDateTime.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
} else {
|
||||||
|
if (mobileNumber == null || !mobileNumber.matches("^[0-9]{10}$")) {
|
||||||
|
return new LoginResponse(false, "A valid 10-digit mobile number is required for first-time registration.");
|
||||||
|
}
|
||||||
|
if (userRepository.existsByMobileNumber(mobileNumber)) {
|
||||||
|
return new LoginResponse(false, "This mobile number is already registered to another account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
user = new User();
|
||||||
|
user.setEmail(cleanEmail);
|
||||||
|
user.setName(finalName);
|
||||||
|
user.setMembership(membership);
|
||||||
|
user.setMobileNumber(mobileNumber);
|
||||||
|
user.setPinHash(passwordEncoder.encode(java.util.UUID.randomUUID().toString()));
|
||||||
|
user.setLoggedIn(true);
|
||||||
|
user.setLastLoginAt(LocalDateTime.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
|
||||||
|
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance(), user.getMembership()
|
||||||
|
);
|
||||||
|
|
||||||
|
return new LoginResponse(true, "Login successful.", userDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
992
ordering_site/package-lock.json
generated
992
ordering_site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"firebase": "^12.16.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ interface AuthContextType {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
checkUserExists: (mobileNumber: string) => Promise<{ success: boolean; userExists: boolean; message: string }>;
|
checkUserExists: (mobileNumber: string) => Promise<{ success: boolean; userExists: boolean; message: string }>;
|
||||||
|
checkEmailExists: (email: string) => Promise<{ exists: boolean }>;
|
||||||
login: (mobileNumber: string, pin: string) => Promise<{ success: boolean; message: string; isSuspended?: boolean }>;
|
login: (mobileNumber: string, pin: string) => Promise<{ success: boolean; message: string; isSuspended?: boolean }>;
|
||||||
register: (mobileNumber: string, name: string, pin: string) => Promise<{ success: boolean; message: string }>;
|
register: (mobileNumber: string, name: string, pin: string) => Promise<{ success: boolean; message: string }>;
|
||||||
|
loginWithGoogle: (email: string, name: string, mobileNumber?: string) => Promise<{ success: boolean; message: string; isSuspended?: boolean }>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
changePin: (currentPin: string, newPin: string) => Promise<{ success: boolean; message: string }>;
|
changePin: (currentPin: string, newPin: string) => Promise<{ success: boolean; message: string }>;
|
||||||
updateProfile: (name: string, mobileNumber: string) => Promise<{ success: boolean; message: string }>;
|
updateProfile: (name: string, mobileNumber: string) => Promise<{ success: boolean; message: string }>;
|
||||||
@@ -79,6 +81,50 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkEmailExists = async (email: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/check-email`, { cache: 'no-store',
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginWithGoogle = async (email: string, name: string, mobileNumber?: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/firebase-login`, { cache: 'no-store',
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, name, mobileNumber }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.user) {
|
||||||
|
const mappedUser: User = {
|
||||||
|
id: data.user.id,
|
||||||
|
name: data.user.name,
|
||||||
|
mobileNumber: data.user.mobileNumber,
|
||||||
|
isLoggedIn: data.user.isLoggedIn || data.user.loggedIn || true,
|
||||||
|
isSuspended: data.user.isSuspended || data.user.suspended || false,
|
||||||
|
ritzTokenBalance: data.user.ritzTokenBalance,
|
||||||
|
membership: data.user.membership
|
||||||
|
};
|
||||||
|
setUser(mappedUser);
|
||||||
|
localStorage.setItem('user', JSON.stringify(mappedUser));
|
||||||
|
if (data.token) {
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
}
|
||||||
|
return { success: true, message: data.message };
|
||||||
|
}
|
||||||
|
return { success: false, message: data.message || 'Google Login failed', isSuspended: data.suspended || data.isSuspended };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: 'Network error. Please try again.' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
@@ -136,7 +182,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
mobileNumber: updatedUserDto.mobileNumber,
|
mobileNumber: updatedUserDto.mobileNumber,
|
||||||
isLoggedIn: updatedUserDto.loggedIn,
|
isLoggedIn: updatedUserDto.loggedIn,
|
||||||
isSuspended: updatedUserDto.suspended,
|
isSuspended: updatedUserDto.suspended,
|
||||||
ritzTokenBalance: updatedUserDto.ritzTokenBalance
|
ritzTokenBalance: updatedUserDto.ritzTokenBalance,
|
||||||
|
membership: updatedUserDto.membership
|
||||||
};
|
};
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||||
@@ -169,7 +216,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
mobileNumber: data.mobileNumber,
|
mobileNumber: data.mobileNumber,
|
||||||
isLoggedIn: data.isLoggedIn || data.loggedIn || true,
|
isLoggedIn: data.isLoggedIn || data.loggedIn || true,
|
||||||
isSuspended: data.isSuspended || data.suspended,
|
isSuspended: data.isSuspended || data.suspended,
|
||||||
ritzTokenBalance: data.ritzTokenBalance
|
ritzTokenBalance: data.ritzTokenBalance,
|
||||||
|
membership: data.membership
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update state and localStorage
|
// Update state and localStorage
|
||||||
@@ -218,7 +266,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isLoading, checkUserExists, login, register, logout, changePin, updateProfile, refreshUser }}>
|
<AuthContext.Provider value={{ user, isLoading, checkUserExists, checkEmailExists, login, register, loginWithGoogle, logout, changePin, updateProfile, refreshUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -429,3 +429,16 @@
|
|||||||
.feedback-snackbar-action {
|
.feedback-snackbar-action {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.guest-banner-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #ef6c37;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: rgba(239, 108, 55, 0.08);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(239, 108, 55, 0.15);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|||||||
@@ -442,3 +442,49 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.05));
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.google-signin-wrapper {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-login-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid rgba(0, 24, 40, 0.15);
|
||||||
|
border-radius: 18px;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
box-shadow: 0 4px 12px rgba(35, 22, 81, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-login-btn:hover:not(:disabled) {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border-color: rgba(35, 22, 81, 0.25);
|
||||||
|
box-shadow: 0 10px 20px rgba(35, 22, 81, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-login-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 4px 12px rgba(35, 22, 81, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-login-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
@@ -1,87 +1,85 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Phone, User as UserIcon, Lock, ChevronLeft } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import tilloLogo from '../assets/Tillo.png';
|
import tilloLogo from '../assets/Tillo.png';
|
||||||
import ritLogo from '../assets/RIT_Logo.png';
|
import ritLogo from '../assets/RIT_Logo.png';
|
||||||
|
import { auth, googleProvider } from '../firebase';
|
||||||
|
import { signInWithPopup } from 'firebase/auth';
|
||||||
import './LoginScreen.css';
|
import './LoginScreen.css';
|
||||||
|
|
||||||
const LoginScreen: React.FC = () => {
|
const LoginScreen: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { checkUserExists, login, register } = useAuth();
|
const { loginWithGoogle, checkEmailExists } = useAuth();
|
||||||
|
|
||||||
const [step, setStep] = useState<'mobile' | 'pin' | 'register'>('mobile');
|
|
||||||
const [mobileNumber, setMobileNumber] = useState('');
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [pin, setPin] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSuspended, setIsSuspended] = useState(false);
|
const [isSuspended, setIsSuspended] = useState(false);
|
||||||
|
|
||||||
|
// First-time registration flow states
|
||||||
|
const [showMobilePrompt, setShowMobilePrompt] = useState(false);
|
||||||
|
const [pendingEmail, setPendingEmail] = useState('');
|
||||||
|
const [pendingName, setPendingName] = useState('');
|
||||||
|
const [mobileNumber, setMobileNumber] = useState('');
|
||||||
|
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await signInWithPopup(auth, googleProvider);
|
||||||
|
const user = result.user;
|
||||||
|
if (user.email) {
|
||||||
|
// Check if user exists on backend
|
||||||
|
const checkResult = await checkEmailExists(user.email);
|
||||||
|
if (checkResult.exists) {
|
||||||
|
// Already registered, log them in directly
|
||||||
|
const backendResult = await loginWithGoogle(user.email, user.displayName || "Google User");
|
||||||
|
if (backendResult.success) {
|
||||||
|
navigate('/');
|
||||||
|
} else if (backendResult.isSuspended) {
|
||||||
|
setIsSuspended(true);
|
||||||
|
} else {
|
||||||
|
setError(backendResult.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First time sign-in, ask for mobile number
|
||||||
|
setPendingEmail(user.email);
|
||||||
|
setPendingName(user.displayName || "Google User");
|
||||||
|
setShowMobilePrompt(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('No email found in Google Account.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err.message || 'Failed to authenticate with Google');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMobileSubmit = async (e: React.FormEvent) => {
|
const handleMobileSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (mobileNumber.length !== 10) {
|
if (!/^[0-9]{10}$/.test(mobileNumber)) {
|
||||||
setError('Please enter a valid 10-digit mobile number');
|
setError('Please enter a valid 10-digit mobile number');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const result = await checkUserExists(mobileNumber);
|
try {
|
||||||
setIsLoading(false);
|
const backendResult = await loginWithGoogle(pendingEmail, pendingName, mobileNumber);
|
||||||
|
if (backendResult.success) {
|
||||||
if (result.success) {
|
|
||||||
if (result.userExists) {
|
|
||||||
setStep('pin');
|
|
||||||
} else {
|
|
||||||
setStep('register');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(result.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoginSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (pin.length !== 6) {
|
|
||||||
setError('PIN must be 6 digits');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await login(mobileNumber, pin);
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else if (result.isSuspended) {
|
} else if (backendResult.isSuspended) {
|
||||||
setIsSuspended(true);
|
setIsSuspended(true);
|
||||||
} else {
|
} else {
|
||||||
setError(result.message);
|
setError(backendResult.message);
|
||||||
}
|
}
|
||||||
};
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Registration failed');
|
||||||
const handleRegisterSubmit = async (e: React.FormEvent) => {
|
} finally {
|
||||||
e.preventDefault();
|
|
||||||
if (name.length < 2) {
|
|
||||||
setError('Please enter a valid name');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pin.length !== 6) {
|
|
||||||
setError('PIN must be 6 digits');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await register(mobileNumber, name, pin);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
navigate('/');
|
|
||||||
} else {
|
|
||||||
setError(result.message);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,121 +95,84 @@ const LoginScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-header">
|
<div className="login-header">
|
||||||
{step !== 'mobile' && (
|
|
||||||
<button
|
|
||||||
className="step-back-btn"
|
|
||||||
onClick={() => setStep('mobile')}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={20} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<h1 className="login-title">
|
<h1 className="login-title">
|
||||||
{step === 'mobile' ? 'Welcome!' : step === 'pin' ? 'Welcome Back' : 'Create Account'}
|
{showMobilePrompt ? 'Almost Done!' : 'Welcome to Tillo'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="login-subtitle">
|
<p className="login-subtitle">
|
||||||
{step === 'mobile'
|
{showMobilePrompt ? 'Enter your mobile number to complete registration' : 'Sign in to your canteen account using Google Auth'}
|
||||||
? 'Enter your mobile number to get started'
|
|
||||||
: step === 'pin'
|
|
||||||
? `Enter PIN for ${mobileNumber}`
|
|
||||||
: 'Enter your details to register'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
{step === 'mobile' && (
|
{showMobilePrompt ? (
|
||||||
<form className="login-form" onSubmit={handleMobileSubmit}>
|
<form onSubmit={handleMobileSubmit} className="login-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Mobile Number</label>
|
<label>Mobile Number</label>
|
||||||
<div className="input-wrapper">
|
<div className="input-wrapper">
|
||||||
<Phone size={18} className="input-icon" />
|
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="10-digit mobile number"
|
placeholder="10-digit mobile number"
|
||||||
value={mobileNumber}
|
value={mobileNumber}
|
||||||
onChange={(e) => setMobileNumber(e.target.value.replace(/[^0-9]/g, '').slice(0, 10))}
|
onChange={(e) => setMobileNumber(e.target.value.replace(/[^0-9]/g, '').slice(0, 10))}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
style={{ paddingLeft: '16px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="login-button" type="submit" disabled={isLoading || mobileNumber.length !== 10}>
|
|
||||||
{isLoading ? <div className="loading-dots"><span>.</span><span>.</span><span>.</span></div> : 'Continue'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'pin' && (
|
|
||||||
<form className="login-form" onSubmit={handleLoginSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Security PIN</label>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<Lock size={18} className="input-icon" />
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter 6-digit PIN"
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) => setPin(e.target.value.replace(/[^0-9]/g, '').slice(0, 6))}
|
|
||||||
disabled={isLoading}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="login-button" type="submit" disabled={isLoading || pin.length !== 6}>
|
|
||||||
{isLoading ? <div className="loading-dots"><span>.</span><span>.</span><span>.</span></div> : 'Verify & Login'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="back-to-mobile"
|
type="submit"
|
||||||
type="button"
|
className="login-button w-full"
|
||||||
onClick={() => setStep('mobile')}
|
disabled={isLoading || mobileNumber.length !== 10}
|
||||||
disabled={isLoading}
|
|
||||||
>
|
>
|
||||||
Use different mobile number
|
{isLoading ? <div className="loading-dots"><span>.</span><span>.</span><span>.</span></div> : 'Register Account'}
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'register' && (
|
|
||||||
<form className="login-form" onSubmit={handleRegisterSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Full Name</label>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<UserIcon size={18} className="input-icon" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="What should we call you?"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Create PIN</label>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<Lock size={18} className="input-icon" />
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Choose a 6-digit PIN"
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) => setPin(e.target.value.replace(/[^0-9]/g, '').slice(0, 6))}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="login-button" type="submit" disabled={isLoading || name.length < 2 || pin.length !== 6}>
|
|
||||||
{isLoading ? <div className="loading-dots"><span>.</span><span>.</span><span>.</span></div> : 'Complete Registration'}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="back-to-mobile"
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep('mobile')}
|
className="back-to-mobile"
|
||||||
|
onClick={() => { setShowMobilePrompt(false); setError(null); }}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="google-signin-wrapper">
|
||||||
|
<button
|
||||||
|
className="google-login-btn"
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading-dots">
|
||||||
|
<span>.</span><span>.</span><span>.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="google-icon" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Sign in with Google</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -342,3 +342,29 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-tag {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-tag.guest {
|
||||||
|
background: rgba(239, 108, 55, 0.1);
|
||||||
|
color: #ef6c37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.membership-tag.regular {
|
||||||
|
background: rgba(8, 168, 80, 0.1);
|
||||||
|
color: #08a850;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Phone,
|
Phone,
|
||||||
ShieldCheck,
|
|
||||||
X,
|
X,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Wallet
|
Wallet
|
||||||
@@ -78,7 +77,6 @@ const ProfileScreen: React.FC = () => {
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: ShoppingBag, label: 'My Orders', sub: 'View order history', path: '/orders' },
|
{ icon: ShoppingBag, label: 'My Orders', sub: 'View order history', path: '/orders' },
|
||||||
{ icon: Wallet, label: 'My Wallet', sub: 'Ritz Tokens & History', path: '/wallet' },
|
{ icon: Wallet, label: 'My Wallet', sub: 'Ritz Tokens & History', path: '/wallet' },
|
||||||
{ icon: ShieldCheck, label: 'Account Security', sub: 'Change Security PIN', path: '/change-pin' },
|
|
||||||
{ icon: Settings, label: 'Preferences', sub: 'Dark Mode, Language', path: '/preferences' },
|
{ icon: Settings, label: 'Preferences', sub: 'Dark Mode, Language', path: '/preferences' },
|
||||||
{ icon: HelpCircle, label: 'Help & Support', sub: 'FAQs, Contact Us', path: '#' }
|
{ icon: HelpCircle, label: 'Help & Support', sub: 'FAQs, Contact Us', path: '#' }
|
||||||
];
|
];
|
||||||
@@ -97,7 +95,12 @@ const ProfileScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-info">
|
<div className="profile-info">
|
||||||
|
<div className="profile-name-row">
|
||||||
<h2 className="user-name">{user?.name || 'User Name'}</h2>
|
<h2 className="user-name">{user?.name || 'User Name'}</h2>
|
||||||
|
<span className={`membership-tag ${user?.membership === 'Guest Member' ? 'guest' : 'regular'}`}>
|
||||||
|
{user?.membership || 'Member'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="user-contact">
|
<div className="user-contact">
|
||||||
<span className="contact-item"><Phone size={14} /> {user?.mobileNumber || '+91 XXXXX XXXXX'}</span>
|
<span className="contact-item"><Phone size={14} /> {user?.mobileNumber || '+91 XXXXX XXXXX'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface User {
|
|||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
isSuspended: boolean;
|
isSuspended: boolean;
|
||||||
ritzTokenBalance: number;
|
ritzTokenBalance: number;
|
||||||
|
membership?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stall {
|
export interface Stall {
|
||||||
|
|||||||
Reference in New Issue
Block a user