feat(admin): implement click-to-edit pattern for club profile fields
continuous-integration/drone/push Build is passing Details

UX Improvements:
- Fields with existing data display as read-only text (not inputs)
- Pencil icon appears on hover (right side of field)
- Click text or icon to convert to editable input
- Auto-blur closes edit mode when field has value
- Prevents accidental edits

Implementation:
- Created reusable EditableField component with pencil icon
- Applied to all optional fields: address (5 fields), contact (3 fields)
- Name and timezone use inline click-to-edit (no separate component)
- Edit state tracked per-field with Set<string>
- Form prefills with existing profile data from API

Visual Design:
- Hover: Border changes to slate-300, background to slate-50
- Pencil icon: slate-400, opacity 0→100 on hover
- Maintains existing validation error styling (red borders)
- Consistent with professional slate theme

User Request: "should not be an input directly (put a pen on the side as well as making the text clickable) and on click convert to input (prevents accidental edits)"
master
Guillermo Pages 1 month ago
parent a61e64ded0
commit 47dbb96c47

@ -1,11 +1,72 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Loader2, AlertCircle, CheckCircle, Info } from 'lucide-react'; import { Loader2, AlertCircle, CheckCircle, Info, Pencil } from 'lucide-react';
import { getClubProfile, updateClubProfile } from '@/src/lib/api/courts'; import { getClubProfile, updateClubProfile } from '@/src/lib/api/courts';
import type { ClubProfile, ClubProfileUpdateRequest} from '@/src/types/courts'; import type { ClubProfile, ClubProfileUpdateRequest} from '@/src/types/courts';
import { COMMON_TIMEZONES, isValidEmail, isValidUrl } from '@/src/types/courts'; import { COMMON_TIMEZONES, isValidEmail, isValidUrl } from '@/src/types/courts';
/**
* Editable Field Component - Click to edit with pencil icon
*/
interface EditableFieldProps {
fieldName: string;
value: string;
onChange: (value: string) => void;
isEditing: boolean;
onToggleEdit: () => void;
placeholder?: string;
maxLength?: number;
type?: 'text' | 'email' | 'tel' | 'url';
error?: string;
disabled?: boolean;
}
function EditableField({
fieldName,
value,
onChange,
isEditing,
onToggleEdit,
placeholder,
maxLength,
type = 'text',
error,
disabled = false,
}: EditableFieldProps) {
const hasValue = value.trim().length > 0;
if (isEditing || !hasValue) {
return (
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => hasValue && onToggleEdit()}
autoFocus
placeholder={placeholder}
maxLength={maxLength}
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
error
? 'border-red-300 focus:border-red-500'
: 'border-slate-200 focus:border-slate-900'
} focus:outline-none`}
disabled={disabled}
/>
);
}
return (
<div
onClick={onToggleEdit}
className="flex items-center justify-between px-4 py-3 border-2 border-slate-200 rounded-lg cursor-pointer hover:border-slate-300 hover:bg-slate-50 transition-colors group"
>
<span className="font-medium text-slate-900">{value}</span>
<Pencil className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
);
}
interface ClubProfileTabProps { interface ClubProfileTabProps {
clubId: number; clubId: number;
onUpdate?: () => void; onUpdate?: () => void;
@ -31,6 +92,9 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [website, setWebsite] = useState(''); const [website, setWebsite] = useState('');
// Edit mode tracking for each field
const [editingFields, setEditingFields] = useState<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
loadProfile(); loadProfile();
}, [clubId]); }, [clubId]);
@ -168,6 +232,27 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
setWebsite(profile.settings?.contact?.website || ''); setWebsite(profile.settings?.contact?.website || '');
setErrors({}); setErrors({});
setError(null); setError(null);
setEditingFields(new Set());
}
function toggleEditField(fieldName: string) {
setEditingFields(prev => {
const next = new Set(prev);
if (next.has(fieldName)) {
next.delete(fieldName);
} else {
next.add(fieldName);
}
return next;
});
}
function isFieldEditing(fieldName: string): boolean {
return editingFields.has(fieldName);
}
function hasFieldValue(value: string): boolean {
return value.trim().length > 0;
} }
// Loading state // Loading state
@ -226,10 +311,13 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Name <span className="text-red-600">*</span> Name <span className="text-red-600">*</span>
</label> </label>
{isFieldEditing('name') || !hasFieldValue(name) ? (
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onBlur={() => hasFieldValue(name) && toggleEditField('name')}
autoFocus
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${ className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
errors.name errors.name
? 'border-red-300 focus:border-red-500' ? 'border-red-300 focus:border-red-500'
@ -237,6 +325,15 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
} focus:outline-none`} } focus:outline-none`}
disabled={saving} disabled={saving}
/> />
) : (
<div
onClick={() => toggleEditField('name')}
className="flex items-center justify-between px-4 py-3 border-2 border-slate-200 rounded-lg cursor-pointer hover:border-slate-300 hover:bg-slate-50 transition-colors group"
>
<span className="font-medium text-slate-900">{name}</span>
<Pencil className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)}
{errors.name && ( {errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name}</p> <p className="mt-1 text-sm text-red-600">{errors.name}</p>
)} )}
@ -247,9 +344,12 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Timezone <span className="text-red-600">*</span> Timezone <span className="text-red-600">*</span>
</label> </label>
{isFieldEditing('timezone') ? (
<select <select
value={timezone} value={timezone}
onChange={(e) => setTimezone(e.target.value)} onChange={(e) => setTimezone(e.target.value)}
onBlur={() => toggleEditField('timezone')}
autoFocus
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${ className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
errors.timezone errors.timezone
? 'border-red-300 focus:border-red-500' ? 'border-red-300 focus:border-red-500'
@ -263,6 +363,17 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
</option> </option>
))} ))}
</select> </select>
) : (
<div
onClick={() => toggleEditField('timezone')}
className="flex items-center justify-between px-4 py-3 border-2 border-slate-200 rounded-lg cursor-pointer hover:border-slate-300 hover:bg-slate-50 transition-colors group"
>
<span className="font-medium text-slate-900">
{COMMON_TIMEZONES.find(tz => tz.value === timezone)?.label || timezone}
</span>
<Pencil className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)}
{errors.timezone && ( {errors.timezone && (
<p className="mt-1 text-sm text-red-600">{errors.timezone}</p> <p className="mt-1 text-sm text-red-600">{errors.timezone}</p>
)} )}
@ -288,13 +399,14 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Address Line 1 Address Line 1
</label> </label>
<input <EditableField
type="text" fieldName="addressLine1"
value={addressLine1} value={addressLine1}
onChange={(e) => setAddressLine1(e.target.value)} onChange={setAddressLine1}
isEditing={isFieldEditing('addressLine1')}
onToggleEdit={() => toggleEditField('addressLine1')}
placeholder="123 High Street" placeholder="123 High Street"
maxLength={200} maxLength={200}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors"
disabled={saving} disabled={saving}
/> />
</div> </div>
@ -304,13 +416,14 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Address Line 2 Address Line 2
</label> </label>
<input <EditableField
type="text" fieldName="addressLine2"
value={addressLine2} value={addressLine2}
onChange={(e) => setAddressLine2(e.target.value)} onChange={setAddressLine2}
isEditing={isFieldEditing('addressLine2')}
onToggleEdit={() => toggleEditField('addressLine2')}
placeholder="Building A" placeholder="Building A"
maxLength={200} maxLength={200}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors"
disabled={saving} disabled={saving}
/> />
</div> </div>
@ -321,13 +434,14 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
City City
</label> </label>
<input <EditableField
type="text" fieldName="city"
value={city} value={city}
onChange={(e) => setCity(e.target.value)} onChange={setCity}
isEditing={isFieldEditing('city')}
onToggleEdit={() => toggleEditField('city')}
placeholder="London" placeholder="London"
maxLength={100} maxLength={100}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors"
disabled={saving} disabled={saving}
/> />
</div> </div>
@ -336,13 +450,14 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Postal Code Postal Code
</label> </label>
<input <EditableField
type="text" fieldName="postalCode"
value={postalCode} value={postalCode}
onChange={(e) => setPostalCode(e.target.value)} onChange={setPostalCode}
isEditing={isFieldEditing('postalCode')}
onToggleEdit={() => toggleEditField('postalCode')}
placeholder="SW1A 1AA" placeholder="SW1A 1AA"
maxLength={20} maxLength={20}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors"
disabled={saving} disabled={saving}
/> />
</div> </div>
@ -353,13 +468,14 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Country Country
</label> </label>
<input <EditableField
type="text" fieldName="country"
value={country} value={country}
onChange={(e) => setCountry(e.target.value)} onChange={setCountry}
isEditing={isFieldEditing('country')}
onToggleEdit={() => toggleEditField('country')}
placeholder="United Kingdom" placeholder="United Kingdom"
maxLength={100} maxLength={100}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors"
disabled={saving} disabled={saving}
/> />
</div> </div>
@ -384,13 +500,15 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Phone Phone
</label> </label>
<input <EditableField
type="tel" fieldName="phone"
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={setPhone}
isEditing={isFieldEditing('phone')}
onToggleEdit={() => toggleEditField('phone')}
placeholder="+44 20 1234 5678" placeholder="+44 20 1234 5678"
maxLength={50} maxLength={50}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors" type="tel"
disabled={saving} disabled={saving}
/> />
</div> </div>
@ -400,17 +518,16 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Email Email
</label> </label>
<input <EditableField
type="email" fieldName="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={setEmail}
isEditing={isFieldEditing('email')}
onToggleEdit={() => toggleEditField('email')}
placeholder="info@centralpadel.com" placeholder="info@centralpadel.com"
maxLength={100} maxLength={100}
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${ type="email"
errors.email error={errors.email}
? 'border-red-300 focus:border-red-500'
: 'border-slate-200 focus:border-slate-900'
} focus:outline-none`}
disabled={saving} disabled={saving}
/> />
{errors.email && ( {errors.email && (
@ -423,17 +540,16 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
<label className="block text-sm font-semibold text-slate-900 mb-2"> <label className="block text-sm font-semibold text-slate-900 mb-2">
Website Website
</label> </label>
<input <EditableField
type="url" fieldName="website"
value={website} value={website}
onChange={(e) => setWebsite(e.target.value)} onChange={setWebsite}
isEditing={isFieldEditing('website')}
onToggleEdit={() => toggleEditField('website')}
placeholder="https://www.centralpadel.com" placeholder="https://www.centralpadel.com"
maxLength={200} maxLength={200}
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${ type="url"
errors.website error={errors.website}
? 'border-red-300 focus:border-red-500'
: 'border-slate-200 focus:border-slate-900'
} focus:outline-none`}
disabled={saving} disabled={saving}
/> />
{errors.website && ( {errors.website && (

Loading…
Cancel
Save