feat(admin): align UI with Backend Brooke Phase 2 contracts, flip mocks to real endpoints
continuous-integration/drone/push Build is passing Details

Phase 2 Integration Updates:
- Profile UI: Read/write address and contact fields from settings.address/settings.contact JSONB structure
- Court dependencies: Updated modal to show 3 counts (slot_definitions, slot_instances_future, slot_instances_booked)
- Error codes: Fixed duplicate court check to use court_name_duplicate (matches backend)
- Mock flags: Flipped all 9 USE_MOCKS flags to false (courts.ts x7, materialisation.ts x2)

Profile Tab (ClubProfileTab.tsx):
- Read address fields from profile.settings?.address?.line_1 etc.
- Read contact fields from profile.settings?.contact?.phone etc.
- Write to settings structure preserving existing settings
- Added Info icon tooltips: "Stored in settings until native fields ship"
- Removed Integration section (provider/remote_club_id not in API)

Court Dependencies Modal (ClubCourtsTab.tsx):
- Display slot_instances_future count (was upcoming_bookings)
- Display slot_instances_booked count (new field)
- Updated deletion instructions for 3-count structure

Types (courts.ts):
- Created ClubProfileSettings interface for JSONB structure
- Updated ClubProfile to use settings instead of flat fields
- Updated CourtDependencies with court_id + 3 dependency counts
- Updated ClubProfileUpdateRequest to support settings

API Clients:
- courts.ts: Updated mock data to match API contracts (settings structure, 3 counts, court_name_duplicate)
- materialisation.ts: Flipped USE_MOCKS to false for real backend integration

Integration Ready:
- All contract mismatches resolved
- Build succeeds
- Ready for Phase 1 smoke tests against BUILD:290-291

Related: Backend Brooke BUILD:290 (courts CRUD) + BUILD:291 (club profile PATCH)
Contract references: docs/owners/payloads/court-api-contract.md, club-profile-api-contract.md
master
Guillermo Pages 1 month ago
parent cb255cf1f3
commit a61e64ded0

@ -1,152 +0,0 @@
kind: pipeline
type: docker
name: default
trigger:
branch:
- master
steps:
- name: debug-secrets
image: alpine
environment:
VAULT_API_URL:
from_secret: VAULT_API_URL
commands:
- 'echo "Docker Registry URL: $${VAULT_API_URL}"'
when:
event:
- push
- tag
# Make the image available for next step
- name: publish
image: plugins/docker
settings:
build_args:
- NEXT_PUBLIC_PYTHON_API_URL=https://api.playchoo.com
- NEXT_PUBLIC_AUTH_BACKEND_URL=https://auth.api.playchoo.com
- NEXT_PUBLIC_SWISSOID_TARGET_SERVICE_HANDLE=playchoo
- NEXT_PUBLIC_APP_VERSION=1.0.10
dockerfile: docker/Dockerfile
context: .
registry: registry.sn48.zivili.ch
repo: registry.sn48.zivili.ch/meow/playchoo-nextjs
tags:
- "amd64-1.0.0"
- "latest"
username:
from_secret: PORTUS_USER
password:
from_secret: PORTUS_PASSWORD
debug: true
launch_debug: true
# make sure to replace image with same tag
force_tag: true
when:
event:
- push
- tag
- name: deploy
image: registry.sn48.zivili.ch/meow/drone-deploy:amd64-1.0.0
pull: never
settings:
ssh_port:
from_secret: SSH_PORT
# this is required for the moment to generate the .docker/config.json
# drone is failing to do it on its own at the moment
dockerconfigjson:
from_secret: dockerconfigjson
# use portus or directly docker logins
portus_user:
from_secret: PORTUS_USER
portus_password:
from_secret: PORTUS_PASSWORD
# used by deploy to login to deploy server
ssh_host:
from_secret: SSH_HOST
ssh_user:
from_secret: SSH_USER
ssh_key:
from_secret: SSH_KEY
ssh_fingerprint:
from_secret: SSH_FINGERPRINT
# used by the deploy script to gather all project's .env values from vault
drone_agent1_token:
from_secret: DRONE_AGENT1_TOKEN
# used by deploy script to know where to gather secrets from
vault_api_url:
from_secret: VAULT_API_URL
---
kind: secret
name: SSH_HOST
get:
path: kv/data/__drone-admin-secrets
name: SSH_HOST
---
kind: secret
name: SSH_USER
get:
path: kv/data/__drone-admin-secrets
name: SSH_USER
---
kind: secret
name: SSH_KEY
get:
path: kv/data/__drone-admin-secrets
name: SSH_KEY
---
kind: secret
name: DRONE_AGENT1_TOKEN
get:
path: kv/data/__drone-admin-secrets
name: DRONE_AGENT1_TOKEN
---
kind: secret
name: VAULT_API_URL
get:
path: kv/data/__drone-admin-secrets
name: VAULT_API_URL
---
kind: secret
name: PORTUS_USER
get:
path: kv/data/__drone-admin-secrets
name: PORTUS_USER
---
kind: secret
name: PORTUS_PASSWORD
get:
path: kv/data/__drone-admin-secrets
name: PORTUS_PASSWORD
---
kind: secret
name: dockerconfigjson
get:
path: kv/data/__drone-admin-secrets
name: dockerconfigjson
image_pull_secrets:
from_secret: dockerconfigjson
---
kind: secret
name: SSH_PORT
get:
path: kv/data/__drone-admin-secrets
name: SSH_PORT
---
kind: secret
name: SSH_FINGERPRINT
get:
path: kv/data/__drone-admin-secrets
name: SSH_FINGERPRINT

@ -276,7 +276,7 @@ function CourtFormModal({ clubId, court, onClose, onSuccess }: CourtFormModalPro
if (nameError) {
setFieldError(nameError.message);
}
} else if (result.error.code === 'duplicate_court_name') {
} else if (result.error.code === 'court_name_duplicate') {
setFieldError(result.error.detail);
} else {
setError(result.error.detail);
@ -496,11 +496,19 @@ function DependenciesBlockingModal({ court, dependencies, onClose }: Dependencie
</span>
</div>
)}
{dependencies.dependencies.upcoming_bookings > 0 && (
{dependencies.dependencies.slot_instances_future > 0 && (
<div className="flex justify-between items-center">
<span className="text-slate-700">Upcoming bookings:</span>
<span className="text-slate-700">Future slot instances:</span>
<span className="font-bold text-slate-900">
{dependencies.dependencies.upcoming_bookings}
{dependencies.dependencies.slot_instances_future}
</span>
</div>
)}
{dependencies.dependencies.slot_instances_booked > 0 && (
<div className="flex justify-between items-center">
<span className="text-slate-700">Booked slot instances:</span>
<span className="font-bold text-slate-900">
{dependencies.dependencies.slot_instances_booked}
</span>
</div>
)}
@ -514,8 +522,11 @@ function DependenciesBlockingModal({ court, dependencies, onClose }: Dependencie
{dependencies.dependencies.slot_definitions > 0 && (
<li>Delete or reassign all slot definitions</li>
)}
{dependencies.dependencies.upcoming_bookings > 0 && (
<li>Cancel or move all upcoming bookings</li>
{dependencies.dependencies.slot_instances_future > 0 && (
<li>Delete future slot instances</li>
)}
{dependencies.dependencies.slot_instances_booked > 0 && (
<li>Cancel or move all booked slot instances</li>
)}
</ol>
</div>

@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, AlertCircle, CheckCircle } from 'lucide-react';
import { Loader2, AlertCircle, CheckCircle, Info } 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';
@ -43,17 +43,17 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
const prof = result.data;
setProfile(prof);
// Populate form
// Populate form - read from settings structure
setName(prof.name);
setTimezone(prof.timezone);
setAddressLine1(prof.address_line_1 || '');
setAddressLine2(prof.address_line_2 || '');
setCity(prof.city || '');
setPostalCode(prof.postal_code || '');
setCountry(prof.country || '');
setPhone(prof.phone || '');
setEmail(prof.email || '');
setWebsite(prof.website || '');
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 {
@ -97,17 +97,29 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
setError(null);
setSuccess(false);
const request: ClubProfileUpdateRequest = {
name: name.trim(),
timezone,
address_line_1: addressLine1.trim() || undefined,
address_line_2: addressLine2.trim() || undefined,
// 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);
@ -143,17 +155,17 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
function handleCancel() {
if (!profile) return;
// Reset form to original values
// Reset form to original values - read from settings structure
setName(profile.name);
setTimezone(profile.timezone);
setAddressLine1(profile.address_line_1 || '');
setAddressLine2(profile.address_line_2 || '');
setCity(profile.city || '');
setPostalCode(profile.postal_code || '');
setCountry(profile.country || '');
setPhone(profile.phone || '');
setEmail(profile.email || '');
setWebsite(profile.website || '');
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);
}
@ -260,7 +272,15 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
{/* Location */}
<section className="bg-white border-2 border-slate-200 rounded-2xl p-6">
<h3 className="text-xl font-bold text-slate-900 mb-6">Location</h3>
<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 */}
@ -348,7 +368,15 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
{/* Contact */}
<section className="bg-white border-2 border-slate-200 rounded-2xl p-6">
<h3 className="text-xl font-bold text-slate-900 mb-6">Contact</h3>
<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 */}
@ -415,24 +443,6 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps
</div>
</section>
{/* Integration (read-only) */}
<section className="bg-slate-50 border-2 border-slate-200 rounded-2xl p-6">
<h3 className="text-xl font-bold text-slate-900 mb-6">Integration</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-slate-600 font-medium">Provider:</span>
<span className="font-semibold text-slate-900">{profile.provider}</span>
</div>
{profile.provider !== 'local' && profile.remote_club_id && (
<div className="flex justify-between items-center">
<span className="text-slate-600 font-medium">Remote Club ID:</span>
<span className="font-mono text-slate-900">{profile.remote_club_id}</span>
</div>
)}
</div>
</section>
{/* Actions */}
<div className="flex items-center justify-end space-x-3 pt-4">
<button

@ -29,7 +29,7 @@ type ApiResult<T> =
*/
export async function getClubProfile(clubId: number): Promise<ApiResult<ClubProfile>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay
@ -77,7 +77,7 @@ export async function updateClubProfile(
request: ClubProfileUpdateRequest
): Promise<ApiResult<ClubProfile>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay
@ -148,7 +148,7 @@ export async function updateClubProfile(
*/
export async function getCourts(clubId: number): Promise<ApiResult<Court[]>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay
@ -196,7 +196,7 @@ export async function createCourt(
request: CourtRequest
): Promise<ApiResult<Court>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay
@ -232,7 +232,7 @@ export async function createCourt(
title: 'Duplicate Court Name',
status: 409,
detail: `A court with the name '${request.name}' already exists for this club`,
code: 'duplicate_court_name',
code: 'court_name_duplicate',
},
};
}
@ -290,7 +290,7 @@ export async function updateCourt(
request: CourtRequest
): Promise<ApiResult<Court>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay
@ -326,7 +326,7 @@ export async function updateCourt(
title: 'Duplicate Court Name',
status: 409,
detail: `A court with the name '${request.name}' already exists for this club`,
code: 'duplicate_court_name',
code: 'court_name_duplicate',
},
};
}
@ -399,7 +399,7 @@ export async function getCourtDependencies(
courtId: number
): Promise<ApiResult<CourtDependencies>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay
@ -407,16 +407,20 @@ export async function getCourtDependencies(
// Mock dependencies - first court has dependencies, others don't
const mockDeps: CourtDependencies = courtId === 101 ? {
court_id: courtId,
can_delete: false,
dependencies: {
slot_definitions: 12,
upcoming_bookings: 45,
slot_instances_future: 34,
slot_instances_booked: 11,
},
} : {
court_id: courtId,
can_delete: true,
dependencies: {
slot_definitions: 0,
upcoming_bookings: 0,
slot_instances_future: 0,
slot_instances_booked: 0,
},
};
@ -461,7 +465,7 @@ export async function deleteCourt(
courtId: number
): Promise<ApiResult<void>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay
@ -555,16 +559,20 @@ export function getMockClubProfile(clubId: number): ClubProfile {
club_id: clubId,
name: 'Central Padel',
timezone: 'Europe/London',
address_line_1: '123 High Street',
address_line_2: 'Building A',
settings: {
address: {
line_1: '123 High Street',
line_2: 'Building A',
city: 'London',
postal_code: 'SW1A 1AA',
country: 'United Kingdom',
},
contact: {
phone: '+44 20 1234 5678',
email: 'info@centralpadel.com',
website: 'https://www.centralpadel.com',
provider: 'local',
remote_club_id: null,
},
},
created_at: '2024-06-15T10:30:00Z',
updated_at: '2025-11-05T14:23:00Z',
};

@ -29,7 +29,7 @@ export async function getMaterialisationStatus(
clubId: number
): Promise<ApiResult<MaterialisationStatus>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay
@ -77,7 +77,7 @@ export async function triggerMaterialisation(
request: MaterialisationTriggerRequest
): Promise<ApiResult<MaterialisationTriggerResponse>> {
// Use mock data for now (until backend is ready)
const USE_MOCKS = true;
const USE_MOCKS = false;
if (USE_MOCKS) {
// Simulate API delay

@ -16,42 +16,45 @@ export interface CourtRequest {
}
export interface CourtDependencies {
court_id: number;
can_delete: boolean;
dependencies: {
slot_definitions: number;
upcoming_bookings: number;
slot_instances_future: number;
slot_instances_booked: number;
};
}
export interface ClubProfileSettings {
address?: {
line_1?: string;
line_2?: string;
city?: string;
postal_code?: string;
country?: string;
};
contact?: {
phone?: string;
email?: string;
website?: string;
};
// Other future settings can be added here
[key: string]: any; // Allow arbitrary JSONB data
}
export interface ClubProfile {
club_id: number;
name: string;
timezone: string; // IANA timezone
address_line_1?: string | null;
address_line_2?: string | null;
city?: string | null;
postal_code?: string | null;
country?: string | null;
phone?: string | null;
email?: string | null;
website?: string | null;
provider: 'local' | 'fairplay' | 'other';
remote_club_id?: string | null;
settings?: ClubProfileSettings;
created_at: string; // ISO 8601 timestamp
updated_at: string; // ISO 8601 timestamp
}
export interface ClubProfileUpdateRequest {
name: string;
timezone: string;
address_line_1?: string;
address_line_2?: string;
city?: string;
postal_code?: string;
country?: string;
phone?: string;
email?: string;
website?: string;
name?: string;
timezone?: string;
settings?: ClubProfileSettings;
}
export interface CourtError {

Loading…
Cancel
Save