This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/portal/login.html
John Mizerek 7b19979118 Show user's first initial in profile avatar instead of hardcoded U
Save FirstName to localStorage on login (both OTP and password paths).
Portal reads it back and displays the first letter in the user avatar.
Falls back to 'U' if no name is stored. Cleared on logout.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 09:38:36 -08:00

775 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Payfrit Business Portal</title>
<link rel="stylesheet" href="portal.css">
<style>
body {
background: var(--bg-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 420px;
padding: 20px;
}
.login-card {
background: var(--bg-card);
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
width: 64px;
height: 64px;
background: var(--primary);
color: #000;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
margin: 0 auto 16px;
}
.login-header h1 {
font-size: 24px;
margin: 0 0 8px;
color: var(--text-primary);
}
.login-header p {
color: var(--text-muted);
margin: 0;
font-size: 14px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input {
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.login-btn {
padding: 14px 24px;
background: var(--primary);
color: #000;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 8px;
}
.login-btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.login-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.login-error.show {
display: block;
}
.login-success {
background: rgba(0, 255, 136, 0.1);
border: 1px solid rgba(0, 255, 136, 0.3);
color: var(--primary);
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.login-success.show {
display: block;
}
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border-color);
}
.login-footer a {
color: var(--primary);
text-decoration: none;
font-size: 14px;
}
.login-footer a:hover {
text-decoration: underline;
}
.business-select {
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 15px;
width: 100%;
cursor: pointer;
}
.business-select:focus {
outline: none;
border-color: var(--primary);
}
.login-card {
visibility: hidden;
}
.login-card.ready {
visibility: visible;
}
.step {
display: none;
}
.step.active {
display: block;
}
.otp-input {
text-align: center;
font-size: 24px;
font-weight: 600;
letter-spacing: 8px;
padding: 14px 16px;
}
.switch-link {
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
cursor: pointer;
}
.switch-link:hover {
color: var(--primary);
text-decoration: underline;
}
.resend-link {
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
background: none;
border: none;
padding: 0;
text-decoration: none;
}
.resend-link:hover {
color: var(--primary);
text-decoration: underline;
}
.resend-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-logo">P</div>
<h1>Business Portal</h1>
<p id="loginSubtitle">Sign in with your email</p>
</div>
<!-- Step 1: OTP - Enter Email -->
<div class="step" id="step-otp-email">
<form class="login-form" id="otpEmailForm">
<div class="login-error" id="otpEmailError"></div>
<div class="form-group">
<label for="otpEmail">Email Address</label>
<input type="email" id="otpEmail" name="email" placeholder="Enter your email" required>
</div>
<button type="submit" class="login-btn" id="sendCodeBtn">Send Login Code</button>
</form>
<div class="login-footer">
<a href="#" class="switch-link" onclick="PortalLogin.showStep('password'); return false;">Sign in with password instead</a>
</div>
</div>
<!-- Step 2: OTP - Enter Code -->
<div class="step" id="step-otp-code">
<form class="login-form" id="otpCodeForm">
<div class="login-error" id="otpCodeError"></div>
<div class="login-success" id="otpCodeSuccess">A 6-digit code has been sent to your email.</div>
<div class="form-group">
<label for="otpCode">Enter 6-Digit Code</label>
<input type="text" id="otpCode" name="code" class="otp-input" placeholder="000000" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" required>
</div>
<button type="submit" class="login-btn" id="verifyCodeBtn">Verify Code</button>
</form>
<div class="login-footer" style="display: flex; justify-content: space-between; align-items: center;">
<a href="#" class="switch-link" onclick="PortalLogin.showStep('otp-email'); return false;">Change email</a>
<button class="resend-link" id="resendBtn" onclick="PortalLogin.resendCode()">Resend code</button>
</div>
</div>
<!-- Step 3: Password Login (fallback) -->
<div class="step" id="step-password">
<form class="login-form" id="loginForm">
<div class="login-error" id="loginError"></div>
<div class="form-group">
<label for="username">Email or Phone</label>
<input type="text" id="username" name="username" placeholder="Enter your email or phone" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter your password" required>
</div>
<button type="submit" class="login-btn" id="loginBtn">Sign In</button>
</form>
<div class="login-footer">
<a href="#" class="switch-link" onclick="PortalLogin.showStep('otp-email'); return false;">Sign in with email code instead</a>
</div>
</div>
<!-- Step 4: Select Business -->
<div class="step" id="step-business">
<div class="login-form">
<div class="form-group">
<label for="businessSelect">Select Business</label>
<select id="businessSelect" class="business-select" onchange="PortalLogin.selectBusiness()">
<option value="">Choose a business...</option>
</select>
</div>
</div>
</div>
</div>
</div>
<script>
const BASE_PATH = (() => {
const path = window.location.pathname;
const portalIndex = path.indexOf('/portal/');
if (portalIndex > 0) {
return path.substring(0, portalIndex);
}
return '';
})();
const PortalLogin = {
userToken: null,
userId: null,
businesses: [],
otpEmail: null,
resendCooldown: false,
init() {
const card = document.querySelector('.login-card');
const savedToken = localStorage.getItem('payfrit_portal_token');
const savedBusiness = localStorage.getItem('payfrit_portal_business');
const savedUserId = localStorage.getItem('payfrit_portal_userid');
if (savedToken && savedBusiness) {
this.verifyAndRedirect(savedToken, savedBusiness);
return;
}
if (savedToken && savedUserId) {
this.userToken = savedToken;
this.userId = parseInt(savedUserId);
this.showStep('business');
card.classList.add('ready');
this.loadBusinessesAndShow();
return;
}
// Default: show OTP email step
this.showStep('otp-email');
card.classList.add('ready');
// OTP email form
document.getElementById('otpEmailForm').addEventListener('submit', (e) => {
e.preventDefault();
this.sendOTP();
});
// OTP code form
document.getElementById('otpCodeForm').addEventListener('submit', (e) => {
e.preventDefault();
this.verifyOTP();
});
// Password login form
document.getElementById('loginForm').addEventListener('submit', (e) => {
e.preventDefault();
this.login();
});
},
async loadBusinessesAndShow() {
await this.loadBusinesses();
if (this.businesses.length === 0) {
this.startNewRestaurant();
} else if (this.businesses.length === 1) {
this.selectBusinessById(this.businesses[0].BusinessID);
} else {
this.showStep('business');
}
},
async verifyAndRedirect(token, businessId) {
localStorage.setItem('payfrit_portal_business', businessId);
window.location.href = BASE_PATH + '/portal/index.html';
},
// --- OTP Flow ---
async sendOTP() {
const email = document.getElementById('otpEmail').value.trim();
const errorEl = document.getElementById('otpEmailError');
const btn = document.getElementById('sendCodeBtn');
if (!email) return;
errorEl.classList.remove('show');
btn.disabled = true;
btn.textContent = 'Sending...';
try {
const response = await fetch(BASE_PATH + '/api/auth/sendLoginOTP.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Email: email })
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
console.error('[OTP] JSON parse error:', parseErr);
errorEl.textContent = 'Server error. Please try again.';
errorEl.classList.add('show');
btn.disabled = false;
btn.textContent = 'Send Login Code';
return;
}
if (data.OK) {
this.otpEmail = email;
// On dev, show the code for testing
if (data.DEV_OTP) {
console.log('[OTP] Dev code:', data.DEV_OTP);
document.getElementById('otpCodeSuccess').textContent =
'Dev mode - your code is: ' + data.DEV_OTP;
} else {
document.getElementById('otpCodeSuccess').textContent =
'A 6-digit code has been sent to ' + email;
}
document.getElementById('otpCodeSuccess').classList.add('show');
this.showStep('otp-code');
document.getElementById('otpCode').focus();
} else {
errorEl.textContent = data.MESSAGE || data.ERROR || 'Could not send code. Please try again.';
errorEl.classList.add('show');
}
} catch (err) {
console.error('[OTP] Send error:', err);
errorEl.textContent = 'Connection error. Please try again.';
errorEl.classList.add('show');
}
btn.disabled = false;
btn.textContent = 'Send Login Code';
},
async verifyOTP() {
const code = document.getElementById('otpCode').value.trim();
const errorEl = document.getElementById('otpCodeError');
const btn = document.getElementById('verifyCodeBtn');
if (!code || code.length !== 6) {
errorEl.textContent = 'Please enter the 6-digit code.';
errorEl.classList.add('show');
return;
}
errorEl.classList.remove('show');
btn.disabled = true;
btn.textContent = 'Verifying...';
try {
const response = await fetch(BASE_PATH + '/api/auth/verifyEmailOTP.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Email: this.otpEmail, Code: code })
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
console.error('[OTP] Verify parse error:', parseErr);
errorEl.textContent = 'Server error. Please try again.';
errorEl.classList.add('show');
btn.disabled = false;
btn.textContent = 'Verify Code';
return;
}
if (data.OK && data.TOKEN) {
// Normalize key casing
const token = data.TOKEN || data.Token;
const userId = data.USERID || data.UserID;
this.userToken = token;
this.userId = userId;
localStorage.setItem('payfrit_portal_token', token);
localStorage.setItem('payfrit_portal_userid', userId);
const firstName = data.FIRSTNAME || data.FirstName || '';
if (firstName) localStorage.setItem('payfrit_portal_firstname', firstName);
await this.loadBusinesses();
const card = document.querySelector('.login-card');
if (this.businesses.length === 0) {
this.startNewRestaurant();
} else if (this.businesses.length === 1) {
this.selectBusinessById(this.businesses[0].BusinessID);
} else {
this.showStep('business');
card.classList.add('ready');
}
} else {
const friendlyErrors = {
'invalid_code': 'Invalid or expired code. Please try again.',
'expired': 'Code has expired. Please request a new one.',
'user_not_found': 'No account found with that email.'
};
errorEl.textContent = friendlyErrors[data.ERROR] || data.MESSAGE || 'Invalid code. Please try again.';
errorEl.classList.add('show');
document.getElementById('otpCode').value = '';
document.getElementById('otpCode').focus();
}
} catch (err) {
console.error('[OTP] Verify error:', err);
errorEl.textContent = 'Connection error. Please try again.';
errorEl.classList.add('show');
}
btn.disabled = false;
btn.textContent = 'Verify Code';
},
async resendCode() {
if (this.resendCooldown) return;
const btn = document.getElementById('resendBtn');
this.resendCooldown = true;
btn.disabled = true;
// Re-send OTP
try {
const response = await fetch(BASE_PATH + '/api/auth/sendLoginOTP.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Email: this.otpEmail })
});
const text = await response.text();
let data;
try { data = JSON.parse(text); } catch (e) { data = {}; }
const successEl = document.getElementById('otpCodeSuccess');
if (data.OK) {
if (data.DEV_OTP) {
successEl.textContent = 'Dev mode - new code: ' + data.DEV_OTP;
} else {
successEl.textContent = 'A new code has been sent to ' + this.otpEmail;
}
successEl.classList.add('show');
}
} catch (err) {
console.error('[OTP] Resend error:', err);
}
// 30-second cooldown
let seconds = 30;
btn.textContent = 'Resend (' + seconds + 's)';
const interval = setInterval(() => {
seconds--;
if (seconds <= 0) {
clearInterval(interval);
btn.textContent = 'Resend code';
btn.disabled = false;
this.resendCooldown = false;
} else {
btn.textContent = 'Resend (' + seconds + 's)';
}
}, 1000);
},
// --- Password Login (fallback) ---
async login() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errorEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
errorEl.classList.remove('show');
btn.disabled = true;
btn.textContent = 'Signing in...';
try {
const response = await fetch(BASE_PATH + '/api/auth/login.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
console.error("[Login] JSON parse error:", parseErr);
errorEl.textContent = "Server error - check browser console (F12)";
errorEl.classList.add("show");
btn.disabled = false;
btn.textContent = "Sign In";
return;
}
if (data.OK && (data.Token || data.TOKEN)) {
const token = data.Token || data.TOKEN;
const userId = data.UserID || data.USERID;
this.userToken = token;
this.userId = userId;
localStorage.setItem('payfrit_portal_token', token);
localStorage.setItem('payfrit_portal_userid', userId);
const firstName = data.FirstName || data.FIRSTNAME || '';
if (firstName) localStorage.setItem('payfrit_portal_firstname', firstName);
await this.loadBusinesses();
const card = document.querySelector('.login-card');
if (this.businesses.length === 0) {
this.startNewRestaurant();
} else if (this.businesses.length === 1) {
this.selectBusinessById(this.businesses[0].BusinessID);
} else {
this.showStep('business');
card.classList.add('ready');
}
} else {
const friendlyErrors = {
'bad_credentials': 'Incorrect email/phone or password. Please try again.',
'not_found': 'No account found with that email or phone number.',
'account_disabled': 'This account has been disabled. Please contact support.'
};
errorEl.textContent = friendlyErrors[data.ERROR] || data.MESSAGE || 'Invalid credentials';
errorEl.classList.add('show');
}
} catch (err) {
console.error('[Login] Error:', err);
errorEl.textContent = 'Connection error. Please try again.';
errorEl.classList.add('show');
}
btn.disabled = false;
btn.textContent = 'Sign In';
},
// --- Business Selection ---
async loadBusinesses() {
try {
console.log('[Login] Loading businesses for UserID:', this.userId);
const bizResponse = await fetch(BASE_PATH + '/api/portal/myBusinesses.cfm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.userToken
},
body: JSON.stringify({ UserID: this.userId })
});
const bizText = await bizResponse.text();
let bizData;
try {
bizData = JSON.parse(bizText);
} catch (parseErr) {
console.error("[Login] Businesses JSON parse error:", parseErr);
return;
}
if (bizData.OK && bizData.BUSINESSES) {
this.businesses = bizData.BUSINESSES;
console.log('[Login] Loaded', this.businesses.length, 'businesses');
this.populateBusinessSelect();
} else {
console.error('[Login] Businesses API error:', bizData.ERROR || bizData.MESSAGE);
}
} catch (err) {
console.error('[Login] Error loading businesses:', err);
}
},
populateBusinessSelect() {
const select = document.getElementById('businessSelect');
if (this.businesses.length === 0) {
select.innerHTML = '<option value="">No businesses yet</option>';
const wizardOption = document.createElement('option');
wizardOption.value = 'NEW_WIZARD';
wizardOption.textContent = 'Create New Business';
select.appendChild(wizardOption);
} else {
select.innerHTML = '<option value="">Choose a business...</option>';
this.businesses.forEach(biz => {
const option = document.createElement('option');
option.value = biz.BusinessID;
option.textContent = biz.Name;
select.appendChild(option);
});
const wizardOption = document.createElement('option');
wizardOption.value = 'NEW_WIZARD';
wizardOption.textContent = 'New Business Wizard';
select.appendChild(wizardOption);
}
},
showStep(step) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
const el = document.getElementById('step-' + step);
if (el) el.classList.add('active');
// Update subtitle
const subtitle = document.getElementById('loginSubtitle');
if (step === 'otp-email') subtitle.textContent = 'Sign in with your email';
else if (step === 'otp-code') subtitle.textContent = 'Enter your verification code';
else if (step === 'password') subtitle.textContent = 'Sign in with your password';
else if (step === 'business') subtitle.textContent = 'Select your business';
// Clear errors when switching
document.querySelectorAll('.login-error').forEach(e => e.classList.remove('show'));
},
selectBusiness() {
const businessId = document.getElementById('businessSelect').value;
if (businessId === 'NEW_WIZARD') {
this.startNewRestaurant();
} else if (businessId) {
this.selectBusinessById(businessId);
}
},
selectBusinessById(businessId) {
localStorage.setItem('payfrit_portal_business', businessId);
window.location.href = BASE_PATH + '/portal/index.html';
},
startNewRestaurant() {
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
},
logout() {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
localStorage.removeItem('payfrit_portal_firstname');
window.location.href = BASE_PATH + '/portal/login.html';
}
};
document.addEventListener('DOMContentLoaded', () => PortalLogin.init());
</script>
</body>
</html>