Files
FlockPal/frontend/src/App.tsx
T
2026-04-09 18:48:59 -04:00

2676 lines
97 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
type Bird = {
id: string;
workspaceId?: number;
name: string;
tagId: string;
species: string;
dateOfBirth: string | null;
gotchaDay: string | null;
chartColor: string;
photoDataUrl: string | null;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
createdAt: string;
latestWeightGrams: number | null;
latestRecordedOn: string | null;
};
type WeightRecord = {
id: string;
birdId: string;
weightGrams: number;
recordedOn: string;
notes: string | null;
};
type VetVisit = {
id: string;
birdId: string;
visitedOn: string;
clinicName: string;
reason: string;
notes: string | null;
};
type Workspace = {
id: number;
name: string;
workspaceType: WorkspaceType;
billingEmail: string | null;
billingPlan: BillingPlan;
createdAt: string;
updatedAt: string;
};
type WorkspaceMember = {
id: string;
workspaceId: number;
userId?: string | null;
inviteEmail?: string;
name: string;
email?: string;
role: WorkspaceRole;
acceptedAt?: string | null;
createdAt: string;
};
type WorkspaceSummary = {
membership: WorkspaceMember;
workspace: Workspace;
};
type AuthProvider = {
providerKey: 'google' | 'microsoft' | 'apple';
displayName: string;
enabled: boolean;
};
type AuthUser = {
id: string;
email: string;
name: string;
createdAt: string;
};
type AuthSessionPayload = {
user: AuthUser;
activeWorkspace: Workspace;
activeMembership: WorkspaceMember;
workspaces: WorkspaceSummary[];
providers: AuthProvider[];
};
type BirdFormState = {
name: string;
tagId: string;
species: string;
dateOfBirth: string;
gotchaDay: string;
chartColor: string;
photoDataUrl: string;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
};
type WorkspaceFormState = {
name: string;
workspaceType: WorkspaceType;
billingEmail: string;
billingPlan: HouseholdBillingPlan;
};
type WorkspaceMemberFormState = {
name: string;
email: string;
role: WorkspaceRole;
};
type WorkspaceCreateFormState = {
name: string;
workspaceType: WorkspaceType;
billingEmail: string;
billingPlan: HouseholdBillingPlan;
};
type AuthFormState = {
name: string;
email: string;
};
type AuthNotice = {
message: string;
previewUrl?: string | null;
};
type PhotoCropState = {
sourceDataUrl: string;
fileName: string;
naturalWidth: number;
naturalHeight: number;
zoom: number;
offsetX: number;
offsetY: number;
};
type PhotoDragState = {
pointerId: number;
startX: number;
startY: number;
startOffsetX: number;
startOffsetY: number;
};
type AppPage = 'overview' | 'flock' | 'settings';
type SettingsSection = 'collaborators' | 'new-workspace' | 'flock-member' | 'transfer';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const sessionTokenStorageKey = 'flockpal_auth_token';
const emptyBirdForm: BirdFormState = {
name: '',
tagId: '',
species: '',
dateOfBirth: '',
gotchaDay: '',
chartColor: '#cb3a35',
photoDataUrl: '',
notifyOnDob: false,
notifyOnGotchaDay: false,
};
const emptyWorkspaceForm: WorkspaceFormState = {
name: 'My Flock',
workspaceType: 'standard',
billingEmail: '',
billingPlan: 'household_basic',
};
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
name: '',
email: '',
role: 'staff',
};
const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
name: '',
workspaceType: 'standard',
billingEmail: '',
billingPlan: 'household_basic',
};
const emptyAuthForm: AuthFormState = {
name: '',
email: '',
};
const defaultAuthProviders: AuthProvider[] = [
{ providerKey: 'google', displayName: 'Google', enabled: false },
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
{ providerKey: 'apple', displayName: 'Apple', enabled: false },
];
const ProviderIcon = ({ providerKey }: { providerKey: AuthProvider['providerKey'] }) => {
if (providerKey === 'google') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="provider-icon-svg">
<path
d="M21.8 12.2c0-.7-.1-1.4-.2-2H12v3.8h5.5a4.7 4.7 0 0 1-2 3.1v2.6h3.2c1.9-1.8 3.1-4.4 3.1-7.5Z"
fill="#4285F4"
/>
<path
d="M12 22c2.7 0 4.9-.9 6.6-2.4l-3.2-2.6c-.9.6-2 .9-3.4.9-2.6 0-4.8-1.8-5.6-4.1H3.1v2.7A10 10 0 0 0 12 22Z"
fill="#34A853"
/>
<path
d="M6.4 13.8a6 6 0 0 1 0-3.7V7.4H3.1a10 10 0 0 0 0 9l3.3-2.6Z"
fill="#FBBC05"
/>
<path
d="M12 6.1c1.5 0 2.8.5 3.9 1.5l2.9-2.9A10 10 0 0 0 3.1 7.4l3.3 2.7C7.2 7.8 9.4 6.1 12 6.1Z"
fill="#EA4335"
/>
</svg>
);
}
if (providerKey === 'microsoft') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="provider-icon-svg">
<rect x="2" y="2" width="9" height="9" fill="#f25022" />
<rect x="13" y="2" width="9" height="9" fill="#7fba00" />
<rect x="2" y="13" width="9" height="9" fill="#00a4ef" />
<rect x="13" y="13" width="9" height="9" fill="#ffb900" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="provider-icon-svg provider-icon-apple">
<path
d="M16.7 12.8c0-2.2 1.8-3.3 1.9-3.4-1-1.5-2.7-1.7-3.2-1.7-1.4-.1-2.7.8-3.4.8-.7 0-1.8-.8-2.9-.8-1.5 0-2.9.9-3.7 2.2-1.6 2.7-.4 6.7 1.2 8.9.8 1.1 1.7 2.3 2.9 2.2 1.1 0 1.6-.7 3-.7s1.8.7 3 .7c1.2 0 2-.9 2.8-2 .9-1.2 1.2-2.5 1.2-2.5-.1 0-2.8-1.1-2.8-3.7Zm-2.2-6.6c.6-.7 1-1.7.9-2.7-.9 0-2 .6-2.6 1.3-.6.7-1.1 1.7-1 2.7 1 .1 2-.5 2.7-1.3Z"
fill="currentColor"
/>
</svg>
);
};
const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) => left.name.localeCompare(right.name));
const toBirdForm = (bird: Bird): BirdFormState => ({
name: bird.name,
tagId: bird.tagId,
species: bird.species,
dateOfBirth: bird.dateOfBirth ?? '',
gotchaDay: bird.gotchaDay ?? '',
chartColor: bird.chartColor,
photoDataUrl: bird.photoDataUrl ?? '',
notifyOnDob: bird.notifyOnDob,
notifyOnGotchaDay: bird.notifyOnGotchaDay,
});
const formatDate = (value: string | null) => {
if (!value) {
return 'Not set';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(`${value}T00:00:00`));
};
const formatShortDate = (value: string | null) => {
if (!value) {
return 'No data yet';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
}).format(new Date(`${value}T00:00:00`));
};
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
const OVERVIEW_WIDTH = 520;
const OVERVIEW_HEIGHT = 220;
const OVERVIEW_PADDING = { top: 20, right: 18, bottom: 36, left: 52 };
const PHOTO_MAX_BYTES = 900_000;
const PHOTO_EXPORT_SIZES = [720, 600, 480];
const PHOTO_EXPORT_QUALITIES = [0.9, 0.82, 0.74, 0.66];
const PHOTO_PREVIEW_SIZE = 112;
const readJsonSafely = async <T,>(response: Response): Promise<T | null> => {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return null;
}
try {
return (await response.json()) as T;
} catch {
return null;
}
};
const readErrorMessage = async (response: Response, fallback: string) => {
const json = await readJsonSafely<{ error?: string }>(response);
if (json?.error) {
return json.error;
}
const text = await response.text();
const trimmed = text.trim();
if (!trimmed) {
return fallback;
}
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
return fallback;
}
return trimmed;
};
const createApiHeaders = (token?: string, headers?: HeadersInit) => {
const nextHeaders = new Headers(headers);
if (token) {
nextHeaders.set('Authorization', `Bearer ${token}`);
}
return nextHeaders;
};
const apiFetch = (path: string, token?: string, init?: RequestInit) =>
fetch(`${apiBaseUrl}${path}`, {
...init,
headers: createApiHeaders(token, init?.headers),
});
const persistSessionToken = (token: string) => {
window.localStorage.setItem(sessionTokenStorageKey, token);
};
const clearSessionToken = () => {
window.localStorage.removeItem(sessionTokenStorageKey);
};
const readStoredSessionToken = () => window.localStorage.getItem(sessionTokenStorageKey) ?? '';
const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => {
const url = new URL(`${apiBaseUrl}/auth/oauth/${providerKey}/start`);
url.searchParams.set('redirectTo', window.location.href);
return url.toString();
};
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan =>
billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw';
const formatBillingPlanName = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return 'Rescue Free';
}
if (billingPlan === 'household_basic') {
return 'Conure';
}
if (billingPlan === 'household_plus') {
return 'Indian Ringneck';
}
return 'Macaw';
};
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return 'No billing is applied to rescue workspaces.';
}
if (billingPlan === 'household_basic') {
return 'Permits up to 4 birds in the workspace.';
}
if (billingPlan === 'household_plus') {
return 'Permits 5 to 10 birds in the workspace.';
}
return 'Permits 11 or more birds in the workspace.';
};
const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
if (billingPlan === 'household_basic') {
return '4';
}
if (billingPlan === 'household_plus') {
return '10';
}
if (billingPlan === 'household_macaw') {
return '11+';
}
return null;
};
const readFileAsDataUrl = async (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('Unable to read that photo.'));
reader.readAsDataURL(file);
});
const loadImageElement = async (sourceDataUrl: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Unable to prepare that photo for cropping.'));
image.src = sourceDataUrl;
});
const blobToDataUrl = async (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('Unable to finalize that cropped photo.'));
reader.readAsDataURL(blob);
});
const canvasToBlob = async (canvas: HTMLCanvasElement, quality: number) =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Unable to export that cropped photo.'));
return;
}
resolve(blob);
},
'image/webp',
quality,
);
});
const exportCroppedPhoto = async (cropState: PhotoCropState) => {
const image = await loadImageElement(cropState.sourceDataUrl);
const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight);
const cropSize = shortestSide / cropState.zoom;
const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2);
const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2);
const sourceX = Math.min(
cropState.naturalWidth - cropSize,
Math.max(0, (cropState.naturalWidth - cropSize) / 2 + (cropState.offsetX / 100) * maxOffsetX),
);
const sourceY = Math.min(
cropState.naturalHeight - cropSize,
Math.max(0, (cropState.naturalHeight - cropSize) / 2 + (cropState.offsetY / 100) * maxOffsetY),
);
let bestBlob: Blob | null = null;
for (const size of PHOTO_EXPORT_SIZES) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Unable to prepare the crop tool in this browser.');
}
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, sourceX, sourceY, cropSize, cropSize, 0, 0, size, size);
for (const quality of PHOTO_EXPORT_QUALITIES) {
const blob = await canvasToBlob(canvas, quality);
if (!bestBlob || blob.size < bestBlob.size) {
bestBlob = blob;
}
if (blob.size <= PHOTO_MAX_BYTES) {
return blobToDataUrl(blob);
}
}
}
if (!bestBlob) {
throw new Error('Unable to export that cropped photo.');
}
if (bestBlob.size > PHOTO_MAX_BYTES) {
throw new Error('That photo is still too large after cropping. Try zooming in a little more.');
}
return blobToDataUrl(bestBlob);
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const getPhotoCropMetrics = (cropState: PhotoCropState, frameSize = PHOTO_PREVIEW_SIZE) => {
const shortestSide = Math.min(cropState.naturalWidth, cropState.naturalHeight);
const cropSize = shortestSide / cropState.zoom;
const scale = frameSize / cropSize;
const displayWidth = cropState.naturalWidth * scale;
const displayHeight = cropState.naturalHeight * scale;
const maxOffsetX = Math.max(0, (cropState.naturalWidth - cropSize) / 2);
const maxOffsetY = Math.max(0, (cropState.naturalHeight - cropSize) / 2);
const left = (frameSize - displayWidth) / 2 - (cropState.offsetX / 100) * maxOffsetX * scale;
const top = (frameSize - displayHeight) / 2 - (cropState.offsetY / 100) * maxOffsetY * scale;
return {
left,
top,
displayWidth,
displayHeight,
scale,
maxOffsetX,
maxOffsetY,
};
};
const chartPath = (points: WeightRecord[], width = 520, height = 180) => {
if (!points.length) {
return '';
}
const weights = points.map((point) => point.weightGrams);
const min = Math.min(...weights);
const max = Math.max(...weights);
const spread = Math.max(max - min, 1);
return points
.map((point, index) => {
const x = (index / Math.max(points.length - 1, 1)) * width;
const y = height - ((point.weightGrams - min) / spread) * (height - 24) - 12;
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(' ');
};
const chartDots = (points: WeightRecord[], width = 520, height = 180) => {
if (!points.length) {
return [];
}
const weights = points.map((point) => point.weightGrams);
const min = Math.min(...weights);
const max = Math.max(...weights);
const spread = Math.max(max - min, 1);
return points.map((point, index) => ({
id: point.id,
x: (index / Math.max(points.length - 1, 1)) * width,
y: height - ((point.weightGrams - min) / spread) * (height - 24) - 12,
label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`,
}));
};
const buildOverviewSeries = (points: WeightRecord[], minWeight: number, maxWeight: number, startDate: Date, endDate: Date) => {
const innerWidth = OVERVIEW_WIDTH - OVERVIEW_PADDING.left - OVERVIEW_PADDING.right;
const innerHeight = OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom;
const weightSpread = Math.max(maxWeight - minWeight, 1);
const startMs = startDate.getTime();
const endMs = endDate.getTime();
const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
return points.map((point) => {
const pointTime = parseDateValue(point.recordedOn).getTime();
const x = OVERVIEW_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth;
const y = OVERVIEW_PADDING.top + (1 - (point.weightGrams - minWeight) / weightSpread) * innerHeight;
return {
id: point.id,
x,
y,
label: `${point.weightGrams.toFixed(1)} g on ${formatShortDate(point.recordedOn)}`,
};
});
};
const toOverviewPath = (points: { x: number; y: number }[]) =>
points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
function App() {
const [activePage, setActivePage] = useState<AppPage>('overview');
const [authToken, setAuthToken] = useState('');
const [authSession, setAuthSession] = useState<AuthSessionPayload | null>(null);
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authForm, setAuthForm] = useState<AuthFormState>(emptyAuthForm);
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [authSubmitting, setAuthSubmitting] = useState(false);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
const [birds, setBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
const [editingBirdId, setEditingBirdId] = useState<string>('');
const [weights, setWeights] = useState<WeightRecord[]>([]);
const [vetVisits, setVetVisits] = useState<VetVisit[]>([]);
const [allBirdWeights, setAllBirdWeights] = useState<Record<string, WeightRecord[]>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [workspaceForm, setWorkspaceForm] = useState<WorkspaceFormState>(emptyWorkspaceForm);
const [workspaceMemberForm, setWorkspaceMemberForm] = useState<WorkspaceMemberFormState>(emptyWorkspaceMemberForm);
const [workspaceCreateForm, setWorkspaceCreateForm] = useState<WorkspaceCreateFormState>(emptyWorkspaceCreateForm);
const [birdForm, setBirdForm] = useState<BirdFormState>(emptyBirdForm);
const [birdPhotoName, setBirdPhotoName] = useState('');
const [photoCrop, setPhotoCrop] = useState<PhotoCropState | null>(null);
const [photoDrag, setPhotoDrag] = useState<PhotoDragState | null>(null);
const [applyingPhotoCrop, setApplyingPhotoCrop] = useState(false);
const [savingBird, setSavingBird] = useState(false);
const [savingWorkspace, setSavingWorkspace] = useState(false);
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
const [weightForm, setWeightForm] = useState({
weightGrams: '',
recordedOn: new Date().toISOString().slice(0, 10),
notes: '',
});
const [vetVisitForm, setVetVisitForm] = useState({
visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '',
reason: '',
notes: '',
});
const [mergeForm, setMergeForm] = useState({
birdId: '',
destinationOwnerEmail: '',
notes: '',
});
const [deletingBird, setDeletingBird] = useState(false);
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
const selectedBird = useMemo(
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId],
);
const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId],
);
const birdsWithRecentWeights = useMemo(
() => birds.filter((bird) => (allBirdWeights[bird.id] ?? []).length > 0),
[allBirdWeights, birds],
);
const missingFirstWeightCount = useMemo(
() => birds.filter((bird) => bird.latestWeightGrams === null).length,
[birds],
);
const selectedBirdTrendCopy = useMemo(() => {
if (weights.length < 2) {
return 'Needs a few more entries before trend detection.';
}
const first = weights[0].weightGrams;
const last = weights[weights.length - 1].weightGrams;
const delta = last - first;
if (Math.abs(delta) < 1) {
return 'Weight has been steady over the last visible entries.';
}
return delta > 0
? `Weight is up ${delta.toFixed(1)} g over the current window.`
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
}, [weights]);
const flockWeeklyTrendItems = useMemo(() => {
return birds
.map((bird) => {
const birdWeights = allBirdWeights[bird.id] ?? [];
if (!birdWeights.length) {
return null;
}
const latestWeight = birdWeights[birdWeights.length - 1];
const weekStart = new Date(`${latestWeight.recordedOn}T00:00:00`);
weekStart.setDate(weekStart.getDate() - 7);
const weeklyWeights = birdWeights.filter(
(entry) => new Date(`${entry.recordedOn}T00:00:00`) >= weekStart,
);
if (weeklyWeights.length < 2) {
return null;
}
const startingWeight = weeklyWeights[0].weightGrams;
const percentChange = startingWeight === 0 ? 0 : ((latestWeight.weightGrams - startingWeight) / startingWeight) * 100;
return {
id: bird.id,
name: bird.name,
chartColor: bird.chartColor,
formattedChange: `${percentChange >= 0 ? '+' : ''}${percentChange.toFixed(1)}%`,
direction: percentChange >= 0 ? 'positive' : 'negative',
} as const;
})
.filter((trend): trend is NonNullable<typeof trend> => trend !== null);
}, [allBirdWeights, birds]);
const overviewChart = useMemo(() => {
const plottedBirds = birds
.map((bird) => ({ bird, weights: allBirdWeights[bird.id] ?? [] }))
.filter((entry) => entry.weights.length > 0);
const endDate = new Date();
endDate.setHours(0, 0, 0, 0);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 29);
if (!plottedBirds.length) {
return {
plottedBirds,
series: [],
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
],
yTicks: [],
};
}
const allWeights = plottedBirds.flatMap((entry) => entry.weights.map((weight) => weight.weightGrams));
const rawMinWeight = Math.min(...allWeights);
const rawMaxWeight = Math.max(...allWeights);
const weightPadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
const minWeight = Math.max(0, rawMinWeight - weightPadding);
const maxWeight = rawMaxWeight + weightPadding;
const midWeight = minWeight + (maxWeight - minWeight) / 2;
return {
plottedBirds,
series: plottedBirds.map(({ bird, weights: birdWeights }) => ({
bird,
points: buildOverviewSeries(birdWeights, minWeight, maxWeight, startDate, endDate),
})),
xTicks: [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: OVERVIEW_PADDING.left },
{ label: formatShortDate(new Date((startDate.getTime() + endDate.getTime()) / 2).toISOString().slice(0, 10)), x: OVERVIEW_WIDTH / 2 },
{ label: formatShortDate(endDate.toISOString().slice(0, 10)), x: OVERVIEW_WIDTH - OVERVIEW_PADDING.right },
],
yTicks: [
{ label: `${maxWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top },
{ label: `${midWeight.toFixed(0)} g`, y: OVERVIEW_PADDING.top + (OVERVIEW_HEIGHT - OVERVIEW_PADDING.top - OVERVIEW_PADDING.bottom) / 2 },
{ label: `${minWeight.toFixed(0)} g`, y: OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom },
],
};
}, [allBirdWeights, birds]);
const applySession = (session: AuthSessionPayload, token: string) => {
setAuthToken(token);
setAuthSession(session);
setAuthProviders(session.providers);
setAuthNotice(null);
setWorkspace(session.activeWorkspace);
setActiveMembership({
...session.activeMembership,
email: session.activeMembership.inviteEmail,
});
setWorkspaceForm({
name: session.activeWorkspace.name,
workspaceType: session.activeWorkspace.workspaceType,
billingEmail: session.activeWorkspace.billingEmail ?? '',
billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic',
});
setWorkspaceCreateForm((current) => ({
...current,
billingEmail: current.billingEmail || session.user.email,
}));
};
const clearAppSession = () => {
clearSessionToken();
setAuthToken('');
setAuthSession(null);
setWorkspace(null);
setActiveMembership(null);
setWorkspaceMembers([]);
setBirds([]);
setWeights([]);
setVetVisits([]);
setAllBirdWeights({});
setSelectedBirdId('');
setEditingBirdId('');
setWorkspaceForm(emptyWorkspaceForm);
setWorkspaceCreateForm(emptyWorkspaceCreateForm);
setAuthNotice(null);
};
useEffect(() => {
const loadProviders = async () => {
try {
const response = await apiFetch('/auth/providers');
if (!response.ok) {
setAuthProviders(defaultAuthProviders);
return;
}
const data = (await readJsonSafely<{ providers?: AuthProvider[] }>(response)) ?? {};
const mergedProviders = defaultAuthProviders.map((defaultProvider) => {
const matchingProvider = (data.providers ?? []).find((provider) => provider.providerKey === defaultProvider.providerKey);
return matchingProvider ?? defaultProvider;
});
setAuthProviders(mergedProviders);
} catch {
setAuthProviders(defaultAuthProviders);
}
};
const bootstrapSession = async () => {
try {
setAuthLoading(true);
await loadProviders();
const url = new URL(window.location.href);
const callbackToken = url.searchParams.get('auth_token') ?? '';
const token = callbackToken || readStoredSessionToken();
if (callbackToken) {
persistSessionToken(callbackToken);
url.searchParams.delete('auth_token');
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
}
if (!token) {
return;
}
const response = await apiFetch('/auth/session', token);
if (!response.ok) {
clearAppSession();
return;
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (data.session && (data.token || token)) {
persistSessionToken(data.token || token);
applySession(data.session, data.token || token);
setError('');
}
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load your session.');
} finally {
setAuthLoading(false);
}
};
void bootstrapSession();
}, []);
useEffect(() => {
if (!authToken || !workspace?.id) {
setLoading(false);
return;
}
const loadBirds = async () => {
try {
setLoading(true);
const [birdsResponse, membersResponse] = await Promise.all([apiFetch('/birds', authToken), apiFetch('/workspace/members', authToken)]);
if (!birdsResponse.ok) {
if (birdsResponse.status === 401) {
clearAppSession();
return;
}
throw new Error(await readErrorMessage(birdsResponse, 'Unable to load flock members.'));
}
const data = (await readJsonSafely<{ birds?: Bird[] }>(birdsResponse)) ?? {};
const nextBirds = data.birds ?? [];
setBirds(nextBirds);
setSelectedBirdId((current) => (current && nextBirds.some((bird) => bird.id === current) ? current : ''));
if (membersResponse.ok) {
const membersData = (await readJsonSafely<{ members?: WorkspaceMember[] }>(membersResponse)) ?? {};
setWorkspaceMembers(
(membersData.members ?? []).map((member) => ({
...member,
email: member.inviteEmail,
})),
);
} else {
setWorkspaceMembers([]);
}
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
} finally {
setLoading(false);
}
};
void loadBirds();
}, [authToken, workspace?.id]);
useEffect(() => {
if (!selectedBird?.id) {
setWeights([]);
setVetVisits([]);
return;
}
const loadBirdDetail = async () => {
try {
const [weightsResponse, visitsResponse] = await Promise.all([
apiFetch(`/birds/${selectedBird.id}/weights?days=90`, authToken),
apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken),
]);
if (!weightsResponse.ok || !visitsResponse.ok) {
throw new Error('Unable to load flock member details.');
}
const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {};
const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {};
setWeights(weightsData.weights ?? []);
setVetVisits(visitsData.vetVisits ?? []);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.');
}
};
void loadBirdDetail();
}, [authToken, selectedBird?.id]);
useEffect(() => {
if (!authToken || !birds.length) {
setAllBirdWeights({});
return;
}
const loadAllBirdWeights = async () => {
try {
const responses = await Promise.all(
birds.map(async (bird) => {
const response = await apiFetch(`/birds/${bird.id}/weights?days=30`, authToken);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load overview weights.'));
}
const data = (await readJsonSafely<{ weights?: WeightRecord[] }>(response)) ?? {};
return [bird.id, (data.weights ?? []) as WeightRecord[]] as const;
}),
);
setAllBirdWeights(Object.fromEntries(responses));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load overview weights.');
}
};
void loadAllBirdWeights();
}, [authToken, birds]);
useEffect(() => {
if (!editingBirdId) {
return;
}
if (!editingBird) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
return;
}
setBirdForm(toBirdForm(editingBird));
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
}, [editingBird, editingBirdId]);
const startCreateBird = () => {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
setError('');
setActivePage('settings');
};
const handleAuthSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setAuthNotice(null);
setAuthSubmitting(true);
try {
const response = await apiFetch('/auth/magic-link/request', undefined, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: authForm.name.trim(),
email: authForm.email,
redirectTo: window.location.href,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to send your sign-in link.'));
}
const data = (await readJsonSafely<{ message?: string; previewUrl?: string | null }>(response)) ?? {};
setAuthNotice({
message: data.message ?? 'Check your email for a secure sign-in link.',
previewUrl: data.previewUrl,
});
setAuthForm(emptyAuthForm);
} catch (authError) {
setError(authError instanceof Error ? authError.message : 'Unable to send your sign-in link.');
} finally {
setAuthSubmitting(false);
}
};
const handleLogout = async () => {
setError('');
try {
if (authToken) {
await apiFetch('/auth/logout', authToken, { method: 'POST' });
}
} catch {
// Best-effort logout.
} finally {
clearAppSession();
setAuthForm(emptyAuthForm);
}
};
const handleWorkspaceSwitch = async (workspaceId: number) => {
if (!authToken || workspaceId === workspace?.id) {
return;
}
setError('');
setSwitchingWorkspaceId(workspaceId);
try {
const response = await apiFetch('/auth/switch-workspace', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to switch workspaces.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) {
throw new Error('Unable to switch workspaces.');
}
const nextToken = data.token || authToken;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
setSelectedBirdId('');
setEditingBirdId('');
setWeights([]);
setVetVisits([]);
setActivePage('overview');
} catch (switchError) {
setError(switchError instanceof Error ? switchError.message : 'Unable to switch workspaces.');
} finally {
setSwitchingWorkspaceId(null);
}
};
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setCreatingWorkspace(true);
try {
const response = await apiFetch('/workspaces', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: workspaceCreateForm.name,
workspaceType: workspaceCreateForm.workspaceType,
billingEmail: workspaceCreateForm.billingEmail,
billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create workspace.'));
}
const workspaceResponse = await apiFetch('/auth/session', authToken);
if (!workspaceResponse.ok) {
throw new Error(await readErrorMessage(workspaceResponse, 'Workspace was created but the session could not be refreshed.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(workspaceResponse)) ?? {};
if (!data.session) {
throw new Error('Unable to refresh your workspace list.');
}
const nextToken = data.token || authToken;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
setWorkspaceCreateForm({
...emptyWorkspaceCreateForm,
billingEmail: data.session.user.email,
});
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create workspace.');
} finally {
setCreatingWorkspace(false);
}
};
const startEditBird = (bird: Bird) => {
setSelectedBirdId(bird.id);
setEditingBirdId(bird.id);
setBirdForm(toBirdForm(bird));
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
setError('');
setActivePage('settings');
};
const handleBirdPhotoChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.size > 12_000_000) {
setError('Photo is too large to process in the browser. Please choose one under 12 MB.');
event.target.value = '';
return;
}
try {
const photoDataUrl = await readFileAsDataUrl(file);
const image = await loadImageElement(photoDataUrl);
setPhotoCrop({
sourceDataUrl: photoDataUrl,
fileName: file.name,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
zoom: 1,
offsetX: 0,
offsetY: 0,
});
setPhotoDrag(null);
setBirdPhotoName(file.name);
setError('');
} catch (photoError) {
setError(photoError instanceof Error ? photoError.message : 'Unable to read that photo.');
} finally {
event.target.value = '';
}
};
const handleApplyPhotoCrop = async () => {
if (!photoCrop) {
return;
}
setApplyingPhotoCrop(true);
setError('');
try {
const photoDataUrl = await exportCroppedPhoto(photoCrop);
setBirdForm((current) => ({ ...current, photoDataUrl }));
setBirdPhotoName(photoCrop.fileName);
setPhotoCrop(null);
setPhotoDrag(null);
} catch (cropError) {
setError(cropError instanceof Error ? cropError.message : 'Unable to crop that photo.');
} finally {
setApplyingPhotoCrop(false);
}
};
const handleCancelPhotoCrop = () => {
setPhotoCrop(null);
setPhotoDrag(null);
setError('');
};
const handleRemovePhoto = () => {
setBirdForm((current) => ({ ...current, photoDataUrl: '' }));
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
};
const handlePhotoCropPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!photoCrop) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
setPhotoDrag({
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startOffsetX: photoCrop.offsetX,
startOffsetY: photoCrop.offsetY,
});
};
const handlePhotoCropPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!photoCrop || !photoDrag || photoDrag.pointerId !== event.pointerId) {
return;
}
const metrics = getPhotoCropMetrics(photoCrop);
const deltaX = event.clientX - photoDrag.startX;
const deltaY = event.clientY - photoDrag.startY;
const nextOffsetX =
metrics.maxOffsetX > 0 ? clamp(photoDrag.startOffsetX - (deltaX / (metrics.maxOffsetX * metrics.scale)) * 100, -100, 100) : 0;
const nextOffsetY =
metrics.maxOffsetY > 0 ? clamp(photoDrag.startOffsetY - (deltaY / (metrics.maxOffsetY * metrics.scale)) * 100, -100, 100) : 0;
setPhotoCrop((current) => (current ? { ...current, offsetX: nextOffsetX, offsetY: nextOffsetY } : current));
};
const handlePhotoCropPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (photoDrag?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture(event.pointerId);
setPhotoDrag(null);
}
};
const handleBirdSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSavingBird(true);
const isEditing = Boolean(editingBirdId);
const method = isEditing ? 'PUT' : 'POST';
try {
const response = await apiFetch(isEditing ? `/birds/${editingBirdId}` : '/birds', authToken, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(birdForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Unable to ${isEditing ? 'update' : 'create'} flock member.`));
}
const data = await readJsonSafely<{ bird: Bird }>(response);
if (!data?.bird) {
throw new Error(`Unable to ${isEditing ? 'update' : 'create'} flock member.`);
}
const savedBird = data.bird as Bird;
setBirds((current) => {
if (isEditing) {
return sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird)));
}
return sortBirdsByName([...current, savedBird]);
});
setSelectedBirdId(isEditing ? savedBird.id : '');
setEditingBirdId(savedBird.id);
setBirdForm(toBirdForm(savedBird));
setBirdPhotoName('');
setActivePage(isEditing ? 'settings' : 'flock');
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save flock member.');
} finally {
setSavingBird(false);
}
};
const handleWeightSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird) {
return;
}
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}/weights`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
weightGrams: Number(weightForm.weightGrams),
recordedOn: weightForm.recordedOn,
notes: weightForm.notes,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save weight.'));
}
const data = await readJsonSafely<{ weight: WeightRecord }>(response);
if (!data?.weight) {
throw new Error('Unable to save weight.');
}
const nextWeights = [...weights, data.weight].sort((left, right) => left.recordedOn.localeCompare(right.recordedOn));
setWeights(nextWeights);
setAllBirdWeights((current) => ({
...current,
[selectedBird.id]: nextWeights.filter((entry) => {
const limitDate = new Date();
limitDate.setDate(limitDate.getDate() - 29);
return new Date(`${entry.recordedOn}T00:00:00`) >= new Date(limitDate.toDateString());
}),
}));
setBirds((current) =>
current.map((bird) =>
bird.id === selectedBird.id
? {
...bird,
latestWeightGrams: data.weight.weightGrams,
latestRecordedOn: data.weight.recordedOn,
}
: bird,
),
);
setWeightForm({ weightGrams: '', recordedOn: new Date().toISOString().slice(0, 10), notes: '' });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save weight.');
}
};
const handleVetVisitSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedBird) {
return;
}
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(vetVisitForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save vet visit.'));
}
const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
if (!data?.vetVisit) {
throw new Error('Unable to save vet visit.');
}
setVetVisits((current) =>
[data.vetVisit, ...current].sort((left, right) => right.visitedOn.localeCompare(left.visitedOn)),
);
setVetVisitForm({
visitedOn: new Date().toISOString().slice(0, 10),
clinicName: '',
reason: '',
notes: '',
});
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
}
};
const handleRemoveBird = async () => {
if (!selectedBird || deletingBird) {
return;
}
const confirmed = window.confirm(
`Remove ${selectedBird.name} from the flock?\n\nThis will also remove weight records and vet visits for this flock member.`,
);
if (!confirmed) {
return;
}
setDeletingBird(true);
setError('');
try {
const response = await apiFetch(`/birds/${selectedBird.id}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to remove flock member.'));
}
const nextBirds = birds.filter((bird) => bird.id !== selectedBird.id);
setBirds(nextBirds);
setAllBirdWeights((current) => {
const next = { ...current };
delete next[selectedBird.id];
return next;
});
setSelectedBirdId('');
setWeights([]);
setVetVisits([]);
if (editingBirdId === selectedBird.id) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdPhotoName('');
}
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Unable to remove flock member.');
} finally {
setDeletingBird(false);
}
};
const handleMergeSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
try {
const response = await apiFetch('/transfers/draft', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mergeForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save transfer draft.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
destinationOwnerExists?: boolean;
inviteSent?: boolean;
}>(response)) ?? {};
const transferBirdName = data.bird?.name || birds.find((bird) => bird.id === mergeForm.birdId)?.name || 'bird';
const inviteCopy = data.inviteSent
? `\n\nA FlockPal invite was also sent to ${mergeForm.destinationOwnerEmail} because that email does not have an account yet.`
: data.destinationOwnerExists
? `\n\n${mergeForm.destinationOwnerEmail} already has a FlockPal account, so no invite was needed.`
: '';
window.alert(
`Transfer prep saved for ${transferBirdName}.${inviteCopy}\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`,
);
setMergeForm({
birdId: '',
destinationOwnerEmail: '',
notes: '',
});
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save transfer draft.');
}
};
const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSavingWorkspace(true);
try {
const response = await apiFetch('/workspace', authToken, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...workspaceForm,
billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save workspace settings.'));
}
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
if (!data.workspace) {
throw new Error('Unable to save workspace settings.');
}
const savedWorkspace = data.workspace;
setWorkspace(savedWorkspace);
setAuthSession((current) =>
current
? {
...current,
activeWorkspace: savedWorkspace,
workspaces: current.workspaces.map((entry) =>
entry.workspace.id === savedWorkspace.id ? { ...entry, workspace: savedWorkspace } : entry,
),
}
: current,
);
setWorkspaceForm({
name: savedWorkspace.name,
workspaceType: savedWorkspace.workspaceType,
billingEmail: savedWorkspace.billingEmail ?? '',
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
});
} catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save workspace settings.');
} finally {
setSavingWorkspace(false);
}
};
const handleWorkspaceMemberSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSavingWorkspaceMember(true);
try {
const response = await apiFetch('/workspace/members', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workspaceMemberForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to add rescue team member.'));
}
const data = (await readJsonSafely<{ member?: WorkspaceMember }>(response)) ?? {};
if (!data.member) {
throw new Error('Unable to add rescue team member.');
}
const nextMember = {
...data.member,
email: data.member.inviteEmail,
};
setWorkspaceMembers((current) => [...current, nextMember]);
setWorkspaceMemberForm(emptyWorkspaceMemberForm);
} catch (memberError) {
setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.');
} finally {
setSavingWorkspaceMember(false);
}
};
const handleRemoveWorkspaceMember = async (memberId: string) => {
setError('');
setRemovingWorkspaceMemberId(memberId);
try {
const response = await apiFetch(`/workspace/members/${memberId}`, authToken, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to remove rescue team member.'));
}
setWorkspaceMembers((current) => current.filter((member) => member.id !== memberId));
} catch (memberError) {
setError(memberError instanceof Error ? memberError.message : 'Unable to remove rescue team member.');
} finally {
setRemovingWorkspaceMemberId('');
}
};
if (authLoading) {
return (
<main className="auth-shell">
<section className="hero-card auth-hero-card">
<div>
<p className="eyebrow">FlockPal</p>
<h1>Loading your flock spaces...</h1>
<p className="muted">Checking your sign-in and workspace access.</p>
</div>
</section>
</main>
);
}
if (!authSession) {
return (
<main className="auth-shell">
<section className="panel auth-panel">
<div className="auth-copy">
<figure className="auth-illustration-card">
<img src={flockPalLandingArt} alt="FlockPal branding with a colorful trio of birds perched together." />
</figure>
<h1>A calmer way to care for every bird in your flock</h1>
<p className="muted">
Track weights, vet visits, hatchdays, gotcha days, and the little routines that help your birds thrive.
</p>
<div className="summary-grid">
<article className="summary-card">
<strong>Daily care, all together</strong>
<span>Keep reminders, records, and notes in one cheerful home for your flock.</span>
</article>
<article className="summary-card">
<strong>Made for real bird people</strong>
<span>From one spoiled companion bird to a lively whole flock, FlockPal keeps the details easy to revisit.</span>
</article>
</div>
</div>
<div className="auth-card">
<div>
<p className="eyebrow">Passwordless sign-in</p>
<h2>Email link or provider</h2>
<p className="muted">FlockPal no longer stores passwords. Use a magic link, Google, Microsoft, or Apple.</p>
</div>
{error ? <p className="error-banner">{error}</p> : null}
{authNotice ? (
<article className="summary-card">
<strong>{authNotice.message}</strong>
<span>
{authNotice.previewUrl ? (
<a href={authNotice.previewUrl}>Open the sign-in link</a>
) : (
'The link expires quickly and can only be used once.'
)}
</span>
</article>
) : null}
<form className="form-panel" onSubmit={handleAuthSubmit}>
<label>
Your name
<input value={authForm.name} onChange={(event) => setAuthForm({ ...authForm, name: event.target.value })} />
</label>
<label>
Email
<input
type="email"
value={authForm.email}
onChange={(event) => setAuthForm({ ...authForm, email: event.target.value })}
required
/>
</label>
<button className="primary-button" type="submit" disabled={authSubmitting}>
{authSubmitting ? 'Sending link...' : 'Email me a sign-in link'}
</button>
</form>
<div className="auth-provider-stack">
<p className="muted">Or continue with a common sign-in provider.</p>
{authProviders.map((provider) => (
<a
key={provider.providerKey}
className={`provider-button provider-${provider.providerKey} ${provider.enabled ? '' : 'disabled'}`}
href={provider.enabled ? oauthStartUrl(provider.providerKey) : undefined}
onClick={(event) => {
if (!provider.enabled) {
event.preventDefault();
}
}}
aria-disabled={!provider.enabled}
>
<span className="provider-button-mark" aria-hidden="true">
<ProviderIcon providerKey={provider.providerKey} />
</span>
<span className="provider-button-copy">
<strong>Continue with {provider.displayName}</strong>
<small>{provider.enabled ? 'Use your existing account to sign in.' : 'Not configured on this server yet.'}</small>
</span>
</a>
))}
</div>
</div>
</section>
</main>
);
}
if (loading) {
return (
<main className="app-shell">
<section className="hero-card">
<p>Loading flock data...</p>
</section>
</main>
);
}
const showWorkspaceSwitcher = authSession.workspaces.length > 1;
return (
<main className="app-shell">
<aside className="side-nav panel">
<div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview
</button>
<button className={`page-tab ${activePage === 'flock' ? 'active' : ''}`} onClick={() => setActivePage('flock')} type="button">
Flock
</button>
<button className={`page-tab ${activePage === 'settings' ? 'active' : ''}`} onClick={() => setActivePage('settings')} type="button">
Settings
</button>
</div>
{showWorkspaceSwitcher ? (
<section className="workspace-switcher">
<div className="workspace-switcher-list">
{authSession.workspaces.map((entry) => (
<button
key={`${entry.workspace.id}-${entry.membership.id}`}
className={`workspace-switcher-item ${entry.workspace.id === workspace?.id ? 'active' : ''}`}
onClick={() => handleWorkspaceSwitch(entry.workspace.id)}
type="button"
disabled={switchingWorkspaceId === entry.workspace.id}
>
<span>{entry.workspace.name}</span>
<small>
{formatBillingPlanName(entry.workspace.billingPlan)} {entry.membership.role}
</small>
</button>
))}
</div>
</section>
) : null}
<button className="secondary-button" onClick={handleLogout} type="button">
Log out
</button>
</aside>
<section className="content-shell">
{activePage !== 'settings' ? (
<section className="hero-card">
<div>
<p className="eyebrow">Dashboard</p>
<img className="dashboard-logo" src={flockPalLandingArt} alt="FlockPal" />
</div>
</section>
) : null}
{error ? <p className="error-banner">{error}</p> : null}
{activePage === 'overview' ? (
<section className="stack-grid">
<section className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Overview</p>
<h2>30-day flock weight snapshot</h2>
</div>
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p>
</div>
<div className="chart-card overview-chart-card">
<svg viewBox="0 0 520 220" className="weight-chart" role="img" aria-label="All birds weight overview">
{overviewChart.yTicks.map((tick) => (
<g key={tick.label}>
<line
x1={OVERVIEW_PADDING.left}
y1={tick.y}
x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
y2={tick.y}
className="chart-grid-line"
/>
<text x={OVERVIEW_PADDING.left - 10} y={tick.y + 4} textAnchor="end" className="chart-axis-label">
{tick.label}
</text>
</g>
))}
<line
x1={OVERVIEW_PADDING.left}
y1={OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom}
x2={OVERVIEW_WIDTH - OVERVIEW_PADDING.right}
y2={OVERVIEW_HEIGHT - OVERVIEW_PADDING.bottom}
className="chart-axis-line"
/>
{overviewChart.xTicks.map((tick) => (
<text key={tick.label} x={tick.x} y={OVERVIEW_HEIGHT - 10} textAnchor="middle" className="chart-axis-label">
{tick.label}
</text>
))}
{overviewChart.series.map(({ bird, points }) => (
<g key={bird.id}>
{points.length > 1 ? (
<path d={toOverviewPath(points)} fill="none" stroke={bird.chartColor} strokeWidth="3.5" strokeLinecap="round" />
) : null}
{points.map((point) => (
<circle key={point.id} cx={point.x} cy={point.y} r="4.5" fill={bird.chartColor}>
<title>{`${bird.name}: ${point.label}`}</title>
</circle>
))}
</g>
))}
</svg>
</div>
<div className="legend-grid">
{overviewChart.plottedBirds.map(({ bird }) => {
return (
<article key={bird.id} className="legend-card">
<span className="legend-swatch" style={{ background: bird.chartColor }} />
<div>
<strong>{bird.name}</strong>
</div>
</article>
);
})}
</div>
</section>
<section className="forms-grid">
<article className="panel form-panel pulse-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock health Pulse</p>
</div>
</div>
<div className="summary-grid">
{missingFirstWeightCount > 0 ? (
<article className="summary-card">
<strong>{missingFirstWeightCount}</strong>
<span>Members still needing a first weight</span>
</article>
) : null}
<article className="summary-card">
<span>Weekly flock changes</span>
{flockWeeklyTrendItems.length ? (
<div className="summary-list">
{flockWeeklyTrendItems.map((trendItem) => (
<strong key={trendItem.id} className="summary-trend-row">
<span className="summary-trend-name" style={{ color: trendItem.chartColor }}>
{trendItem.name}
</span>
<span>: </span>
<span
className={
trendItem.direction === 'positive' ? 'summary-trend-change positive' : 'summary-trend-change negative'
}
>
{trendItem.formattedChange}
</span>
</strong>
))}
</div>
) : (
<strong>No weekly changes yet</strong>
)}
</article>
</div>
</article>
</section>
</section>
) : null}
{activePage === 'flock' ? (
<section className={selectedBird ? 'dashboard-grid' : 'stack-grid'}>
<aside className="panel bird-list-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock</p>
<h2>Flock members</h2>
<p className="muted">Select a bird to view more details.</p>
</div>
<button className="secondary-button" onClick={startCreateBird} type="button">
Add bird
</button>
</div>
<div className="bird-list">
{birds.map((bird) => (
<button
key={bird.id}
className={`bird-card ${bird.id === selectedBird?.id ? 'active' : ''}`}
onClick={() => setSelectedBirdId(bird.id)}
type="button"
>
<div className="bird-card-header">
{bird.photoDataUrl ? (
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
) : (
<div className="bird-avatar placeholder-avatar" aria-hidden="true">
{bird.name.slice(0, 1).toUpperCase()}
</div>
)}
<div className="bird-card-copy">
<span>{bird.name}</span>
<small>{bird.species}</small>
</div>
</div>
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
</button>
))}
</div>
</aside>
{selectedBird ? (
<section className="panel flock-member-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock member</p>
<h2>{selectedBird.name}</h2>
</div>
<div className="button-row">
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
Edit details
</button>
<button className="danger-button" onClick={handleRemoveBird} type="button" disabled={deletingBird}>
{deletingBird ? 'Removing...' : 'Remove from flock'}
</button>
</div>
</div>
<>
<section className="profile-hero">
{selectedBird.photoDataUrl ? (
<img className="profile-photo" src={selectedBird.photoDataUrl} alt={`${selectedBird.name}`} />
) : (
<div className="profile-photo placeholder-avatar" aria-hidden="true">
{selectedBird.name.slice(0, 1).toUpperCase()}
</div>
)}
<div className="profile-copy">
<p className="eyebrow">Profile</p>
<h3>{selectedBird.name}</h3>
<p className="muted">
{selectedBird.species} Band {selectedBird.tagId}
</p>
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
</div>
</section>
<div className="detail-grid">
<article className="detail-card">
<span>Name</span>
<strong>{selectedBird.name}</strong>
</article>
<article className="detail-card">
<span>Band ID</span>
<strong>{selectedBird.tagId}</strong>
</article>
<article className="detail-card">
<span>DOB</span>
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
</article>
<article className="detail-card">
<span>Gotcha day</span>
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
</article>
<article className="detail-card">
<span>Species</span>
<strong>{selectedBird.species}</strong>
</article>
<article className="detail-card">
<span>Latest weight</span>
<strong>{formatWeight(selectedBird.latestWeightGrams)}</strong>
</article>
</div>
<div className="flock-member-sections">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Weight</p>
<h2>Trend and log</h2>
</div>
<p className="muted">Latest reading: {formatShortDate(selectedBird.latestRecordedOn)}</p>
</div>
<div className="chart-card">
<svg viewBox="0 0 520 180" className="weight-chart" role="img" aria-label="Selected flock member weight trend chart">
<defs>
<linearGradient id="lineGlow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={selectedBird.chartColor} stopOpacity="0.45" />
<stop offset="100%" stopColor={selectedBird.chartColor} />
</linearGradient>
</defs>
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
</svg>
<div className="chart-footer">
<p>{selectedBirdTrendCopy}</p>
<span>{weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}</span>
</div>
</div>
<form className="form-panel inline-form" onSubmit={handleWeightSubmit}>
<label>
Weight in grams
<input
type="number"
min="1"
step="0.1"
value={weightForm.weightGrams}
onChange={(event) => setWeightForm({ ...weightForm, weightGrams: event.target.value })}
required
/>
</label>
<label>
Recorded on
<input
type="date"
value={weightForm.recordedOn}
onChange={(event) => setWeightForm({ ...weightForm, recordedOn: event.target.value })}
required
/>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={weightForm.notes}
onChange={(event) => setWeightForm({ ...weightForm, notes: event.target.value })}
placeholder="Optional notes about appetite, molt, meds, or behavior"
/>
</label>
<button className="primary-button" type="submit">
Save weight
</button>
</form>
</section>
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Vet visits</p>
<h2>Care history and notes</h2>
</div>
</div>
<form className="form-panel inline-form" onSubmit={handleVetVisitSubmit}>
<label>
Visit date
<input
type="date"
value={vetVisitForm.visitedOn}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, visitedOn: event.target.value })}
required
/>
</label>
<label>
Clinic
<input
value={vetVisitForm.clinicName}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, clinicName: event.target.value })}
required
/>
</label>
<label>
Reason
<input
value={vetVisitForm.reason}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, reason: event.target.value })}
required
/>
</label>
<label className="wide-field">
Notes
<textarea
rows={3}
value={vetVisitForm.notes}
onChange={(event) => setVetVisitForm({ ...vetVisitForm, notes: event.target.value })}
placeholder="Exam notes, medications, follow-ups, or restrictions"
/>
</label>
<button className="primary-button" type="submit">
Save vet visit
</button>
</form>
<div className="recent-list">
{vetVisits.length ? (
vetVisits.map((visit) => (
<article key={visit.id} className="vet-visit-card">
<strong>{visit.reason}</strong>
<span>
{formatDate(visit.visitedOn)} {visit.clinicName}
</span>
<small>{visit.notes || 'No notes recorded.'}</small>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No vet visits yet</strong>
<small>Add the first visit above to start this care history.</small>
</article>
)}
</div>
</section>
</div>
</>
</section>
) : null}
</section>
) : null}
{activePage === 'settings' ? (
<section className="forms-grid settings-grid">
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Workspace</p>
<h2>Workspace profile and billing</h2>
</div>
</div>
<p className="muted">
Each workspace carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
participating in a rescue workspace without mixing billing or bird ownership.
</p>
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
<label>
Workspace name
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
</label>
<label>
Workspace type
<select
value={workspaceForm.workspaceType}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
workspaceType: event.target.value as WorkspaceFormState['workspaceType'],
})
}
>
<option value="standard">Standard household</option>
<option value="rescue">Rescue</option>
</select>
</label>
{workspaceForm.workspaceType === 'standard' ? (
<>
<label>
Household plan
<select
value={workspaceForm.billingPlan}
onChange={(event) =>
setWorkspaceForm({
...workspaceForm,
billingPlan: event.target.value as WorkspaceFormState['billingPlan'],
})
}
>
<option value="household_basic">Conure</option>
<option value="household_plus">Indian Ringneck</option>
<option value="household_macaw">Macaw</option>
</select>
</label>
<article className="summary-card">
<strong>{formatBillingPlanName(workspaceForm.billingPlan)}</strong>
<span>{formatBillingPlanCapacity(workspaceForm.billingPlan)}</span>
</article>
</>
) : (
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>Rescue workspaces stay free while still supporting shared team access.</span>
</article>
)}
<label>
Billing contact email
<input
type="email"
value={workspaceForm.billingEmail}
onChange={(event) => setWorkspaceForm({ ...workspaceForm, billingEmail: event.target.value })}
placeholder="Optional for later billing and account management"
/>
</label>
<button className="primary-button" type="submit" disabled={savingWorkspace}>
{savingWorkspace ? 'Saving workspace...' : 'Save workspace settings'}
</button>
</form>
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Billing</p>
<h2>Billing info</h2>
</div>
</div>
<div className="summary-grid">
<article className="summary-card">
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a workspace plan to see bird capacity.'}</span>
</article>
<article className="summary-card">
<strong>{workspace?.billingEmail || authSession.user.email}</strong>
<span>Billing contact for invoices, receipts, and account notices.</span>
</article>
<article className="summary-card">
<strong>
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
? `${birds.length} / ${formatBillingPlanBirdLimit(workspace.billingPlan)} birds`
: `${birds.length} bird${birds.length === 1 ? '' : 's'}`}
</strong>
<span>
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
? 'Current bird count against your paid plan allowance.'
: 'Current flock count in this workspace.'}
</span>
</article>
<article className="summary-card">
<strong>Stripe integration coming soon</strong>
<span>Customer portal, payment method management, invoices, and renewal status will appear here.</span>
</article>
</div>
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Collaborators</p>
<h2>Shared workspace access</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'collaborators' ? null : 'collaborators'))
}
type="button"
aria-expanded={expandedSettingsSection === 'collaborators'}
>
{expandedSettingsSection === 'collaborators' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'collaborators' ? (
<>
<p className="muted">
Invite other people to help manage this flock. Rescue workspaces support teams, and household workspaces can also support
co-caregivers without changing who owns the billing.
</p>
<form className="form-panel" onSubmit={handleWorkspaceMemberSubmit}>
<label>
Team member name
<input
value={workspaceMemberForm.name}
onChange={(event) => setWorkspaceMemberForm({ ...workspaceMemberForm, name: event.target.value })}
required
/>
</label>
<label>
Email
<input
type="email"
value={workspaceMemberForm.email}
onChange={(event) => setWorkspaceMemberForm({ ...workspaceMemberForm, email: event.target.value })}
required
/>
</label>
<label>
Role
<select
value={workspaceMemberForm.role}
onChange={(event) =>
setWorkspaceMemberForm({
...workspaceMemberForm,
role: event.target.value as WorkspaceMemberFormState['role'],
})
}
>
<option value="owner">Owner</option>
<option value="manager">Manager</option>
<option value="staff">Staff</option>
<option value="viewer">Viewer</option>
</select>
</label>
<button className="primary-button" type="submit" disabled={savingWorkspaceMember}>
{savingWorkspaceMember ? 'Adding member...' : 'Add collaborator'}
</button>
</form>
<div className="recent-list">
{workspaceMembers.length ? (
workspaceMembers.map((member) => (
<article key={member.id} className="vet-visit-card">
<strong>{member.name}</strong>
<span>
{member.role} {member.email || member.inviteEmail}
</span>
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
<button
className="secondary-button"
onClick={() => handleRemoveWorkspaceMember(member.id)}
type="button"
disabled={removingWorkspaceMemberId === member.id || member.role === 'owner'}
>
{member.role === 'owner' ? 'Owner' : removingWorkspaceMemberId === member.id ? 'Removing...' : 'Remove'}
</button>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No collaborators yet</strong>
<small>Add the people who should be able to help care for birds in this workspace.</small>
</article>
)}
</div>
</>
) : null}
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">New workspace</p>
<h2>Add another flock space</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'new-workspace' ? null : 'new-workspace'))
}
type="button"
aria-expanded={expandedSettingsSection === 'new-workspace'}
>
{expandedSettingsSection === 'new-workspace' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'new-workspace' ? (
<>
<p className="muted">
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate
for access and billing.
</p>
<form className="form-panel" onSubmit={handleCreateWorkspace}>
<label>
Workspace name
<input
value={workspaceCreateForm.name}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
required
/>
</label>
<label>
Workspace type
<select
value={workspaceCreateForm.workspaceType}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
workspaceType: event.target.value as WorkspaceCreateFormState['workspaceType'],
})
}
>
<option value="standard">Standard household</option>
<option value="rescue">Rescue</option>
</select>
</label>
{workspaceCreateForm.workspaceType === 'standard' ? (
<>
<label>
Household plan
<select
value={workspaceCreateForm.billingPlan}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
billingPlan: event.target.value as WorkspaceCreateFormState['billingPlan'],
})
}
>
<option value="household_basic">Conure</option>
<option value="household_plus">Indian Ringneck</option>
<option value="household_macaw">Macaw</option>
</select>
</label>
<article className="summary-card">
<strong>{formatBillingPlanName(workspaceCreateForm.billingPlan)}</strong>
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
</article>
</>
) : (
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue workspaces.</span>
</article>
)}
<label>
Billing contact email
<input
type="email"
value={workspaceCreateForm.billingEmail}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, billingEmail: event.target.value })}
placeholder="Used for household billing and account notices"
/>
</label>
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
{creatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
</button>
</form>
</>
) : null}
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Bird profiles</p>
<h2>{editingBird ? `Update ${editingBird.name}` : 'Flock member details'}</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'flock-member' ? null : 'flock-member'))
}
type="button"
aria-expanded={expandedSettingsSection === 'flock-member'}
>
{expandedSettingsSection === 'flock-member' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'flock-member' ? (
<>
<div className="button-row">
{selectedBird ? (
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
Edit selected profile
</button>
) : null}
<button className="secondary-button" onClick={startCreateBird} type="button">
New bird profile
</button>
</div>
<div className="picker-list">
{birds.map((bird) => (
<button
key={bird.id}
className={`picker-chip ${editingBirdId === bird.id ? 'active' : ''}`}
onClick={() => startEditBird(bird)}
type="button"
>
{bird.name}
</button>
))}
</div>
<form className="form-panel" onSubmit={handleBirdSubmit}>
<label>
Bird name
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
</label>
<label>
Species
<input value={birdForm.species} onChange={(event) => setBirdForm({ ...birdForm, species: event.target.value })} required />
</label>
<label>
DOB
<input
type="date"
value={birdForm.dateOfBirth}
onChange={(event) => setBirdForm({ ...birdForm, dateOfBirth: event.target.value })}
/>
</label>
<label>
Gotcha day
<input
type="date"
value={birdForm.gotchaDay}
onChange={(event) => setBirdForm({ ...birdForm, gotchaDay: event.target.value })}
/>
</label>
<label className="toggle-card">
<span>Notify on DOB</span>
<input
type="checkbox"
checked={birdForm.notifyOnDob}
onChange={(event) => setBirdForm({ ...birdForm, notifyOnDob: event.target.checked })}
/>
<small className="muted">Send a reminder on this bird's birthday each year.</small>
</label>
<label className="toggle-card">
<span>Notify on gotcha day</span>
<input
type="checkbox"
checked={birdForm.notifyOnGotchaDay}
onChange={(event) => setBirdForm({ ...birdForm, notifyOnGotchaDay: event.target.checked })}
/>
<small className="muted">Send a reminder on this bird's gotcha day anniversary.</small>
</label>
<label>
Graph color
<input type="color" value={birdForm.chartColor} onChange={(event) => setBirdForm({ ...birdForm, chartColor: event.target.value })} />
</label>
<div className="color-preview-card">
<span className="legend-swatch large-swatch" style={{ background: birdForm.chartColor }} />
<p className="muted">This color will follow this bird across the overview graph and its individual weight trend.</p>
</div>
<div className="photo-editor">
<div className="photo-preview-shell">
{photoCrop ? (
<div
className={`crop-preview-frame ${photoDrag ? 'dragging' : ''}`}
onPointerDown={handlePhotoCropPointerDown}
onPointerMove={handlePhotoCropPointerMove}
onPointerUp={handlePhotoCropPointerUp}
onPointerCancel={handlePhotoCropPointerUp}
>
<img
className="crop-preview-image"
src={photoCrop.sourceDataUrl}
alt="Crop preview"
style={{
width: `${getPhotoCropMetrics(photoCrop).displayWidth}px`,
height: `${getPhotoCropMetrics(photoCrop).displayHeight}px`,
left: `${getPhotoCropMetrics(photoCrop).left}px`,
top: `${getPhotoCropMetrics(photoCrop).top}px`,
}}
/>
<div className="crop-preview-overlay" aria-hidden="true" />
</div>
) : birdForm.photoDataUrl ? (
<img className="profile-photo" src={birdForm.photoDataUrl} alt="Bird preview" />
) : (
<div className="profile-photo placeholder-avatar" aria-hidden="true">
{(birdForm.name || 'B').slice(0, 1).toUpperCase()}
</div>
)}
</div>
<div className="photo-copy">
<label className="file-picker">
Photo
<input accept="image/png,image/jpeg,image/jpg,image/webp,image/gif" onChange={handleBirdPhotoChange} type="file" />
</label>
<p className="muted">
{photoCrop
? `${photoCrop.fileName} ready to crop. Adjust it below and save the crop.`
: birdPhotoName || (birdForm.photoDataUrl ? 'Current photo ready to save.' : 'Optional. Great for quick identification.')}
</p>
{photoCrop ? (
<div className="crop-control-stack">
<p className="muted">Drag the photo to position it inside the square crop. The saved image will match this preview.</p>
<label>
Zoom
<input
type="range"
min="1"
max="3"
step="0.01"
value={photoCrop.zoom}
onChange={(event) =>
setPhotoCrop((current) => (current ? { ...current, zoom: Number(event.target.value) } : current))
}
/>
</label>
<div className="button-row">
<button className="primary-button" onClick={handleApplyPhotoCrop} type="button" disabled={applyingPhotoCrop}>
{applyingPhotoCrop ? 'Applying crop...' : 'Apply crop'}
</button>
<button className="secondary-button" onClick={handleCancelPhotoCrop} type="button" disabled={applyingPhotoCrop}>
Cancel
</button>
</div>
<p className="muted">Photos are cropped to a square and optimized automatically to stay within the current upload limit.</p>
</div>
) : birdForm.photoDataUrl ? (
<button className="secondary-button" onClick={handleRemovePhoto} type="button">
Remove photo
</button>
) : null}
</div>
</div>
<button className="primary-button" type="submit" disabled={savingBird}>
{savingBird ? 'Saving...' : editingBird ? 'Save profile changes' : 'Save bird profile'}
</button>
</form>
</>
) : null}
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Settings</p>
<h2>Bird transfer prep</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'transfer' ? null : 'transfer'))
}
type="button"
aria-expanded={expandedSettingsSection === 'transfer'}
>
{expandedSettingsSection === 'transfer' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'transfer' ? (
<>
<p className="muted">
This is the first step toward rescue handoffs and owner-to-owner transfers. For now it captures the matching details we would
later use to safely move a bird record between accounts.
</p>
<form className="form-panel" onSubmit={handleMergeSubmit}>
<label>
Bird to transfer
<select
value={mergeForm.birdId}
onChange={(event) => setMergeForm({ ...mergeForm, birdId: event.target.value })}
required
>
<option value="">Select a bird from this flock</option>
{birds.map((bird) => (
<option key={bird.id} value={bird.id}>
{bird.name} {bird.species} Band {bird.tagId}
</option>
))}
</select>
</label>
<label>
Destination owner email
<input
type="email"
value={mergeForm.destinationOwnerEmail}
onChange={(event) => setMergeForm({ ...mergeForm, destinationOwnerEmail: event.target.value })}
required
/>
</label>
<label>
Transfer notes
<textarea
rows={4}
value={mergeForm.notes}
onChange={(event) => setMergeForm({ ...mergeForm, notes: event.target.value })}
placeholder="Optional context for rescue release, adoption, or household transfer"
/>
</label>
<button className="primary-button" type="submit">
Save transfer draft
</button>
</form>
</>
) : null}
</article>
</section>
) : null}
</section>
</main>
);
}
export default App;