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:
Radosław Gierwiało
2025-11-13 16:11:04 +01:00
parent ac64afa851
commit 46224fca79
3 changed files with 137 additions and 49 deletions

View File

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

View File

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

View File

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