feat: add auto-lookup and duplicate check for WSDC registration
Enhanced WSDC registration flow with auto-lookup and account validation: Backend changes: - Add accountExists flag to WSDC lookup endpoint - Check database for existing users with WSDC ID - Fix Prisma binary target for Alpine Linux Docker containers Frontend changes: - Auto-lookup WSDC data after entering 4+ digits (500ms debounce) - Show live preview dropdown with dancer information - Display warning if account with WSDC ID already exists - Block registration and suggest login for existing accounts - Improve UX with real-time validation feedback - Add CheckCircle, XCircle, AlertCircle icons for visual feedback This prevents duplicate WSDC ID registrations and provides immediate feedback to users, improving the registration experience. Tested with: - ID 26111 (Vince Yap) - new account allowed - ID 26997 (Radoslaw) - existing account blocked
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
// Database: PostgreSQL 15
|
// Database: PostgreSQL 15
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Provides proxy endpoint for World Swing Dance Council (WSDC) dancer lookup
|
* Provides proxy endpoint for World Swing Dance Council (WSDC) dancer lookup
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { prisma } = require('../utils/db');
|
||||||
const WSDC_API_BASE = 'https://points.worldsdc.com/lookup2020/find';
|
const WSDC_API_BASE = 'https://points.worldsdc.com/lookup2020/find';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,9 +62,15 @@ exports.lookupDancer = async (req, res) => {
|
|||||||
dominateRole: data.dominate_data?.short_dominate_role || null
|
dominateRole: data.dominate_data?.short_dominate_role || null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if user with this WSDC ID already exists in our database
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { wsdcId: String(data.dancer_wsdcid) }
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
dancer: dancerData
|
dancer: dancerData,
|
||||||
|
accountExists: !!existingUser
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { wsdcAPI } from '../services/api';
|
import { wsdcAPI } from '../services/api';
|
||||||
import { Video, Mail, Lock, User, Hash, ArrowRight, ArrowLeft, Loader2 } from 'lucide-react';
|
import { Video, Mail, Lock, User, Hash, ArrowRight, ArrowLeft, Loader2, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||||
import PasswordStrengthIndicator from '../components/common/PasswordStrengthIndicator';
|
import PasswordStrengthIndicator from '../components/common/PasswordStrengthIndicator';
|
||||||
|
|
||||||
const RegisterPage = () => {
|
const RegisterPage = () => {
|
||||||
@@ -13,6 +13,8 @@ const RegisterPage = () => {
|
|||||||
const [hasWsdcId, setHasWsdcId] = useState(null);
|
const [hasWsdcId, setHasWsdcId] = useState(null);
|
||||||
const [wsdcId, setWsdcId] = useState('');
|
const [wsdcId, setWsdcId] = useState('');
|
||||||
const [wsdcData, setWsdcData] = useState(null);
|
const [wsdcData, setWsdcData] = useState(null);
|
||||||
|
const [wsdcPreview, setWsdcPreview] = useState(null); // Preview data after 4+ digits
|
||||||
|
const [accountExists, setAccountExists] = useState(false); // Check if account exists
|
||||||
const [wsdcLoading, setWsdcLoading] = useState(false);
|
const [wsdcLoading, setWsdcLoading] = useState(false);
|
||||||
const [wsdcError, setWsdcError] = useState('');
|
const [wsdcError, setWsdcError] = useState('');
|
||||||
|
|
||||||
@@ -31,35 +33,68 @@ const RegisterPage = () => {
|
|||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Handle WSDC ID lookup
|
// Auto-lookup WSDC ID after 4+ digits are entered
|
||||||
const handleWsdcLookup = async () => {
|
useEffect(() => {
|
||||||
if (!wsdcId || wsdcId.trim() === '') {
|
const lookupWsdcId = async () => {
|
||||||
setWsdcError('Please enter your WSDC ID');
|
if (wsdcId.length >= 4 && /^\d+$/.test(wsdcId)) {
|
||||||
|
setWsdcLoading(true);
|
||||||
|
setWsdcError('');
|
||||||
|
setWsdcPreview(null);
|
||||||
|
setAccountExists(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await wsdcAPI.lookupDancer(wsdcId);
|
||||||
|
|
||||||
|
if (response.success && response.dancer) {
|
||||||
|
setWsdcPreview(response.dancer);
|
||||||
|
setAccountExists(response.accountExists || false);
|
||||||
|
|
||||||
|
if (!response.accountExists) {
|
||||||
|
setWsdcError('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setWsdcPreview(null);
|
||||||
|
setAccountExists(false);
|
||||||
|
if (err.status === 404) {
|
||||||
|
setWsdcError('WSDC ID not found in the registry');
|
||||||
|
} else {
|
||||||
|
setWsdcError('');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setWsdcLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setWsdcPreview(null);
|
||||||
|
setAccountExists(false);
|
||||||
|
setWsdcError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(lookupWsdcId, 500); // Debounce 500ms
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [wsdcId]);
|
||||||
|
|
||||||
|
// Handle WSDC ID confirmation and continue to registration
|
||||||
|
const handleWsdcContinue = () => {
|
||||||
|
if (!wsdcPreview) {
|
||||||
|
setWsdcError('Please enter a valid WSDC ID');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWsdcLoading(true);
|
if (accountExists) {
|
||||||
setWsdcError('');
|
setWsdcError('An account with this WSDC ID already exists');
|
||||||
|
return;
|
||||||
try {
|
|
||||||
const response = await wsdcAPI.lookupDancer(wsdcId);
|
|
||||||
|
|
||||||
if (response.success && response.dancer) {
|
|
||||||
setWsdcData(response.dancer);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
firstName: response.dancer.firstName,
|
|
||||||
lastName: response.dancer.lastName,
|
|
||||||
}));
|
|
||||||
setStep(2);
|
|
||||||
} else {
|
|
||||||
setWsdcError('WSDC ID not found. Please check and try again.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setWsdcError(err.data?.message || 'Failed to lookup WSDC ID. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setWsdcLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set data and move to registration form
|
||||||
|
setWsdcData(wsdcPreview);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
firstName: wsdcPreview.firstName,
|
||||||
|
lastName: wsdcPreview.lastName,
|
||||||
|
}));
|
||||||
|
setStep(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle "No WSDC ID" option
|
// Handle "No WSDC ID" option
|
||||||
@@ -158,39 +193,84 @@ const RegisterPage = () => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={wsdcId}
|
value={wsdcId}
|
||||||
onChange={(e) => setWsdcId(e.target.value)}
|
onChange={(e) => setWsdcId(e.target.value.replace(/\D/g, ''))}
|
||||||
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
className="pl-10 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||||
placeholder="26997"
|
placeholder="26997"
|
||||||
disabled={wsdcLoading}
|
maxLength={10}
|
||||||
/>
|
/>
|
||||||
|
{wsdcLoading && (
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<Loader2 className="h-5 w-5 text-gray-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{wsdcError && (
|
|
||||||
<p className="mt-2 text-sm text-red-600">{wsdcError}</p>
|
{/* Preview dropdown - shows after 4 digits */}
|
||||||
|
{wsdcId.length >= 4 && wsdcPreview && !wsdcLoading && (
|
||||||
|
<div className="mt-3 border border-gray-200 rounded-md p-4 bg-gray-50">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
|
<span className="font-medium text-gray-900">Dancer found</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p><span className="text-gray-600">WSDC ID:</span> <span className="font-medium">{wsdcPreview.wsdcId}</span></p>
|
||||||
|
<p><span className="text-gray-600">Name:</span> <span className="font-medium">{wsdcPreview.firstName} {wsdcPreview.lastName}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accountExists && (
|
||||||
|
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md flex items-start gap-2">
|
||||||
|
<XCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-red-800">Account already exists</p>
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
An account with this WSDC ID is already registered.
|
||||||
|
<Link to="/login" className="font-medium underline ml-1">Sign in instead</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!accountExists && (
|
||||||
|
<div className="mt-3 p-2 bg-green-50 border border-green-200 rounded-md flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Looks good! Click Continue to proceed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{wsdcError && !wsdcPreview && (
|
||||||
|
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-md flex items-start gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-yellow-800">{wsdcError}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleWsdcLookup}
|
onClick={handleWsdcContinue}
|
||||||
disabled={wsdcLoading}
|
disabled={!wsdcPreview || accountExists || wsdcLoading}
|
||||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 font-medium"
|
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||||
>
|
>
|
||||||
{wsdcLoading ? (
|
Continue
|
||||||
<>
|
<ArrowRight className="w-5 h-5" />
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
Looking up...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Continue
|
|
||||||
<ArrowRight className="w-5 h-5" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setHasWsdcId(null)}
|
onClick={() => {
|
||||||
disabled={wsdcLoading}
|
setHasWsdcId(null);
|
||||||
|
setWsdcId('');
|
||||||
|
setWsdcPreview(null);
|
||||||
|
setAccountExists(false);
|
||||||
|
setWsdcError('');
|
||||||
|
}}
|
||||||
className="w-full py-2 px-4 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium"
|
className="w-full py-2 px-4 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5 inline mr-2" />
|
<ArrowLeft className="w-5 h-5 inline mr-2" />
|
||||||
|
|||||||
Reference in New Issue
Block a user