You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
585 lines
20 KiB
TypeScript
585 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Loader2, AlertCircle, CheckCircle, Info, Pencil } from 'lucide-react';
|
|
import { getClubProfile, updateClubProfile } from '@/src/lib/api/courts';
|
|
import type { ClubProfile, ClubProfileUpdateRequest} 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 {
|
|
clubId: number;
|
|
onUpdate?: () => void;
|
|
}
|
|
|
|
export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps) {
|
|
const [profile, setProfile] = useState<ClubProfile | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
// Form state
|
|
const [name, setName] = useState('');
|
|
const [timezone, setTimezone] = useState('');
|
|
const [addressLine1, setAddressLine1] = useState('');
|
|
const [addressLine2, setAddressLine2] = useState('');
|
|
const [city, setCity] = useState('');
|
|
const [postalCode, setPostalCode] = useState('');
|
|
const [country, setCountry] = useState('');
|
|
const [phone, setPhone] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [website, setWebsite] = useState('');
|
|
|
|
// Edit mode tracking for each field
|
|
const [editingFields, setEditingFields] = useState<Set<string>>(new Set());
|
|
|
|
useEffect(() => {
|
|
loadProfile();
|
|
}, [clubId]);
|
|
|
|
async function loadProfile() {
|
|
setLoading(true);
|
|
const result = await getClubProfile(clubId);
|
|
|
|
if (result.success) {
|
|
const prof = result.data;
|
|
setProfile(prof);
|
|
|
|
// Populate form - read from settings structure
|
|
setName(prof.name);
|
|
setTimezone(prof.timezone);
|
|
setAddressLine1(prof.settings?.address?.line_1 || '');
|
|
setAddressLine2(prof.settings?.address?.line_2 || '');
|
|
setCity(prof.settings?.address?.city || '');
|
|
setPostalCode(prof.settings?.address?.postal_code || '');
|
|
setCountry(prof.settings?.address?.country || '');
|
|
setPhone(prof.settings?.contact?.phone || '');
|
|
setEmail(prof.settings?.contact?.email || '');
|
|
setWebsite(prof.settings?.contact?.website || '');
|
|
|
|
setError(null);
|
|
} else {
|
|
setError(result.error.detail);
|
|
}
|
|
|
|
setLoading(false);
|
|
}
|
|
|
|
function validateForm(): boolean {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
if (!name || name.trim().length === 0) {
|
|
newErrors.name = 'Name is required';
|
|
}
|
|
|
|
if (!timezone) {
|
|
newErrors.timezone = 'Timezone is required';
|
|
}
|
|
|
|
if (email && !isValidEmail(email)) {
|
|
newErrors.email = 'Invalid email format';
|
|
}
|
|
|
|
if (website && !isValidUrl(website)) {
|
|
newErrors.website = 'Invalid URL format (must include http:// or https://)';
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
|
|
if (!validateForm()) {
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
setSuccess(false);
|
|
|
|
// Build settings structure, preserving existing settings
|
|
const updatedSettings = {
|
|
...profile?.settings,
|
|
address: {
|
|
...profile?.settings?.address,
|
|
line_1: addressLine1.trim() || undefined,
|
|
line_2: addressLine2.trim() || undefined,
|
|
city: city.trim() || undefined,
|
|
postal_code: postalCode.trim() || undefined,
|
|
country: country.trim() || undefined,
|
|
},
|
|
contact: {
|
|
...profile?.settings?.contact,
|
|
phone: phone.trim() || undefined,
|
|
email: email.trim() || undefined,
|
|
website: website.trim() || undefined,
|
|
},
|
|
};
|
|
|
|
const request: ClubProfileUpdateRequest = {
|
|
name: name.trim(),
|
|
timezone,
|
|
settings: updatedSettings,
|
|
};
|
|
|
|
const result = await updateClubProfile(clubId, request);
|
|
|
|
if (result.success) {
|
|
setProfile(result.data);
|
|
setSuccess(true);
|
|
setError(null);
|
|
|
|
// Call parent update callback
|
|
if (onUpdate) {
|
|
onUpdate();
|
|
}
|
|
|
|
// Hide success message after 3 seconds
|
|
setTimeout(() => setSuccess(false), 3000);
|
|
} else {
|
|
// Handle validation errors
|
|
if (result.error.code === 'validation_error' && result.error.errors) {
|
|
const fieldErrors: Record<string, string> = {};
|
|
result.error.errors.forEach(err => {
|
|
fieldErrors[err.field] = err.message;
|
|
});
|
|
setErrors(fieldErrors);
|
|
} else {
|
|
setError(result.error.detail);
|
|
}
|
|
}
|
|
|
|
setSaving(false);
|
|
}
|
|
|
|
function handleCancel() {
|
|
if (!profile) return;
|
|
|
|
// Reset form to original values - read from settings structure
|
|
setName(profile.name);
|
|
setTimezone(profile.timezone);
|
|
setAddressLine1(profile.settings?.address?.line_1 || '');
|
|
setAddressLine2(profile.settings?.address?.line_2 || '');
|
|
setCity(profile.settings?.address?.city || '');
|
|
setPostalCode(profile.settings?.address?.postal_code || '');
|
|
setCountry(profile.settings?.address?.country || '');
|
|
setPhone(profile.settings?.contact?.phone || '');
|
|
setEmail(profile.settings?.contact?.email || '');
|
|
setWebsite(profile.settings?.contact?.website || '');
|
|
setErrors({});
|
|
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
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="w-8 h-8 text-slate-600 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (error && !profile) {
|
|
return (
|
|
<div className="bg-red-50 border-2 border-red-200 rounded-2xl p-6">
|
|
<div className="flex items-start space-x-3">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
<p className="text-red-700">{error}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!profile) return null;
|
|
|
|
return (
|
|
<div className="max-w-4xl">
|
|
{/* Success message */}
|
|
{success && (
|
|
<div className="mb-6 bg-green-50 border-2 border-green-200 rounded-2xl p-6">
|
|
<div className="flex items-center space-x-3">
|
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
<p className="text-green-700 font-medium">Profile updated successfully</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error message */}
|
|
{error && (
|
|
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-2xl p-6">
|
|
<div className="flex items-start space-x-3">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
<p className="text-red-700">{error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-8">
|
|
{/* Basic Information */}
|
|
<section className="bg-white border-2 border-slate-200 rounded-2xl p-6">
|
|
<h3 className="text-xl font-bold text-slate-900 mb-6">Basic Information</h3>
|
|
|
|
<div className="space-y-4">
|
|
{/* Name */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Name <span className="text-red-600">*</span>
|
|
</label>
|
|
{isFieldEditing('name') || !hasFieldValue(name) ? (
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
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 ${
|
|
errors.name
|
|
? 'border-red-300 focus:border-red-500'
|
|
: 'border-slate-200 focus:border-slate-900'
|
|
} focus:outline-none`}
|
|
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 && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Timezone */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Timezone <span className="text-red-600">*</span>
|
|
</label>
|
|
{isFieldEditing('timezone') ? (
|
|
<select
|
|
value={timezone}
|
|
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 ${
|
|
errors.timezone
|
|
? 'border-red-300 focus:border-red-500'
|
|
: 'border-slate-200 focus:border-slate-900'
|
|
} focus:outline-none`}
|
|
disabled={saving}
|
|
>
|
|
{COMMON_TIMEZONES.map((tz) => (
|
|
<option key={tz.value} value={tz.value}>
|
|
{tz.label}
|
|
</option>
|
|
))}
|
|
</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 && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.timezone}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Location */}
|
|
<section className="bg-white border-2 border-slate-200 rounded-2xl p-6">
|
|
<div className="flex items-start justify-between mb-6">
|
|
<h3 className="text-xl font-bold text-slate-900">Location</h3>
|
|
<div className="group relative">
|
|
<Info className="w-5 h-5 text-slate-400 cursor-help" />
|
|
<div className="absolute right-0 top-6 w-64 p-3 bg-slate-900 text-white text-sm rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
|
|
Stored in settings until native fields ship
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{/* Address Line 1 */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Address Line 1
|
|
</label>
|
|
<EditableField
|
|
fieldName="addressLine1"
|
|
value={addressLine1}
|
|
onChange={setAddressLine1}
|
|
isEditing={isFieldEditing('addressLine1')}
|
|
onToggleEdit={() => toggleEditField('addressLine1')}
|
|
placeholder="123 High Street"
|
|
maxLength={200}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
{/* Address Line 2 */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Address Line 2
|
|
</label>
|
|
<EditableField
|
|
fieldName="addressLine2"
|
|
value={addressLine2}
|
|
onChange={setAddressLine2}
|
|
isEditing={isFieldEditing('addressLine2')}
|
|
onToggleEdit={() => toggleEditField('addressLine2')}
|
|
placeholder="Building A"
|
|
maxLength={200}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
{/* City & Postal Code */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
City
|
|
</label>
|
|
<EditableField
|
|
fieldName="city"
|
|
value={city}
|
|
onChange={setCity}
|
|
isEditing={isFieldEditing('city')}
|
|
onToggleEdit={() => toggleEditField('city')}
|
|
placeholder="London"
|
|
maxLength={100}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Postal Code
|
|
</label>
|
|
<EditableField
|
|
fieldName="postalCode"
|
|
value={postalCode}
|
|
onChange={setPostalCode}
|
|
isEditing={isFieldEditing('postalCode')}
|
|
onToggleEdit={() => toggleEditField('postalCode')}
|
|
placeholder="SW1A 1AA"
|
|
maxLength={20}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Country */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Country
|
|
</label>
|
|
<EditableField
|
|
fieldName="country"
|
|
value={country}
|
|
onChange={setCountry}
|
|
isEditing={isFieldEditing('country')}
|
|
onToggleEdit={() => toggleEditField('country')}
|
|
placeholder="United Kingdom"
|
|
maxLength={100}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Contact */}
|
|
<section className="bg-white border-2 border-slate-200 rounded-2xl p-6">
|
|
<div className="flex items-start justify-between mb-6">
|
|
<h3 className="text-xl font-bold text-slate-900">Contact</h3>
|
|
<div className="group relative">
|
|
<Info className="w-5 h-5 text-slate-400 cursor-help" />
|
|
<div className="absolute right-0 top-6 w-64 p-3 bg-slate-900 text-white text-sm rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
|
|
Stored in settings until native fields ship
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{/* Phone */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Phone
|
|
</label>
|
|
<EditableField
|
|
fieldName="phone"
|
|
value={phone}
|
|
onChange={setPhone}
|
|
isEditing={isFieldEditing('phone')}
|
|
onToggleEdit={() => toggleEditField('phone')}
|
|
placeholder="+44 20 1234 5678"
|
|
maxLength={50}
|
|
type="tel"
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Email
|
|
</label>
|
|
<EditableField
|
|
fieldName="email"
|
|
value={email}
|
|
onChange={setEmail}
|
|
isEditing={isFieldEditing('email')}
|
|
onToggleEdit={() => toggleEditField('email')}
|
|
placeholder="info@centralpadel.com"
|
|
maxLength={100}
|
|
type="email"
|
|
error={errors.email}
|
|
disabled={saving}
|
|
/>
|
|
{errors.email && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Website */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Website
|
|
</label>
|
|
<EditableField
|
|
fieldName="website"
|
|
value={website}
|
|
onChange={setWebsite}
|
|
isEditing={isFieldEditing('website')}
|
|
onToggleEdit={() => toggleEditField('website')}
|
|
placeholder="https://www.centralpadel.com"
|
|
maxLength={200}
|
|
type="url"
|
|
error={errors.website}
|
|
disabled={saving}
|
|
/>
|
|
{errors.website && (
|
|
<p className="mt-1 text-sm text-red-600">{errors.website}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end space-x-3 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleCancel}
|
|
className="px-6 py-3 text-slate-700 font-semibold rounded-lg hover:bg-slate-100 transition-colors"
|
|
disabled={saving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
disabled={saving}
|
|
>
|
|
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|