Add OTP-based signup page for website onboarding

- New portal/signup.html with phone → OTP → profile flow
- Handle both new users (signup) and existing users (login)
- Auto-detect user type and use appropriate API endpoints
- Show DEV_OTP on page for local testing
- Updated sendOTP.cfm to gracefully handle missing Twilio

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-26 11:49:44 -08:00
parent ed3f9192d5
commit 8e3bb681e7
2 changed files with 851 additions and 8 deletions

View file

@ -130,19 +130,28 @@ try {
}, { datasource: "payfrit" }); }, { datasource: "payfrit" });
} }
// Send OTP via Twilio // Send OTP via Twilio (if available)
smsResult = application.twilioObj.sendSMS( smsMessage = "Code saved (SMS skipped in dev)";
recipientNumber: "+1" & phone, devOTP = otp; // Return OTP in dev mode for testing
messageBody: "Your Payfrit verification code is: " & otp
);
smsStatus = smsResult.success ? "sent" : "failed: " & smsResult.message; if (structKeyExists(application, "twilioObj")) {
try {
smsResult = application.twilioObj.sendSMS(
recipientNumber: "+1" & phone,
messageBody: "Your Payfrit verification code is: " & otp
);
smsMessage = smsResult.success ? "Verification code sent" : "SMS failed - please try again";
if (smsResult.success) devOTP = ""; // Don't leak OTP in production
} catch (any smsErr) {
smsMessage = "SMS error: " & smsErr.message;
}
}
writeOutput(serializeJSON({ writeOutput(serializeJSON({
"OK": true, "OK": true,
"UUID": userUUID, "UUID": userUUID,
"MESSAGE": smsResult.success ? "Verification code sent" : "SMS failed but code created - contact support", "MESSAGE": smsMessage,
"SMS_STATUS": smsStatus "DEV_OTP": devOTP
})); }));
} catch (any e) { } catch (any e) {

834
portal/signup.html Normal file
View file

@ -0,0 +1,834 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Get Started - Payfrit</title>
<link rel="stylesheet" href="portal.css">
<style>
/* Dark theme variables for signup/login pages */
:root {
--bg-primary: #0a0a0a;
--bg-card: #141414;
--bg-secondary: #1a1a1a;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #666666;
--border-color: #2a2a2a;
--primary: #00ff88;
--primary-hover: #00cc6a;
}
body {
background: var(--bg-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.signup-container {
width: 100%;
max-width: 420px;
padding: 20px;
}
.signup-card {
background: var(--bg-card);
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.signup-header {
text-align: center;
margin-bottom: 32px;
}
.signup-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;
}
.signup-header h1 {
font-size: 24px;
margin: 0 0 8px;
color: var(--text-primary);
}
.signup-header p {
color: var(--text-muted);
margin: 0;
font-size: 14px;
}
.signup-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);
}
.signup-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;
}
.signup-btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.signup-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.signup-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;
}
.signup-error.show {
display: block;
}
.signup-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.signup-success.show {
display: block;
}
.signup-footer {
text-align: center;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border-color);
}
.signup-footer a {
color: var(--primary);
text-decoration: none;
font-size: 14px;
}
.signup-footer a:hover {
text-decoration: underline;
}
.step {
display: none;
}
.step.active {
display: block;
}
/* OTP Input Styling */
.otp-inputs {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
}
.otp-inputs input {
width: 48px;
height: 56px;
text-align: center;
font-size: 24px;
font-weight: 600;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
}
.otp-inputs input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
}
.resend-link {
text-align: center;
margin-top: 16px;
}
.resend-link button {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
font-size: 14px;
text-decoration: underline;
}
.resend-link button:disabled {
color: var(--text-muted);
cursor: not-allowed;
text-decoration: none;
}
/* Phone input with flag */
.phone-input-group {
display: flex;
gap: 8px;
}
.phone-prefix {
padding: 14px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
font-size: 15px;
flex-shrink: 0;
}
.phone-input-group input {
flex: 1;
}
/* Back button */
.back-link {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
text-decoration: none;
font-size: 14px;
margin-bottom: 16px;
}
.back-link:hover {
color: var(--text-primary);
}
/* Welcome message for existing users */
.welcome-back {
text-align: center;
margin-bottom: 24px;
}
.welcome-back h2 {
color: var(--text-primary);
font-size: 20px;
margin: 0 0 8px;
}
.welcome-back p {
color: var(--text-muted);
font-size: 14px;
margin: 0;
}
</style>
</head>
<body>
<div class="signup-container">
<div class="signup-card">
<div class="signup-header">
<div class="signup-logo">P</div>
<h1>Get Started with Payfrit</h1>
<p>Create your account to start accepting orders</p>
</div>
<!-- Step 1: Phone Number -->
<div class="step active" id="step-phone">
<form class="signup-form" id="phoneForm">
<div class="signup-error" id="phoneError"></div>
<div class="form-group">
<label for="phone">Mobile Phone Number</label>
<div class="phone-input-group">
<span class="phone-prefix">+1</span>
<input type="tel" id="phone" name="phone" placeholder="(555) 123-4567" required
pattern="[0-9()\- ]{10,14}" inputmode="tel" autocomplete="tel">
</div>
</div>
<button type="submit" class="signup-btn" id="phoneBtn">Send Verification Code</button>
</form>
<div class="signup-footer">
<span style="color: var(--text-muted); font-size: 14px;">Already have an account? </span>
<a href="login.html">Sign in with password</a>
</div>
</div>
<!-- Step 2: OTP Verification -->
<div class="step" id="step-otp">
<a href="#" class="back-link" onclick="PayfritSignup.showStep('phone'); return false;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back
</a>
<div id="welcomeBack" class="welcome-back" style="display: none;">
<h2>Welcome back!</h2>
<p>Enter the code we sent to continue</p>
</div>
<form class="signup-form" id="otpForm">
<div class="signup-error" id="otpError"></div>
<div class="signup-success" id="otpSuccess"></div>
<div class="form-group">
<label style="text-align: center;">Enter 6-digit code sent to <span id="maskedPhone"></span></label>
<div class="otp-inputs">
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]" class="otp-digit" data-index="0">
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]" class="otp-digit" data-index="1">
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]" class="otp-digit" data-index="2">
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]" class="otp-digit" data-index="3">
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]" class="otp-digit" data-index="4">
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]" class="otp-digit" data-index="5">
</div>
</div>
<button type="submit" class="signup-btn" id="otpBtn">Verify Code</button>
<div class="resend-link">
<button type="button" id="resendBtn" onclick="PayfritSignup.resendOTP()" disabled>
Resend code in <span id="resendTimer">60</span>s
</button>
</div>
</form>
</div>
<!-- Step 3: Profile (for new users only) -->
<div class="step" id="step-profile">
<a href="#" class="back-link" onclick="PayfritSignup.showStep('otp'); return false;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Back
</a>
<div style="text-align: center; margin-bottom: 24px;">
<h2 style="color: var(--text-primary); font-size: 20px; margin: 0 0 8px;">Almost there!</h2>
<p style="color: var(--text-muted); font-size: 14px; margin: 0;">Tell us a bit about yourself</p>
</div>
<form class="signup-form" id="profileForm">
<div class="signup-error" id="profileError"></div>
<div class="form-group">
<label for="firstName">First Name</label>
<input type="text" id="firstName" name="firstName" placeholder="John" required autocomplete="given-name">
</div>
<div class="form-group">
<label for="lastName">Last Name</label>
<input type="text" id="lastName" name="lastName" placeholder="Smith" required autocomplete="family-name">
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="john@example.com" required autocomplete="email">
</div>
<button type="submit" class="signup-btn" id="profileBtn">Complete Setup</button>
</form>
</div>
</div>
</div>
<script>
// Detect base path for API calls
const BASE_PATH = (() => {
const path = window.location.pathname;
const portalIndex = path.indexOf('/portal/');
if (portalIndex > 0) {
return path.substring(0, portalIndex);
}
return '';
})();
const PayfritSignup = {
userUUID: null,
userToken: null,
userId: null,
phone: null,
isExistingUser: false,
resendCountdown: null,
init() {
// Setup form handlers
document.getElementById('phoneForm').addEventListener('submit', (e) => {
e.preventDefault();
this.submitPhone();
});
document.getElementById('otpForm').addEventListener('submit', (e) => {
e.preventDefault();
this.submitOTP();
});
document.getElementById('profileForm').addEventListener('submit', (e) => {
e.preventDefault();
this.submitProfile();
});
// Setup OTP input auto-focus
this.setupOTPInputs();
// Format phone number as user types
document.getElementById('phone').addEventListener('input', (e) => {
this.formatPhoneInput(e.target);
});
},
formatPhoneInput(input) {
let value = input.value.replace(/\D/g, '');
if (value.length > 10) value = value.substring(0, 10);
if (value.length >= 6) {
input.value = `(${value.substring(0, 3)}) ${value.substring(3, 6)}-${value.substring(6)}`;
} else if (value.length >= 3) {
input.value = `(${value.substring(0, 3)}) ${value.substring(3)}`;
} else if (value.length > 0) {
input.value = `(${value}`;
}
},
setupOTPInputs() {
const inputs = document.querySelectorAll('.otp-digit');
inputs.forEach((input, index) => {
input.addEventListener('input', (e) => {
const value = e.target.value.replace(/\D/g, '');
e.target.value = value.substring(0, 1);
if (value && index < 5) {
inputs[index + 1].focus();
}
// Auto-submit when all digits entered
if (this.getOTPValue().length === 6) {
this.submitOTP();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && index > 0) {
inputs[index - 1].focus();
}
});
// Handle paste
input.addEventListener('paste', (e) => {
e.preventDefault();
const paste = (e.clipboardData || window.clipboardData).getData('text');
const digits = paste.replace(/\D/g, '').substring(0, 6);
digits.split('').forEach((digit, i) => {
if (inputs[i]) {
inputs[i].value = digit;
}
});
if (digits.length === 6) {
this.submitOTP();
}
});
});
},
getOTPValue() {
return Array.from(document.querySelectorAll('.otp-digit'))
.map(input => input.value)
.join('');
},
clearOTPInputs() {
document.querySelectorAll('.otp-digit').forEach(input => {
input.value = '';
});
document.querySelector('.otp-digit').focus();
},
showStep(step) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
document.getElementById(`step-${step}`).classList.add('active');
// Focus first input in step
setTimeout(() => {
const firstInput = document.querySelector(`#step-${step} input:not([type="hidden"])`);
if (firstInput) firstInput.focus();
}, 100);
},
showError(elementId, message) {
const el = document.getElementById(elementId);
el.textContent = message;
el.classList.add('show');
},
hideError(elementId) {
const el = document.getElementById(elementId);
el.classList.remove('show');
},
showSuccess(elementId, message) {
const el = document.getElementById(elementId);
el.textContent = message;
el.classList.add('show');
},
async submitPhone() {
const phoneRaw = document.getElementById('phone').value;
this.phone = phoneRaw.replace(/\D/g, '');
if (this.phone.length !== 10) {
this.showError('phoneError', 'Please enter a valid 10-digit phone number');
return;
}
this.hideError('phoneError');
const btn = document.getElementById('phoneBtn');
btn.disabled = true;
btn.textContent = 'Sending...';
try {
// First try login (existing user)
const loginResponse = await fetch(BASE_PATH + '/api/auth/loginOTP.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: this.phone })
});
const loginData = await loginResponse.json();
console.log('[Signup] loginOTP response:', loginData);
if (loginData.OK) {
// Existing user - login flow
this.isExistingUser = true;
this.userUUID = loginData.UUID;
this.devOTP = loginData.DEV_OTP || loginData.dev_otp || '';
document.getElementById('welcomeBack').style.display = 'block';
this.proceedToOTP();
return;
}
if (loginData.ERROR === 'no_account') {
// New user - signup flow
const signupResponse = await fetch(BASE_PATH + '/api/auth/sendOTP.cfm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: this.phone })
});
const signupData = await signupResponse.json();
console.log('[Signup] sendOTP response:', signupData);
if (signupData.OK) {
this.isExistingUser = false;
this.userUUID = signupData.UUID;
this.devOTP = signupData.DEV_OTP || signupData.dev_otp || '';
document.getElementById('welcomeBack').style.display = 'none';
this.proceedToOTP();
return;
}
// Handle signup errors
if (signupData.ERROR === 'phone_exists') {
this.showError('phoneError', 'This phone already has an account. Try signing in.');
} else {
this.showError('phoneError', signupData.MESSAGE || 'Failed to send code. Please try again.');
}
} else {
this.showError('phoneError', loginData.MESSAGE || 'Something went wrong. Please try again.');
}
} catch (err) {
console.error('[Signup] Error:', err);
this.showError('phoneError', 'Connection error. Please try again.');
}
btn.disabled = false;
btn.textContent = 'Send Verification Code';
},
proceedToOTP() {
// Show masked phone
const masked = `(${this.phone.substring(0, 3)}) ***-${this.phone.substring(6)}`;
document.getElementById('maskedPhone').textContent = masked;
// Show DEV_OTP if available (local dev only)
if (this.devOTP) {
this.showSuccess('otpSuccess', `DEV CODE: ${this.devOTP}`);
}
// Reset and show OTP step
this.clearOTPInputs();
this.hideError('otpError');
this.showStep('otp');
// Start resend countdown
this.startResendCountdown();
// Reset button
document.getElementById('phoneBtn').disabled = false;
document.getElementById('phoneBtn').textContent = 'Send Verification Code';
},
startResendCountdown() {
let seconds = 60;
const btn = document.getElementById('resendBtn');
const timer = document.getElementById('resendTimer');
btn.disabled = true;
if (this.resendCountdown) clearInterval(this.resendCountdown);
this.resendCountdown = setInterval(() => {
seconds--;
timer.textContent = seconds;
if (seconds <= 0) {
clearInterval(this.resendCountdown);
btn.disabled = false;
btn.innerHTML = 'Resend code';
}
}, 1000);
},
async resendOTP() {
const endpoint = this.isExistingUser ? '/api/auth/loginOTP.cfm' : '/api/auth/sendOTP.cfm';
try {
const response = await fetch(BASE_PATH + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: this.phone })
});
const data = await response.json();
if (data.OK) {
this.userUUID = data.UUID;
this.showSuccess('otpSuccess', 'New code sent!');
this.startResendCountdown();
this.clearOTPInputs();
setTimeout(() => {
document.getElementById('otpSuccess').classList.remove('show');
}, 3000);
} else {
this.showError('otpError', data.MESSAGE || 'Failed to resend code');
}
} catch (err) {
this.showError('otpError', 'Connection error. Please try again.');
}
},
async submitOTP() {
const otp = this.getOTPValue();
if (otp.length !== 6) {
this.showError('otpError', 'Please enter the complete 6-digit code');
return;
}
this.hideError('otpError');
const btn = document.getElementById('otpBtn');
btn.disabled = true;
btn.textContent = 'Verifying...';
const endpoint = this.isExistingUser ? '/api/auth/verifyLoginOTP.cfm' : '/api/auth/verifyOTP.cfm';
try {
const response = await fetch(BASE_PATH + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: this.userUUID, otp: otp })
});
const data = await response.json();
console.log('[Signup] verify response:', data);
if (data.OK) {
this.userToken = data.Token || data.TOKEN;
this.userId = data.UserID || data.USERID;
// Save auth
localStorage.setItem('payfrit_portal_token', this.userToken);
localStorage.setItem('payfrit_portal_userid', this.userId);
if (this.isExistingUser) {
// Existing user - check if they have businesses and redirect
this.checkBusinessesAndRedirect();
} else if (data.NeedsProfile || data.NEEDSPROFILE) {
// New user needs profile
this.showStep('profile');
} else {
// Profile already complete (shouldn't happen for new users)
this.checkBusinessesAndRedirect();
}
} else {
this.showError('otpError', data.MESSAGE || 'Invalid code. Please try again.');
this.clearOTPInputs();
}
} catch (err) {
console.error('[Signup] Verify error:', err);
this.showError('otpError', 'Connection error. Please try again.');
}
btn.disabled = false;
btn.textContent = 'Verify Code';
},
async submitProfile() {
const firstName = document.getElementById('firstName').value.trim();
const lastName = document.getElementById('lastName').value.trim();
const email = document.getElementById('email').value.trim();
if (!firstName || !lastName || !email) {
this.showError('profileError', 'Please fill in all fields');
return;
}
if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
this.showError('profileError', 'Please enter a valid email address');
return;
}
this.hideError('profileError');
const btn = document.getElementById('profileBtn');
btn.disabled = true;
btn.textContent = 'Completing...';
try {
const response = await fetch(BASE_PATH + '/api/auth/completeProfile.cfm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Token': this.userToken
},
body: JSON.stringify({ firstName, lastName, email })
});
const data = await response.json();
console.log('[Signup] completeProfile response:', data);
if (data.OK) {
// Profile complete - redirect to setup wizard
this.redirectToWizard();
} else {
this.showError('profileError', data.MESSAGE || 'Failed to save profile');
}
} catch (err) {
console.error('[Signup] Profile error:', err);
this.showError('profileError', 'Connection error. Please try again.');
}
btn.disabled = false;
btn.textContent = 'Complete Setup';
},
async checkBusinessesAndRedirect() {
try {
const response = 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 data = await response.json();
console.log('[Signup] myBusinesses response:', data);
if (data.OK && data.BUSINESSES && data.BUSINESSES.length > 0) {
// Has businesses - go to portal
localStorage.setItem('payfrit_portal_business', data.BUSINESSES[0].BusinessID || data.BUSINESSES[0].BUSINESSID);
window.location.href = BASE_PATH + '/portal/index.html';
} else {
// No businesses - go to wizard
this.redirectToWizard();
}
} catch (err) {
console.error('[Signup] Check businesses error:', err);
// Default to wizard
this.redirectToWizard();
}
},
redirectToWizard() {
// Clear any existing business selection (we're creating new)
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
}
};
document.addEventListener('DOMContentLoaded', () => PayfritSignup.init());
</script>
</body>
</html>