4303 lines
161 KiB
TypeScript
4303 lines
161 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import flockPalLandingArt from './assets/flockpal-landing-art.png';
|
|
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
|
|
|
|
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
|
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
|
type BillingInterval = 'monthly' | 'yearly';
|
|
type WorkspaceType = 'standard' | 'rescue';
|
|
type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
|
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
|
type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
|
type IntegrationTokenScope = 'read_only' | 'read_write';
|
|
type BirdGender = 'unknown' | 'male' | 'female';
|
|
|
|
type Bird = {
|
|
id: string;
|
|
workspaceId?: number;
|
|
name: string;
|
|
tagId: string;
|
|
species: string;
|
|
gender: BirdGender;
|
|
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;
|
|
billingInterval: BillingInterval;
|
|
subscriptionStatus: SubscriptionStatus;
|
|
stripeCustomerId: string | null;
|
|
stripeSubscriptionId: string | null;
|
|
rescueVerificationStatus: RescueVerificationStatus;
|
|
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[];
|
|
isAdmin: boolean;
|
|
providers: AuthProvider[];
|
|
};
|
|
|
|
type AdminSummary = {
|
|
totalBirds: number;
|
|
totalUsers: number;
|
|
totalWorkspaces: number;
|
|
rescueWorkspaces: number;
|
|
pendingRescues: number;
|
|
dailyUsers: number;
|
|
};
|
|
|
|
type AdminRescueWorkspace = {
|
|
workspace: Workspace;
|
|
ownerEmail: string | null;
|
|
birdCount: number;
|
|
memberCount: number;
|
|
};
|
|
|
|
type IntegrationTokenSummary = {
|
|
id: string;
|
|
userId: string;
|
|
workspaceId: number;
|
|
name: string;
|
|
tokenPrefix: string;
|
|
scope: IntegrationTokenScope;
|
|
lastUsedAt: string | null;
|
|
expiresAt: string | null;
|
|
revokedAt: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
type IntegrationTokenFormState = {
|
|
name: string;
|
|
scope: IntegrationTokenScope;
|
|
expiresInDays: string;
|
|
};
|
|
|
|
type BirdFormState = {
|
|
name: string;
|
|
tagId: string;
|
|
species: string;
|
|
gender: BirdGender;
|
|
dateOfBirth: string;
|
|
gotchaDay: string;
|
|
chartColor: string;
|
|
photoDataUrl: string;
|
|
notifyOnDob: boolean;
|
|
notifyOnGotchaDay: boolean;
|
|
};
|
|
|
|
type WorkspaceFormState = {
|
|
name: string;
|
|
workspaceType: WorkspaceType;
|
|
billingEmail: string;
|
|
billingPlan: HouseholdBillingPlan;
|
|
billingInterval: BillingInterval;
|
|
};
|
|
|
|
type WorkspaceMemberFormState = {
|
|
name: string;
|
|
email: string;
|
|
role: WorkspaceRole;
|
|
};
|
|
|
|
type WorkspaceCreateFormState = {
|
|
name: string;
|
|
workspaceType: WorkspaceType;
|
|
billingEmail: string;
|
|
billingPlan: HouseholdBillingPlan;
|
|
billingInterval: BillingInterval;
|
|
};
|
|
|
|
type AuthFormState = {
|
|
name: string;
|
|
email: string;
|
|
};
|
|
|
|
type AuthNotice = {
|
|
message: string;
|
|
previewUrl?: string | null;
|
|
};
|
|
|
|
type BulkWeightRowState = {
|
|
weightGrams: string;
|
|
};
|
|
|
|
type BirdWeightAssessment =
|
|
| {
|
|
status: 'no_match';
|
|
reference: null;
|
|
}
|
|
| {
|
|
status: 'no_weight';
|
|
reference: ParrotWeightReference;
|
|
}
|
|
| {
|
|
status: 'reference_only';
|
|
reference: Extract<ParrotWeightReference, { kind: 'approximate' }>;
|
|
}
|
|
| {
|
|
status: 'within' | 'below' | 'above';
|
|
reference: Extract<ParrotWeightReference, { kind: 'range' }>;
|
|
varianceGrams: number;
|
|
};
|
|
|
|
type OutOfRangeBirdWeightAssessment = {
|
|
status: 'below' | 'above';
|
|
reference: Extract<ParrotWeightReference, { kind: 'range' }>;
|
|
varianceGrams: number;
|
|
};
|
|
|
|
type WeightDropAlert = {
|
|
bird: Bird;
|
|
previousWeight: WeightRecord;
|
|
latestWeight: WeightRecord;
|
|
dropPercent: number;
|
|
};
|
|
|
|
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' | 'admin';
|
|
type SettingsSection = 'collaborators' | 'integration-tokens' | '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: '',
|
|
gender: 'unknown',
|
|
dateOfBirth: '',
|
|
gotchaDay: '',
|
|
chartColor: '#cb3a35',
|
|
photoDataUrl: '',
|
|
notifyOnDob: false,
|
|
notifyOnGotchaDay: false,
|
|
};
|
|
|
|
const emptyWorkspaceForm: WorkspaceFormState = {
|
|
name: 'My Flock',
|
|
workspaceType: 'standard',
|
|
billingEmail: '',
|
|
billingPlan: 'household_basic',
|
|
billingInterval: 'monthly',
|
|
};
|
|
|
|
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
|
|
name: '',
|
|
email: '',
|
|
role: 'caregiver',
|
|
};
|
|
|
|
const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
|
|
name: '',
|
|
workspaceType: 'standard',
|
|
billingEmail: '',
|
|
billingPlan: 'household_basic',
|
|
billingInterval: 'monthly',
|
|
};
|
|
|
|
const emptyAuthForm: AuthFormState = {
|
|
name: '',
|
|
email: '',
|
|
};
|
|
|
|
const emptyIntegrationTokenForm: IntegrationTokenFormState = {
|
|
name: '',
|
|
scope: 'read_write',
|
|
expiresInDays: '',
|
|
};
|
|
|
|
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,
|
|
gender: bird.gender,
|
|
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 getBirdGenderLabel = (bird: Pick<Bird, 'gender'>) => {
|
|
if (bird.gender === 'female') {
|
|
return 'Female';
|
|
}
|
|
if (bird.gender === 'male') {
|
|
return 'Male';
|
|
}
|
|
return 'Unknown';
|
|
};
|
|
|
|
const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
|
|
if (bird.gender === 'female') {
|
|
return '♀';
|
|
}
|
|
if (bird.gender === 'male') {
|
|
return '♂';
|
|
}
|
|
return '?';
|
|
};
|
|
|
|
const formatDateTime = (value: string | null) => {
|
|
if (!value) {
|
|
return 'Never';
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(new Date(value));
|
|
};
|
|
|
|
const formatWeight = (value: number | null) => (value ? `${value.toFixed(1)} g` : 'Pending');
|
|
const formatRange = (minGrams: number, maxGrams: number) => `${minGrams.toFixed(0)}-${maxGrams.toFixed(0)} g`;
|
|
const parseDateValue = (value: string) => new Date(`${value}T00:00:00`);
|
|
const daysBetweenDates = (startDate: string, endDate: string) =>
|
|
Math.abs(parseDateValue(endDate).getTime() - parseDateValue(startDate).getTime()) / (24 * 60 * 60 * 1000);
|
|
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 MEMBER_CHART_WIDTH = 520;
|
|
const MEMBER_CHART_HEIGHT = 180;
|
|
const MEMBER_CHART_PADDING = { top: 16, right: 18, bottom: 34, left: 52 };
|
|
|
|
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,
|
|
cache: 'no-store',
|
|
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`, window.location.origin);
|
|
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 formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly');
|
|
|
|
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 flocks.';
|
|
}
|
|
|
|
if (billingPlan === 'household_basic') {
|
|
return 'Permits up to 4 birds in the flock.';
|
|
}
|
|
|
|
if (billingPlan === 'household_plus') {
|
|
return 'Permits 5 to 10 birds in the flock.';
|
|
}
|
|
|
|
return 'Permits 11 or more birds in the flock.';
|
|
};
|
|
|
|
const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => {
|
|
if (billingPlan === 'household_basic') {
|
|
return 'Conure (4 birds)';
|
|
}
|
|
|
|
if (billingPlan === 'household_plus') {
|
|
return 'Indian Ringneck (10 birds)';
|
|
}
|
|
|
|
return 'Macaw (11+)';
|
|
};
|
|
|
|
const householdPlanPrices: Record<HouseholdBillingPlan, Record<BillingInterval, string>> = {
|
|
household_basic: {
|
|
monthly: '$4.99/month',
|
|
yearly: '$50/year',
|
|
},
|
|
household_plus: {
|
|
monthly: '$8.99/month',
|
|
yearly: '$90/year',
|
|
},
|
|
household_macaw: {
|
|
monthly: '$15.99/month',
|
|
yearly: '$160/year',
|
|
},
|
|
};
|
|
|
|
const formatBillingIntervalDropdownLabel = (billingPlan: HouseholdBillingPlan, billingInterval: BillingInterval) =>
|
|
`${formatBillingIntervalName(billingInterval)} (${householdPlanPrices[billingPlan][billingInterval]})`;
|
|
|
|
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 formatSubscriptionStatus = (status: SubscriptionStatus) => {
|
|
if (status === 'trialing') {
|
|
return 'Trialing';
|
|
}
|
|
if (status === 'past_due') {
|
|
return 'Past due';
|
|
}
|
|
if (status === 'canceled') {
|
|
return 'Canceled';
|
|
}
|
|
if (status === 'unpaid') {
|
|
return 'Unpaid';
|
|
}
|
|
if (status === 'none') {
|
|
return 'No subscription';
|
|
}
|
|
return 'Active';
|
|
};
|
|
|
|
const subscriptionAllowsFlockWrites = (status: SubscriptionStatus) => status === 'active' || status === 'trialing';
|
|
|
|
const formatFlockAccessStatus = (status: SubscriptionStatus) => (subscriptionAllowsFlockWrites(status) ? formatSubscriptionStatus(status) : 'Read-only');
|
|
|
|
const formatFlockAccessDescription = (status: SubscriptionStatus) =>
|
|
subscriptionAllowsFlockWrites(status)
|
|
? 'This flock is writable while the subscription is active.'
|
|
: `This flock is read-only until billing is restored. Current subscription status: ${formatSubscriptionStatus(status)}.`;
|
|
|
|
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
|
|
if (status === 'approved') {
|
|
return 'Active';
|
|
}
|
|
if (status === 'rejected') {
|
|
return 'Rejected';
|
|
}
|
|
if (status === 'not_required') {
|
|
return 'Not required';
|
|
}
|
|
return 'Pending verification';
|
|
};
|
|
|
|
const formatWorkspaceRole = (role: WorkspaceRole) => {
|
|
if (role === 'owner') {
|
|
return 'Owner';
|
|
}
|
|
if (role === 'assistant') {
|
|
return 'Assistant';
|
|
}
|
|
if (role === 'caregiver') {
|
|
return 'Caregiver';
|
|
}
|
|
return 'Viewer';
|
|
};
|
|
|
|
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(' ');
|
|
|
|
const assessBirdWeight = (bird: Bird): BirdWeightAssessment => {
|
|
const reference = findParrotWeightReference(bird.species);
|
|
|
|
if (!reference) {
|
|
return {
|
|
status: 'no_match',
|
|
reference: null,
|
|
};
|
|
}
|
|
|
|
if (bird.latestWeightGrams === null) {
|
|
return {
|
|
status: 'no_weight',
|
|
reference,
|
|
};
|
|
}
|
|
|
|
if (reference.kind === 'approximate') {
|
|
return {
|
|
status: 'reference_only',
|
|
reference,
|
|
};
|
|
}
|
|
|
|
if (bird.latestWeightGrams < reference.minGrams) {
|
|
return {
|
|
status: 'below',
|
|
reference,
|
|
varianceGrams: reference.minGrams - bird.latestWeightGrams,
|
|
};
|
|
}
|
|
|
|
if (bird.latestWeightGrams > reference.maxGrams) {
|
|
return {
|
|
status: 'above',
|
|
reference,
|
|
varianceGrams: bird.latestWeightGrams - reference.maxGrams,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'within',
|
|
reference,
|
|
varianceGrams: 0,
|
|
};
|
|
};
|
|
|
|
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 [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
|
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
|
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
|
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 [integrationTokenForm, setIntegrationTokenForm] = useState<IntegrationTokenFormState>(emptyIntegrationTokenForm);
|
|
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 [cancelingRescueRequest, setCancelingRescueRequest] = useState(false);
|
|
const [billingRedirecting, setBillingRedirecting] = useState(false);
|
|
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
|
|
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
|
|
const [deletingWorkspace, setDeletingWorkspace] = useState(false);
|
|
const [creatingIntegrationToken, setCreatingIntegrationToken] = useState(false);
|
|
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
|
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
|
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
|
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
|
|
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
|
|
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
|
|
const [bulkWeightOpen, setBulkWeightOpen] = useState(false);
|
|
const [savingBulkWeights, setSavingBulkWeights] = useState(false);
|
|
const [bulkWeightDate, setBulkWeightDate] = useState(new Date().toISOString().slice(0, 10));
|
|
const [bulkWeightRows, setBulkWeightRows] = useState<Record<string, BulkWeightRowState>>({});
|
|
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 [flockTransferForm, setFlockTransferForm] = useState({
|
|
birdId: '',
|
|
destinationOwnerEmail: '',
|
|
});
|
|
const [transferringBird, setTransferringBird] = useState(false);
|
|
const [transferError, setTransferError] = useState('');
|
|
const [transferNotice, setTransferNotice] = useState<{
|
|
message: string;
|
|
previewUrl?: string | null;
|
|
} | null>(null);
|
|
const [deletingBird, setDeletingBird] = useState(false);
|
|
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
|
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
|
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 showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird);
|
|
|
|
const missingFirstWeightCount = useMemo(
|
|
() => birds.filter((bird) => bird.latestWeightGrams === null).length,
|
|
[birds],
|
|
);
|
|
|
|
const birdWeightAssessments = useMemo(
|
|
() =>
|
|
Object.fromEntries(
|
|
birds.map((bird) => [
|
|
bird.id,
|
|
assessBirdWeight(bird),
|
|
]),
|
|
) as Record<string, BirdWeightAssessment>,
|
|
[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 outOfRangeBirds = useMemo(
|
|
() =>
|
|
birds
|
|
.map((bird) => {
|
|
const assessment = birdWeightAssessments[bird.id];
|
|
|
|
if (!assessment || (assessment.status !== 'below' && assessment.status !== 'above')) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
bird,
|
|
assessment: assessment as OutOfRangeBirdWeightAssessment,
|
|
};
|
|
})
|
|
.filter((item): item is { bird: Bird; assessment: OutOfRangeBirdWeightAssessment } => item !== null),
|
|
[birdWeightAssessments, birds],
|
|
);
|
|
|
|
const weightDropAlerts = useMemo(
|
|
() =>
|
|
birds
|
|
.map((bird) => {
|
|
const birdWeights = [...(allBirdWeights[bird.id] ?? [])].sort(
|
|
(firstEntry, secondEntry) => parseDateValue(firstEntry.recordedOn).getTime() - parseDateValue(secondEntry.recordedOn).getTime(),
|
|
);
|
|
|
|
if (birdWeights.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const latestWeight = birdWeights[birdWeights.length - 1];
|
|
const previousWeight = birdWeights[birdWeights.length - 2];
|
|
|
|
if (previousWeight.weightGrams <= 0 || daysBetweenDates(previousWeight.recordedOn, latestWeight.recordedOn) > 2) {
|
|
return null;
|
|
}
|
|
|
|
const dropPercent = ((previousWeight.weightGrams - latestWeight.weightGrams) / previousWeight.weightGrams) * 100;
|
|
|
|
if (dropPercent < 5 || dropPercent > 10) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
bird,
|
|
previousWeight,
|
|
latestWeight,
|
|
dropPercent,
|
|
};
|
|
})
|
|
.filter((alert): alert is WeightDropAlert => alert !== null),
|
|
[allBirdWeights, birds],
|
|
);
|
|
|
|
const totalWeightAlerts = outOfRangeBirds.length + weightDropAlerts.length;
|
|
|
|
const filteredSpeciesOptions = useMemo(() => {
|
|
const query = birdForm.species.trim().toLowerCase();
|
|
|
|
if (!query) {
|
|
return parrotSpeciesOptions.slice(0, 12);
|
|
}
|
|
|
|
return parrotSpeciesOptions
|
|
.filter((speciesOption) => speciesOption.toLowerCase().includes(query))
|
|
.slice(0, 12);
|
|
}, [birdForm.species]);
|
|
|
|
const selectedBirdChart = useMemo(() => {
|
|
if (!weights.length) {
|
|
return {
|
|
points: [] as { id: string; x: number; y: number; label: string }[],
|
|
path: '',
|
|
isFlat: false,
|
|
yTicks: [] as { label: string; y: number }[],
|
|
xTicks: [] as { label: string; x: number }[],
|
|
};
|
|
}
|
|
|
|
const rawMinWeight = Math.min(...weights.map((entry) => entry.weightGrams));
|
|
const rawMaxWeight = Math.max(...weights.map((entry) => entry.weightGrams));
|
|
const isFlat = Math.abs(rawMaxWeight - rawMinWeight) < 0.01;
|
|
const padding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
|
|
const minWeight = Math.max(0, rawMinWeight - padding);
|
|
const maxWeight = rawMaxWeight + padding;
|
|
const innerWidth = MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.left - MEMBER_CHART_PADDING.right;
|
|
const innerHeight = MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.top - MEMBER_CHART_PADDING.bottom;
|
|
const startDate = parseDateValue(weights[0].recordedOn);
|
|
const endDate = parseDateValue(weights[weights.length - 1].recordedOn);
|
|
const startMs = startDate.getTime();
|
|
const endMs = endDate.getTime();
|
|
const dateSpread = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
|
|
const weightSpread = Math.max(maxWeight - minWeight, 1);
|
|
|
|
const points = weights.map((entry) => {
|
|
const pointTime = parseDateValue(entry.recordedOn).getTime();
|
|
const x = MEMBER_CHART_PADDING.left + ((pointTime - startMs) / dateSpread) * innerWidth;
|
|
const y = MEMBER_CHART_PADDING.top + (1 - (entry.weightGrams - minWeight) / weightSpread) * innerHeight;
|
|
|
|
return {
|
|
id: entry.id,
|
|
x,
|
|
y,
|
|
label: `${entry.weightGrams.toFixed(1)} g on ${formatShortDate(entry.recordedOn)}`,
|
|
};
|
|
});
|
|
|
|
const path = toOverviewPath(points);
|
|
const midWeight = minWeight + (maxWeight - minWeight) / 2;
|
|
const midDate = new Date((startMs + endMs) / 2);
|
|
|
|
return {
|
|
points,
|
|
path,
|
|
isFlat,
|
|
yTicks: [
|
|
{ label: `${maxWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top },
|
|
{ label: `${midWeight.toFixed(0)} g`, y: MEMBER_CHART_PADDING.top + innerHeight / 2 },
|
|
{ label: `${minWeight.toFixed(0)} g`, y: MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom },
|
|
],
|
|
xTicks: [
|
|
{ label: formatShortDate(weights[0].recordedOn), x: MEMBER_CHART_PADDING.left },
|
|
{ label: formatShortDate(midDate.toISOString().slice(0, 10)), x: MEMBER_CHART_WIDTH / 2 },
|
|
{ label: formatShortDate(weights[weights.length - 1].recordedOn), x: MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right },
|
|
],
|
|
};
|
|
}, [weights]);
|
|
|
|
const hasSelectedBirdLine = selectedBirdChart.points.length >= 2 && selectedBirdChart.path.length > 0;
|
|
|
|
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);
|
|
setNewIntegrationTokenSecret('');
|
|
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',
|
|
billingInterval: session.activeWorkspace.billingInterval,
|
|
});
|
|
setWorkspaceCreateForm((current) => ({
|
|
...current,
|
|
billingEmail: current.billingEmail || session.user.email,
|
|
}));
|
|
};
|
|
|
|
const clearAppSession = () => {
|
|
clearSessionToken();
|
|
setAuthToken('');
|
|
setAuthSession(null);
|
|
setWorkspace(null);
|
|
setActiveMembership(null);
|
|
setWorkspaceMembers([]);
|
|
setIntegrationTokens([]);
|
|
setAdminSummary(null);
|
|
setAdminRescueWorkspaces([]);
|
|
setBirds([]);
|
|
setWeights([]);
|
|
setVetVisits([]);
|
|
setAllBirdWeights({});
|
|
setSelectedBirdId('');
|
|
setEditingBirdId('');
|
|
setWorkspaceForm(emptyWorkspaceForm);
|
|
setWorkspaceCreateForm(emptyWorkspaceCreateForm);
|
|
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
|
setNewIntegrationTokenSecret('');
|
|
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 loadWorkspaceData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [birdsResponse, membersResponse, integrationTokensResponse] = await Promise.all([
|
|
apiFetch('/birds', authToken),
|
|
apiFetch('/workspace/members', authToken),
|
|
apiFetch('/integration-tokens', 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([]);
|
|
}
|
|
|
|
if (integrationTokensResponse.ok) {
|
|
const integrationTokensData =
|
|
(await readJsonSafely<{ integrationTokens?: IntegrationTokenSummary[] }>(integrationTokensResponse)) ?? {};
|
|
setIntegrationTokens(integrationTokensData.integrationTokens ?? []);
|
|
} else {
|
|
setIntegrationTokens([]);
|
|
}
|
|
} catch (loadError) {
|
|
setError(loadError instanceof Error ? loadError.message : 'Unable to load flock members.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
void loadWorkspaceData();
|
|
}, [authToken, workspace?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!authToken || !authSession?.isAdmin || activePage !== 'admin') {
|
|
return;
|
|
}
|
|
|
|
const loadAdminDashboard = async () => {
|
|
try {
|
|
const [summaryResponse, rescuesResponse] = await Promise.all([
|
|
apiFetch('/admin/summary', authToken),
|
|
apiFetch('/admin/rescue-workspaces', authToken),
|
|
]);
|
|
|
|
if (!summaryResponse.ok || !rescuesResponse.ok) {
|
|
throw new Error('Unable to load admin dashboard.');
|
|
}
|
|
|
|
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
|
|
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
|
|
|
|
setAdminSummary(summaryData.summary ?? null);
|
|
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
|
|
} catch (adminError) {
|
|
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
|
|
}
|
|
};
|
|
|
|
void loadAdminDashboard();
|
|
}, [activePage, authSession?.isAdmin, authToken]);
|
|
|
|
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 ?? []);
|
|
setEditingVetVisitId('');
|
|
setDeletingVetVisitId('');
|
|
} 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]);
|
|
|
|
useEffect(() => {
|
|
setBulkWeightRows((current) => {
|
|
const nextEntries = birds.map((bird) => [bird.id, current[bird.id] ?? { weightGrams: '' }] as const);
|
|
return Object.fromEntries(nextEntries);
|
|
});
|
|
}, [birds]);
|
|
|
|
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 flocks.'));
|
|
}
|
|
|
|
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
|
|
|
if (!data.session) {
|
|
throw new Error('Unable to switch flocks.');
|
|
}
|
|
|
|
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 flocks.');
|
|
} finally {
|
|
setSwitchingWorkspaceId(null);
|
|
}
|
|
};
|
|
|
|
const handleCreateIntegrationToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
if (!authToken) {
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setCreatingIntegrationToken(true);
|
|
|
|
try {
|
|
const response = await apiFetch('/integration-tokens', authToken, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: integrationTokenForm.name.trim(),
|
|
scope: integrationTokenForm.scope,
|
|
expiresInDays: integrationTokenForm.expiresInDays ? Number(integrationTokenForm.expiresInDays) : undefined,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to create integration token.'));
|
|
}
|
|
|
|
const data =
|
|
(await readJsonSafely<{ integrationToken?: IntegrationTokenSummary; token?: string }>(response)) ?? {};
|
|
|
|
if (!data.integrationToken || !data.token) {
|
|
throw new Error('Unable to create integration token.');
|
|
}
|
|
|
|
setIntegrationTokens((current) => [data.integrationToken!, ...current]);
|
|
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
|
setNewIntegrationTokenSecret(data.token);
|
|
setExpandedSettingsSection('integration-tokens');
|
|
} catch (integrationTokenError) {
|
|
setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to create integration token.');
|
|
} finally {
|
|
setCreatingIntegrationToken(false);
|
|
}
|
|
};
|
|
|
|
const handleRevokeIntegrationToken = async (tokenId: string) => {
|
|
if (!authToken) {
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setRevokingIntegrationTokenId(tokenId);
|
|
|
|
try {
|
|
const response = await apiFetch(`/integration-tokens/${tokenId}`, authToken, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to revoke integration token.'));
|
|
}
|
|
|
|
setIntegrationTokens((current) => current.filter((token) => token.id !== tokenId));
|
|
} catch (integrationTokenError) {
|
|
setError(integrationTokenError instanceof Error ? integrationTokenError.message : 'Unable to revoke integration token.');
|
|
} finally {
|
|
setRevokingIntegrationTokenId('');
|
|
}
|
|
};
|
|
|
|
const handleRescueVerificationStatusChange = async (workspaceId: number, rescueVerificationStatus: RescueVerificationStatus) => {
|
|
if (!authToken) {
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setUpdatingRescueWorkspaceId(workspaceId);
|
|
|
|
try {
|
|
const response = await apiFetch(`/admin/rescue-workspaces/${workspaceId}`, authToken, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rescueVerificationStatus }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to update rescue verification status.'));
|
|
}
|
|
|
|
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
|
|
|
if (!data.workspace) {
|
|
throw new Error('Unable to update rescue verification status.');
|
|
}
|
|
|
|
const nextRescueWorkspaces = adminRescueWorkspaces
|
|
.map((entry) => (entry.workspace.id === workspaceId ? { ...entry, workspace: data.workspace! } : entry))
|
|
.filter((entry) => entry.workspace.workspaceType === 'rescue');
|
|
|
|
setAdminRescueWorkspaces(nextRescueWorkspaces);
|
|
setAdminSummary((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
rescueWorkspaces: nextRescueWorkspaces.length,
|
|
pendingRescues: nextRescueWorkspaces.filter((entry) => entry.workspace.rescueVerificationStatus === 'pending').length,
|
|
}
|
|
: current,
|
|
);
|
|
} catch (adminError) {
|
|
setError(adminError instanceof Error ? adminError.message : 'Unable to update rescue verification status.');
|
|
} finally {
|
|
setUpdatingRescueWorkspaceId(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,
|
|
billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to create flock.'));
|
|
}
|
|
|
|
const workspaceResponse = await apiFetch('/auth/session', authToken);
|
|
if (!workspaceResponse.ok) {
|
|
throw new Error(await readErrorMessage(workspaceResponse, 'Flock 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 flock 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 flock.');
|
|
} 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 handleBulkWeightValueChange = (birdId: string, weightGrams: string) => {
|
|
setBulkWeightRows((current) => ({
|
|
...current,
|
|
[birdId]: {
|
|
weightGrams,
|
|
},
|
|
}));
|
|
};
|
|
|
|
const handleBulkWeightSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
const entries = birds
|
|
.map((bird) => ({
|
|
bird,
|
|
weightGrams: bulkWeightRows[bird.id]?.weightGrams?.trim() ?? '',
|
|
}))
|
|
.filter((entry) => entry.weightGrams);
|
|
|
|
if (!entries.length) {
|
|
setError('Add at least one weight before saving the bulk update.');
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setSavingBulkWeights(true);
|
|
|
|
try {
|
|
const savedWeights: Record<string, WeightRecord> = {};
|
|
|
|
for (const entry of entries) {
|
|
const response = await apiFetch(`/birds/${entry.bird.id}/weights`, authToken, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
weightGrams: Number(entry.weightGrams),
|
|
recordedOn: bulkWeightDate,
|
|
notes: '',
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, `Unable to save weight for ${entry.bird.name}.`));
|
|
}
|
|
|
|
const data = await readJsonSafely<{ weight?: WeightRecord }>(response);
|
|
|
|
if (!data?.weight) {
|
|
throw new Error(`Unable to save weight for ${entry.bird.name}.`);
|
|
}
|
|
|
|
savedWeights[entry.bird.id] = data.weight;
|
|
}
|
|
|
|
setBirds((current) =>
|
|
current.map((bird) => {
|
|
const savedWeight = savedWeights[bird.id];
|
|
|
|
if (!savedWeight) {
|
|
return bird;
|
|
}
|
|
|
|
return {
|
|
...bird,
|
|
latestWeightGrams: savedWeight.weightGrams,
|
|
latestRecordedOn: savedWeight.recordedOn,
|
|
};
|
|
}),
|
|
);
|
|
|
|
setAllBirdWeights((current) => {
|
|
const next = { ...current };
|
|
const limitDate = new Date();
|
|
limitDate.setDate(limitDate.getDate() - 29);
|
|
const limitMs = new Date(limitDate.toDateString()).getTime();
|
|
|
|
for (const [birdId, savedWeight] of Object.entries(savedWeights)) {
|
|
const nextWeights = [...(current[birdId] ?? []), savedWeight]
|
|
.sort((left, right) => left.recordedOn.localeCompare(right.recordedOn))
|
|
.filter((entry) => new Date(`${entry.recordedOn}T00:00:00`).getTime() >= limitMs);
|
|
next[birdId] = nextWeights;
|
|
}
|
|
|
|
return next;
|
|
});
|
|
|
|
if (selectedBird?.id && savedWeights[selectedBird.id]) {
|
|
setWeights((current) =>
|
|
[...current.filter((entry) => entry.recordedOn !== bulkWeightDate), savedWeights[selectedBird.id]].sort((left, right) =>
|
|
left.recordedOn.localeCompare(right.recordedOn),
|
|
),
|
|
);
|
|
}
|
|
|
|
setBulkWeightRows((current) =>
|
|
Object.fromEntries(
|
|
Object.entries(current).map(([birdId]) => [birdId, { weightGrams: '' }]),
|
|
),
|
|
);
|
|
} catch (bulkError) {
|
|
setError(bulkError instanceof Error ? bulkError.message : 'Unable to save the bulk weight update.');
|
|
} finally {
|
|
setSavingBulkWeights(false);
|
|
}
|
|
};
|
|
|
|
const handleVetVisitSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
if (!selectedBird) {
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
|
|
try {
|
|
const isEditingVetVisit = Boolean(editingVetVisitId);
|
|
const response = await apiFetch(
|
|
isEditingVetVisit ? `/birds/${selectedBird.id}/vet-visits/${editingVetVisitId}` : `/birds/${selectedBird.id}/vet-visits`,
|
|
authToken,
|
|
{
|
|
method: isEditingVetVisit ? 'PUT' : 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(vetVisitForm),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, `Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`));
|
|
}
|
|
|
|
const data = await readJsonSafely<{ vetVisit: VetVisit }>(response);
|
|
if (!data?.vetVisit) {
|
|
throw new Error(`Unable to ${isEditingVetVisit ? 'update' : 'save'} vet visit.`);
|
|
}
|
|
setVetVisits((current) =>
|
|
(isEditingVetVisit ? current.map((visit) => (visit.id === data.vetVisit.id ? data.vetVisit : visit)) : [data.vetVisit, ...current]).sort(
|
|
(left, right) => right.visitedOn.localeCompare(left.visitedOn),
|
|
),
|
|
);
|
|
setVetVisitForm({
|
|
visitedOn: new Date().toISOString().slice(0, 10),
|
|
clinicName: '',
|
|
reason: '',
|
|
notes: '',
|
|
});
|
|
setEditingVetVisitId('');
|
|
} catch (submitError) {
|
|
setError(submitError instanceof Error ? submitError.message : 'Unable to save vet visit.');
|
|
}
|
|
};
|
|
|
|
const handleEditVetVisit = (visit: VetVisit) => {
|
|
setEditingVetVisitId(visit.id);
|
|
setVetVisitForm({
|
|
visitedOn: visit.visitedOn,
|
|
clinicName: visit.clinicName,
|
|
reason: visit.reason,
|
|
notes: visit.notes ?? '',
|
|
});
|
|
setError('');
|
|
};
|
|
|
|
const handleCancelVetVisitEdit = () => {
|
|
setEditingVetVisitId('');
|
|
setVetVisitForm({
|
|
visitedOn: new Date().toISOString().slice(0, 10),
|
|
clinicName: '',
|
|
reason: '',
|
|
notes: '',
|
|
});
|
|
};
|
|
|
|
const handleDeleteVetVisit = async (visitId: string) => {
|
|
if (!selectedBird || deletingVetVisitId) {
|
|
return;
|
|
}
|
|
|
|
setDeletingVetVisitId(visitId);
|
|
setError('');
|
|
|
|
try {
|
|
const response = await apiFetch(`/birds/${selectedBird.id}/vet-visits/${visitId}`, authToken, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to remove vet visit.'));
|
|
}
|
|
|
|
setVetVisits((current) => current.filter((visit) => visit.id !== visitId));
|
|
if (editingVetVisitId === visitId) {
|
|
handleCancelVetVisitEdit();
|
|
}
|
|
} catch (removeError) {
|
|
setError(removeError instanceof Error ? removeError.message : 'Unable to remove vet visit.');
|
|
} finally {
|
|
setDeletingVetVisitId('');
|
|
}
|
|
};
|
|
|
|
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([]);
|
|
setEditingVetVisitId('');
|
|
setDeletingVetVisitId('');
|
|
|
|
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 handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
if (transferringBird) {
|
|
return;
|
|
}
|
|
setError('');
|
|
setTransferError('');
|
|
setTransferNotice(null);
|
|
setTransferringBird(true);
|
|
|
|
try {
|
|
const response = await apiFetch(`/birds/${flockTransferForm.birdId}/transfer`, authToken, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
destinationOwnerEmail: flockTransferForm.destinationOwnerEmail,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to transfer bird to another flock.'));
|
|
}
|
|
|
|
const data =
|
|
(await readJsonSafely<{
|
|
bird?: Bird;
|
|
inviteSent?: boolean;
|
|
invitePreviewUrl?: string | null;
|
|
message?: string;
|
|
}>(response)) ?? {};
|
|
const transferredBirdName = data.bird?.name || birds.find((bird) => bird.id === flockTransferForm.birdId)?.name || 'Bird';
|
|
|
|
if (data.inviteSent) {
|
|
setTransferNotice({
|
|
message:
|
|
data.message ??
|
|
`A bird transfer invite was sent to ${flockTransferForm.destinationOwnerEmail}. The transfer will complete automatically after they sign in.`,
|
|
previewUrl: data.invitePreviewUrl,
|
|
});
|
|
setFlockTransferForm({
|
|
birdId: '',
|
|
destinationOwnerEmail: '',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setBirds((current) => current.filter((bird) => bird.id !== flockTransferForm.birdId));
|
|
setAllBirdWeights((current) => {
|
|
const next = { ...current };
|
|
delete next[flockTransferForm.birdId];
|
|
return next;
|
|
});
|
|
setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
|
setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current));
|
|
if (selectedBird?.id === flockTransferForm.birdId) {
|
|
setSelectedBirdId('');
|
|
}
|
|
if (editingBirdId === flockTransferForm.birdId) {
|
|
setEditingBirdId('');
|
|
setBirdForm(emptyBirdForm);
|
|
setBirdPhotoName('');
|
|
}
|
|
setFlockTransferForm({
|
|
birdId: '',
|
|
destinationOwnerEmail: '',
|
|
});
|
|
|
|
window.alert(`${transferredBirdName} was transferred to ${flockTransferForm.destinationOwnerEmail}.`);
|
|
} catch (submitError) {
|
|
const message = submitError instanceof Error ? submitError.message : 'Unable to transfer bird to another flock.';
|
|
setTransferError(message);
|
|
setError(message);
|
|
} finally {
|
|
setTransferringBird(false);
|
|
}
|
|
};
|
|
|
|
const saveWorkspaceSettings = async () => {
|
|
const response = await apiFetch('/workspace', authToken, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
...workspaceForm,
|
|
billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan,
|
|
billingInterval: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingInterval,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to save flock settings.'));
|
|
}
|
|
|
|
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
|
|
|
if (!data.workspace) {
|
|
throw new Error('Unable to save flock 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',
|
|
billingInterval: savedWorkspace.billingInterval,
|
|
});
|
|
|
|
return savedWorkspace;
|
|
};
|
|
|
|
const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
setError('');
|
|
setSavingWorkspace(true);
|
|
|
|
try {
|
|
await saveWorkspaceSettings();
|
|
} catch (workspaceError) {
|
|
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.');
|
|
} finally {
|
|
setSavingWorkspace(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteWorkspace = async () => {
|
|
if (!workspace || !authToken || deletingWorkspace || activeMembership?.role !== 'owner') {
|
|
return;
|
|
}
|
|
|
|
const confirmed = window.confirm(
|
|
`Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first.\n\nYou will be switched to another flock or a new personal flock automatically.`,
|
|
);
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setDeletingWorkspace(true);
|
|
|
|
try {
|
|
const response = await apiFetch('/workspace', authToken, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to delete flock.'));
|
|
}
|
|
|
|
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
|
|
|
if (!data.session) {
|
|
throw new Error('Flock was deleted but the session could not be refreshed.');
|
|
}
|
|
|
|
const nextToken = data.token || authToken;
|
|
persistSessionToken(nextToken);
|
|
applySession(data.session, nextToken);
|
|
} catch (workspaceError) {
|
|
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to delete flock.');
|
|
} finally {
|
|
setDeletingWorkspace(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelRescueRequest = async () => {
|
|
if (!authToken) {
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setCancelingRescueRequest(true);
|
|
|
|
try {
|
|
const response = await apiFetch('/workspace/rescue-status/cancel', authToken, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to cancel rescue status request.'));
|
|
}
|
|
|
|
const data = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
|
|
|
if (!data.workspace) {
|
|
throw new Error('Unable to cancel rescue status request.');
|
|
}
|
|
|
|
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',
|
|
billingInterval: savedWorkspace.billingInterval,
|
|
});
|
|
} catch (workspaceError) {
|
|
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.');
|
|
} finally {
|
|
setCancelingRescueRequest(false);
|
|
}
|
|
};
|
|
|
|
const handleStartBillingCheckout = async () => {
|
|
if (!authToken || !workspace) {
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setBillingRedirecting(true);
|
|
setSavingWorkspace(true);
|
|
|
|
try {
|
|
const savedWorkspace = await saveWorkspaceSettings();
|
|
const billingPlan = isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : workspaceForm.billingPlan;
|
|
const billingInterval = savedWorkspace.billingInterval;
|
|
|
|
const response = await apiFetch('/billing/checkout-session', authToken, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ billingPlan, billingInterval }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to start Stripe checkout.'));
|
|
}
|
|
|
|
const data = (await readJsonSafely<{ url?: string }>(response)) ?? {};
|
|
|
|
if (!data.url) {
|
|
throw new Error('Unable to start Stripe checkout.');
|
|
}
|
|
|
|
window.location.assign(data.url);
|
|
} catch (billingError) {
|
|
setError(billingError instanceof Error ? billingError.message : 'Unable to start Stripe checkout.');
|
|
setBillingRedirecting(false);
|
|
setSavingWorkspace(false);
|
|
}
|
|
};
|
|
|
|
const handleOpenBillingPortal = async () => {
|
|
if (!authToken) {
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setBillingRedirecting(true);
|
|
|
|
try {
|
|
const response = await apiFetch('/billing/portal-session', authToken, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readErrorMessage(response, 'Unable to open Stripe billing portal.'));
|
|
}
|
|
|
|
const data = (await readJsonSafely<{ url?: string }>(response)) ?? {};
|
|
|
|
if (!data.url) {
|
|
throw new Error('Unable to open Stripe billing portal.');
|
|
}
|
|
|
|
window.location.assign(data.url);
|
|
} catch (billingError) {
|
|
setError(billingError instanceof Error ? billingError.message : 'Unable to open Stripe billing portal.');
|
|
setBillingRedirecting(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('');
|
|
}
|
|
};
|
|
|
|
const handleWeightRangeAlertClick = () => {
|
|
if (!totalWeightAlerts) {
|
|
return;
|
|
}
|
|
setShowWeightAlertModal(true);
|
|
};
|
|
|
|
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 flock 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>Organized care for companion birds and rescue flocks</h1>
|
|
<p className="muted">
|
|
Keep every bird's care story in one place; your flock's health, history, and routines together and easier to visualize.
|
|
</p>
|
|
</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">
|
|
<div className="side-rail">
|
|
<div className="brand-lockup">
|
|
<img className="side-nav-logo" src={flockPalLandingArt} alt="FlockPal" />
|
|
</div>
|
|
<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>
|
|
{authSession.isAdmin ? (
|
|
<button className={`page-tab ${activePage === 'admin' ? 'active' : ''}`} onClick={() => setActivePage('admin')} type="button">
|
|
Admin
|
|
</button>
|
|
) : null}
|
|
</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)} • {formatWorkspaceRole(entry.membership.role)}
|
|
</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
<button className="secondary-button" onClick={handleLogout} type="button">
|
|
Log out
|
|
</button>
|
|
</aside>
|
|
</div>
|
|
|
|
<section className="content-shell">
|
|
{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>
|
|
<div className="button-row overview-alert-actions">
|
|
{totalWeightAlerts ? (
|
|
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
|
|
{totalWeightAlerts} weight alert{totalWeightAlerts === 1 ? '' : 's'}
|
|
</button>
|
|
) : null}
|
|
<p className="muted">{birdsWithRecentWeights.length} birds with recent entries</p>
|
|
</div>
|
|
</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}
|
|
{totalWeightAlerts ? (
|
|
<article className="summary-card summary-alert-card">
|
|
<span>Weight alerts</span>
|
|
<strong>
|
|
{totalWeightAlerts} alert{totalWeightAlerts === 1 ? '' : 's'} need review
|
|
</strong>
|
|
{outOfRangeBirds.length ? (
|
|
<span>
|
|
{outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges
|
|
</span>
|
|
) : null}
|
|
{weightDropAlerts.length ? (
|
|
<span>
|
|
{weightDropAlerts.length} bird{weightDropAlerts.length === 1 ? '' : 's'} down 5-10% between recent entries
|
|
</span>
|
|
) : null}
|
|
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
|
|
Review alerts
|
|
</button>
|
|
</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 === 'admin' && authSession.isAdmin ? (
|
|
<section className="stack-grid">
|
|
<article className="panel">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Admin</p>
|
|
<h2>Platform pulse</h2>
|
|
</div>
|
|
<p className="muted">Operational counts for the full FlockPal platform.</p>
|
|
</div>
|
|
<div className="summary-grid">
|
|
<article className="summary-card">
|
|
<span>Total birds</span>
|
|
<strong>{adminSummary?.totalBirds ?? '-'}</strong>
|
|
</article>
|
|
<article className="summary-card">
|
|
<span>Daily users</span>
|
|
<strong>{adminSummary?.dailyUsers ?? '-'}</strong>
|
|
</article>
|
|
<article className="summary-card">
|
|
<span>Total users</span>
|
|
<strong>{adminSummary?.totalUsers ?? '-'}</strong>
|
|
</article>
|
|
<article className="summary-card">
|
|
<span>Flocks</span>
|
|
<strong>{adminSummary?.totalWorkspaces ?? '-'}</strong>
|
|
</article>
|
|
<article className="summary-card">
|
|
<span>Rescue flocks</span>
|
|
<strong>{adminSummary?.rescueWorkspaces ?? '-'}</strong>
|
|
</article>
|
|
<article className="summary-card">
|
|
<span>Pending rescues</span>
|
|
<strong>{adminSummary?.pendingRescues ?? '-'}</strong>
|
|
</article>
|
|
</div>
|
|
</article>
|
|
|
|
<article className="panel">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Verification</p>
|
|
<h2>Rescue flocks</h2>
|
|
</div>
|
|
<p className="muted">Pending rescues are read-only until approved.</p>
|
|
</div>
|
|
<div className="recent-list">
|
|
{adminRescueWorkspaces.length ? (
|
|
adminRescueWorkspaces.map((entry) => (
|
|
<article key={entry.workspace.id} className="vet-visit-card">
|
|
<strong>{entry.workspace.name}</strong>
|
|
<span>
|
|
{formatRescueVerificationStatus(entry.workspace.rescueVerificationStatus)} • {entry.birdCount} birds • {entry.memberCount} members
|
|
</span>
|
|
<small>
|
|
Owner {entry.ownerEmail ?? 'unknown'} • Billing {entry.workspace.billingEmail ?? 'not set'}
|
|
</small>
|
|
<div className="button-row">
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'approved')}
|
|
type="button"
|
|
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'approved'}
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'pending')}
|
|
type="button"
|
|
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'pending'}
|
|
>
|
|
Mark pending
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => handleRescueVerificationStatusChange(entry.workspace.id, 'rejected')}
|
|
type="button"
|
|
disabled={updatingRescueWorkspaceId === entry.workspace.id || entry.workspace.rescueVerificationStatus === 'rejected'}
|
|
>
|
|
Reject and make household
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))
|
|
) : (
|
|
<article className="vet-visit-card empty-card">
|
|
<strong>No rescue flocks yet</strong>
|
|
<small>New rescue flocks will appear here for verification review.</small>
|
|
</article>
|
|
)}
|
|
</div>
|
|
</article>
|
|
</section>
|
|
) : null}
|
|
|
|
{activePage === 'flock' ? (
|
|
<section className={showFlockDetailColumn ? '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>
|
|
<div className="button-row">
|
|
<button className="secondary-button" onClick={() => setBulkWeightOpen((current) => !current)} type="button">
|
|
{bulkWeightOpen ? 'Hide bulk add' : 'Bulk add weights'}
|
|
</button>
|
|
<button className="secondary-button" onClick={startCreateBird} type="button">
|
|
Add bird
|
|
</button>
|
|
</div>
|
|
</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"
|
|
style={bird.id === selectedBird?.id ? { borderColor: bird.chartColor, boxShadow: `0 16px 24px ${bird.chartColor}33` } : undefined}
|
|
>
|
|
<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 className="bird-card-title">
|
|
<span>{bird.name}</span>
|
|
<span aria-label={getBirdGenderLabel(bird)} className={`gender-inline ${bird.gender}`}>
|
|
{getBirdGenderSymbol(bird)}
|
|
</span>
|
|
</span>
|
|
<small>{bird.species}</small>
|
|
</div>
|
|
</div>
|
|
<strong>{formatWeight(bird.latestWeightGrams)}</strong>
|
|
{birdWeightAssessments[bird.id]?.status === 'below' || birdWeightAssessments[bird.id]?.status === 'above' ? (
|
|
<span className="bird-alert-badge">
|
|
{birdWeightAssessments[bird.id]?.status === 'below' ? 'Below chart range' : 'Above chart range'}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
{showFlockDetailColumn ? (
|
|
<section className="flock-detail-column">
|
|
{bulkWeightOpen ? (
|
|
<section className="panel bulk-weight-panel">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Weigh-in</p>
|
|
<h2>Bulk add weights</h2>
|
|
</div>
|
|
<label className="bulk-date-field">
|
|
Date
|
|
<input type="date" value={bulkWeightDate} onChange={(event) => setBulkWeightDate(event.target.value)} required />
|
|
</label>
|
|
</div>
|
|
<form className="bulk-weight-form" onSubmit={handleBulkWeightSubmit}>
|
|
<div className="bulk-weight-table-shell">
|
|
<table className="bulk-weight-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Flock member</th>
|
|
<th>Last weight</th>
|
|
<th>Weight today</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{birds.map((bird) => (
|
|
<tr key={bird.id}>
|
|
<td>{bird.name}</td>
|
|
<td>{formatWeight(bird.latestWeightGrams)}</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
step="0.1"
|
|
value={bulkWeightRows[bird.id]?.weightGrams ?? ''}
|
|
onChange={(event) => handleBulkWeightValueChange(bird.id, event.target.value)}
|
|
placeholder="g"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="button-row">
|
|
<button className="primary-button" type="submit" disabled={savingBulkWeights}>
|
|
{savingBulkWeights ? 'Saving weights...' : 'Save bulk weights'}
|
|
</button>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() =>
|
|
setBulkWeightRows((current) => Object.fromEntries(Object.keys(current).map((birdId) => [birdId, { weightGrams: '' }])))
|
|
}
|
|
type="button"
|
|
disabled={savingBulkWeights}
|
|
>
|
|
Clear entries
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
) : null}
|
|
|
|
{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 className="profile-title">
|
|
<span>{selectedBird.name}</span>
|
|
<span
|
|
aria-label={getBirdGenderLabel(selectedBird)}
|
|
className={`gender-symbol ${selectedBird.gender}`}
|
|
>
|
|
{getBirdGenderSymbol(selectedBird)}
|
|
</span>
|
|
</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>Gender</span>
|
|
<strong className="detail-gender">
|
|
<span aria-hidden="true" className={`gender-symbol ${selectedBird.gender}`}>
|
|
{getBirdGenderSymbol(selectedBird)}
|
|
</span>
|
|
{getBirdGenderLabel(selectedBird)}
|
|
</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 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}`}
|
|
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>
|
|
{selectedBirdChart.yTicks.map((tick) => (
|
|
<g key={tick.label}>
|
|
<line
|
|
x1={MEMBER_CHART_PADDING.left}
|
|
y1={tick.y}
|
|
x2={MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}
|
|
y2={tick.y}
|
|
className="chart-grid-line"
|
|
/>
|
|
<text x={MEMBER_CHART_PADDING.left - 10} y={tick.y + 4} textAnchor="end" className="chart-axis-label">
|
|
{tick.label}
|
|
</text>
|
|
</g>
|
|
))}
|
|
<line
|
|
x1={MEMBER_CHART_PADDING.left}
|
|
y1={MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom}
|
|
x2={MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}
|
|
y2={MEMBER_CHART_HEIGHT - MEMBER_CHART_PADDING.bottom}
|
|
className="chart-axis-line"
|
|
/>
|
|
{selectedBirdChart.xTicks.map((tick) => (
|
|
<text
|
|
key={`${tick.label}-${tick.x}`}
|
|
x={tick.x}
|
|
y={MEMBER_CHART_HEIGHT - 10}
|
|
textAnchor="middle"
|
|
className="chart-axis-label"
|
|
>
|
|
{tick.label}
|
|
</text>
|
|
))}
|
|
{hasSelectedBirdLine && selectedBirdChart.isFlat ? (
|
|
<line
|
|
x1={selectedBirdChart.points[0].x}
|
|
y1={selectedBirdChart.points[0].y}
|
|
x2={selectedBirdChart.points[selectedBirdChart.points.length - 1].x}
|
|
y2={selectedBirdChart.points[selectedBirdChart.points.length - 1].y}
|
|
stroke={selectedBird.chartColor}
|
|
strokeWidth="4"
|
|
strokeLinecap="round"
|
|
/>
|
|
) : null}
|
|
{hasSelectedBirdLine && !selectedBirdChart.isFlat ? (
|
|
<path d={selectedBirdChart.path} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
|
|
) : null}
|
|
{selectedBirdChart.points.map((point) => (
|
|
<circle key={point.id} cx={point.x} cy={point.y} r="5" fill={selectedBird.chartColor} stroke="#fffdf9" strokeWidth="2">
|
|
<title>{point.label}</title>
|
|
</circle>
|
|
))}
|
|
</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>
|
|
<div className="button-row wide-field">
|
|
<button className="primary-button" type="submit">
|
|
{editingVetVisitId ? 'Save vet visit changes' : 'Save vet visit'}
|
|
</button>
|
|
{editingVetVisitId ? (
|
|
<button className="secondary-button" onClick={handleCancelVetVisitEdit} type="button">
|
|
Cancel edit
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</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>
|
|
<div className="button-row">
|
|
<button className="secondary-button" onClick={() => handleEditVetVisit(visit)} type="button">
|
|
Edit
|
|
</button>
|
|
{editingVetVisitId === visit.id ? (
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => handleDeleteVetVisit(visit.id)}
|
|
type="button"
|
|
disabled={deletingVetVisitId === visit.id}
|
|
>
|
|
{deletingVetVisitId === visit.id ? 'Deleting...' : 'Delete'}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</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}
|
|
</section>
|
|
) : null}
|
|
|
|
{activePage === 'settings' ? (
|
|
<section className="forms-grid settings-grid">
|
|
<div className="settings-column settings-column-left">
|
|
<article className="panel form-panel settings-card-flock-profile">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Flock</p>
|
|
<h2>Flock profile</h2>
|
|
</div>
|
|
</div>
|
|
<p className="muted">
|
|
Manage this flock's name and type. Household billing details live in the Billing info card below.
|
|
</p>
|
|
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
|
|
<label>
|
|
Flock name
|
|
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
|
|
</label>
|
|
<label>
|
|
Flock 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>
|
|
{workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue' ? (
|
|
<article className="summary-card summary-alert-card">
|
|
<strong>This converts the current flock</strong>
|
|
<span>
|
|
Saving here updates this household flock into a rescue flock. It does not create a separate flock space. The flock will be
|
|
read-only until FlockPal approves rescue verification.
|
|
</span>
|
|
</article>
|
|
) : null}
|
|
{workspaceForm.workspaceType === 'rescue' ? (
|
|
<article className="summary-card">
|
|
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
|
<span>Rescue flocks stay free while still supporting shared team access.</span>
|
|
</article>
|
|
) : null}
|
|
<button className="primary-button" type="submit" disabled={savingWorkspace}>
|
|
{savingWorkspace
|
|
? 'Saving flock...'
|
|
: workspace?.workspaceType === 'standard' && workspaceForm.workspaceType === 'rescue'
|
|
? 'Convert current flock to rescue'
|
|
: 'Save flock settings'}
|
|
</button>
|
|
{activeMembership?.role === 'owner' ? (
|
|
<button className="danger-button" onClick={handleDeleteWorkspace} type="button" disabled={deletingWorkspace}>
|
|
{deletingWorkspace ? 'Deleting flock...' : 'Delete flock'}
|
|
</button>
|
|
) : null}
|
|
{activeMembership?.role === 'owner' ? (
|
|
<small className="muted">Delete is only available when this flock has no birds. Collaborators and tokens are removed with it.</small>
|
|
) : null}
|
|
</form>
|
|
</article>
|
|
|
|
<article className="panel form-panel settings-card-billing">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Billing</p>
|
|
<h2>Billing info</h2>
|
|
</div>
|
|
</div>
|
|
{workspace?.workspaceType !== 'rescue' ? (
|
|
<div className="form-panel">
|
|
<label>
|
|
Household plan
|
|
<select
|
|
value={workspaceForm.billingPlan}
|
|
onChange={(event) =>
|
|
setWorkspaceForm({
|
|
...workspaceForm,
|
|
billingPlan: event.target.value as WorkspaceFormState['billingPlan'],
|
|
})
|
|
}
|
|
>
|
|
<option value="household_basic">Conure (4 birds)</option>
|
|
<option value="household_plus">Indian Ringneck (10 birds)</option>
|
|
<option value="household_macaw">Macaw (11+)</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Billing frequency
|
|
<select
|
|
value={workspaceForm.billingInterval}
|
|
onChange={(event) =>
|
|
setWorkspaceForm({
|
|
...workspaceForm,
|
|
billingInterval: event.target.value as WorkspaceFormState['billingInterval'],
|
|
})
|
|
}
|
|
>
|
|
<option value="monthly">{formatBillingIntervalDropdownLabel(workspaceForm.billingPlan, 'monthly')}</option>
|
|
<option value="yearly">{formatBillingIntervalDropdownLabel(workspaceForm.billingPlan, 'yearly')}</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Billing contact email
|
|
<input
|
|
type="email"
|
|
value={workspaceForm.billingEmail}
|
|
onChange={(event) => setWorkspaceForm({ ...workspaceForm, billingEmail: event.target.value })}
|
|
placeholder="Optional for billing and account management"
|
|
/>
|
|
</label>
|
|
<div className="billing-inline-action">
|
|
<div>
|
|
<strong>{workspace?.stripeSubscriptionId ? 'Manage household billing' : 'Start household subscription'}</strong>
|
|
<span>
|
|
{workspace?.stripeSubscriptionId
|
|
? 'Open Stripe to update payment methods, invoices, cancellation, or plan changes for this flock.'
|
|
: 'Continue to Stripe with the selected plan and frequency. Billing is tracked separately for each household flock.'}
|
|
</span>
|
|
</div>
|
|
{activeMembership?.role === 'owner' || activeMembership?.role === 'assistant' ? (
|
|
<button
|
|
className="primary-button"
|
|
type="button"
|
|
onClick={workspace?.stripeSubscriptionId ? handleOpenBillingPortal : handleStartBillingCheckout}
|
|
disabled={billingRedirecting || savingWorkspace || !workspace}
|
|
>
|
|
{billingRedirecting || savingWorkspace
|
|
? 'Opening Stripe...'
|
|
: workspace?.stripeSubscriptionId
|
|
? 'Manage billing'
|
|
: 'Start subscription'}
|
|
</button>
|
|
) : (
|
|
<small className="muted">Ask a flock owner or assistant to manage billing for this flock.</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="summary-grid">
|
|
<article className="summary-card">
|
|
<strong>
|
|
{workspace ? `${formatBillingPlanName(workspace.billingPlan)} • ${formatBillingIntervalName(workspace.billingInterval)}` : 'No plan yet'}
|
|
</strong>
|
|
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
|
|
</article>
|
|
{workspace?.workspaceType !== 'rescue' ? (
|
|
<article className="summary-card">
|
|
<strong>{workspace ? formatFlockAccessStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
|
|
<span>{workspace ? formatFlockAccessDescription(workspace.subscriptionStatus) : 'Subscription status controls flock write access.'}</span>
|
|
</article>
|
|
) : null}
|
|
{workspace?.workspaceType === 'rescue' ? (
|
|
<article className="summary-card">
|
|
<strong>{formatRescueVerificationStatus(workspace.rescueVerificationStatus)}</strong>
|
|
<span>
|
|
{workspace.rescueVerificationStatus === 'approved'
|
|
? 'Rescue verification is approved and this flock is fully active.'
|
|
: workspace.rescueVerificationStatus === 'rejected'
|
|
? 'This rescue request was rejected. Update the flock or contact support before trying again.'
|
|
: 'Rescue flocks are read-only until an admin approves their verification.'}
|
|
</span>
|
|
{workspace.rescueVerificationStatus === 'pending' &&
|
|
(activeMembership?.role === 'owner' || activeMembership?.role === 'assistant') ? (
|
|
<button
|
|
className="secondary-button"
|
|
type="button"
|
|
onClick={handleCancelRescueRequest}
|
|
disabled={cancelingRescueRequest}
|
|
>
|
|
{cancelingRescueRequest ? 'Canceling request...' : 'Cancel rescue request'}
|
|
</button>
|
|
) : null}
|
|
</article>
|
|
) : null}
|
|
<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 bird count in this flock.'}
|
|
</span>
|
|
</article>
|
|
</div>
|
|
</article>
|
|
|
|
</div>
|
|
<div className="settings-column settings-column-right">
|
|
<article className="panel form-panel settings-card-collaborators">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Collaborators</p>
|
|
<h2>Shared flock 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 flocks support teams, and household flocks 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="assistant">Assistant</option>
|
|
<option value="caregiver">Caregiver</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>
|
|
{formatWorkspaceRole(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 flock.</small>
|
|
</article>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</article>
|
|
|
|
<article className="panel form-panel settings-card-automation">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Automation</p>
|
|
<h2>Integration tokens</h2>
|
|
</div>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() =>
|
|
setExpandedSettingsSection((current) => (current === 'integration-tokens' ? null : 'integration-tokens'))
|
|
}
|
|
type="button"
|
|
aria-expanded={expandedSettingsSection === 'integration-tokens'}
|
|
>
|
|
{expandedSettingsSection === 'integration-tokens' ? 'Close' : 'Open'}
|
|
</button>
|
|
</div>
|
|
{expandedSettingsSection === 'integration-tokens' ? (
|
|
<>
|
|
<p className="muted">
|
|
Create a flock-scoped token for automations like n8n. The secret is shown only once, so store it in your automation tool when it appears.
|
|
</p>
|
|
<form className="form-panel" onSubmit={handleCreateIntegrationToken}>
|
|
<label>
|
|
Token name
|
|
<input
|
|
value={integrationTokenForm.name}
|
|
onChange={(event) => setIntegrationTokenForm({ ...integrationTokenForm, name: event.target.value })}
|
|
placeholder="n8n household sync"
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
Access level
|
|
<select
|
|
value={integrationTokenForm.scope}
|
|
onChange={(event) =>
|
|
setIntegrationTokenForm({
|
|
...integrationTokenForm,
|
|
scope: event.target.value as IntegrationTokenFormState['scope'],
|
|
})
|
|
}
|
|
>
|
|
<option value="read_write">Read and write</option>
|
|
<option value="read_only">Read only</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Expire after days
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="3650"
|
|
value={integrationTokenForm.expiresInDays}
|
|
onChange={(event) => setIntegrationTokenForm({ ...integrationTokenForm, expiresInDays: event.target.value })}
|
|
placeholder="Optional"
|
|
/>
|
|
</label>
|
|
<button className="primary-button" type="submit" disabled={creatingIntegrationToken}>
|
|
{creatingIntegrationToken ? 'Creating token...' : 'Create integration token'}
|
|
</button>
|
|
</form>
|
|
|
|
{newIntegrationTokenSecret ? (
|
|
<article className="summary-card integration-token-secret">
|
|
<strong>Copy this token now</strong>
|
|
<span>It will not be shown again after you leave this page or create another token.</span>
|
|
<input readOnly value={newIntegrationTokenSecret} onFocus={(event) => event.currentTarget.select()} />
|
|
</article>
|
|
) : null}
|
|
|
|
<div className="recent-list">
|
|
{integrationTokens.length ? (
|
|
integrationTokens.map((token) => (
|
|
<article key={token.id} className="vet-visit-card">
|
|
<strong>{token.name}</strong>
|
|
<span>
|
|
{token.tokenPrefix}... • {token.scope === 'read_only' ? 'read only' : 'read and write'}
|
|
</span>
|
|
<small>
|
|
Last used {formatDateTime(token.lastUsedAt)} • Expires {token.expiresAt ? formatDateTime(token.expiresAt) : 'Never'}
|
|
</small>
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() => handleRevokeIntegrationToken(token.id)}
|
|
type="button"
|
|
disabled={revokingIntegrationTokenId === token.id}
|
|
>
|
|
{revokingIntegrationTokenId === token.id ? 'Revoking...' : 'Revoke'}
|
|
</button>
|
|
</article>
|
|
))
|
|
) : (
|
|
<article className="vet-visit-card empty-card">
|
|
<strong>No integration tokens yet</strong>
|
|
<small>Create one for n8n, scripts, or other personal automations tied to this flock.</small>
|
|
</article>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</article>
|
|
|
|
<article className="panel form-panel settings-card-separate-flock">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Separate flock</p>
|
|
<h2>Add an additional flock</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">
|
|
Use this only when you need a separate flock. To turn the current household flock into a rescue, use Flock profile and
|
|
billing above instead.
|
|
</p>
|
|
<form className="form-panel" onSubmit={handleCreateWorkspace}>
|
|
<label>
|
|
Flock name
|
|
<input
|
|
value={workspaceCreateForm.name}
|
|
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
Flock 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 (4 birds)</option>
|
|
<option value="household_plus">Indian Ringneck (10 birds)</option>
|
|
<option value="household_macaw">Macaw (11+)</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Billing frequency
|
|
<select
|
|
value={workspaceCreateForm.billingInterval}
|
|
onChange={(event) =>
|
|
setWorkspaceCreateForm({
|
|
...workspaceCreateForm,
|
|
billingInterval: event.target.value as WorkspaceCreateFormState['billingInterval'],
|
|
})
|
|
}
|
|
>
|
|
<option value="monthly">{formatBillingIntervalDropdownLabel(workspaceCreateForm.billingPlan, 'monthly')}</option>
|
|
<option value="yearly">{formatBillingIntervalDropdownLabel(workspaceCreateForm.billingPlan, 'yearly')}</option>
|
|
</select>
|
|
</label>
|
|
<article className="summary-card">
|
|
<strong>
|
|
{formatBillingPlanName(workspaceCreateForm.billingPlan)} •{' '}
|
|
{formatBillingIntervalName(workspaceCreateForm.billingInterval)}
|
|
</strong>
|
|
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
|
|
</article>
|
|
</>
|
|
) : (
|
|
<article className="summary-card">
|
|
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
|
<span>No billing is applied to rescue flocks.</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 flock...' : 'Create flock'}
|
|
</button>
|
|
</form>
|
|
</>
|
|
) : null}
|
|
</article>
|
|
|
|
<article className="panel form-panel settings-card-bird-profiles">
|
|
<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 className="species-picker-field">
|
|
Species
|
|
<div className="species-picker">
|
|
<input
|
|
value={birdForm.species}
|
|
onChange={(event) => {
|
|
setBirdForm({ ...birdForm, species: event.target.value });
|
|
setSpeciesPickerOpen(true);
|
|
}}
|
|
onFocus={() => setSpeciesPickerOpen(true)}
|
|
onBlur={() => {
|
|
window.setTimeout(() => {
|
|
setSpeciesPickerOpen(false);
|
|
}, 120);
|
|
}}
|
|
placeholder="Start typing a species"
|
|
autoComplete="off"
|
|
required
|
|
/>
|
|
{speciesPickerOpen ? (
|
|
<div className="species-picker-menu">
|
|
{filteredSpeciesOptions.length ? (
|
|
filteredSpeciesOptions.map((speciesOption) => (
|
|
<button
|
|
key={speciesOption}
|
|
className={`species-picker-option ${birdForm.species === speciesOption ? 'active' : ''}`}
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
setBirdForm({ ...birdForm, species: speciesOption });
|
|
setSpeciesPickerOpen(false);
|
|
}}
|
|
type="button"
|
|
>
|
|
{speciesOption}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="species-picker-empty">No matching species yet. Keep typing to add a custom entry.</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
|
|
</label>
|
|
<div className="segmented-field">
|
|
<span>Gender</span>
|
|
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
|
<button
|
|
className={`segmented-option ${birdForm.gender === 'unknown' ? 'active' : ''}`}
|
|
onClick={() => setBirdForm({ ...birdForm, gender: 'unknown' })}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={birdForm.gender === 'unknown'}
|
|
>
|
|
<span className="gender-symbol unknown" aria-hidden="true">
|
|
?
|
|
</span>
|
|
Unknown
|
|
</button>
|
|
<button
|
|
className={`segmented-option ${birdForm.gender === 'male' ? 'active' : ''}`}
|
|
onClick={() => setBirdForm({ ...birdForm, gender: 'male' })}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={birdForm.gender === 'male'}
|
|
>
|
|
<span className="gender-symbol male" aria-hidden="true">
|
|
♂
|
|
</span>
|
|
Male
|
|
</button>
|
|
<button
|
|
className={`segmented-option ${birdForm.gender === 'female' ? 'active' : ''}`}
|
|
onClick={() => setBirdForm({ ...birdForm, gender: 'female' })}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={birdForm.gender === 'female'}
|
|
>
|
|
<span className="gender-symbol female" aria-hidden="true">
|
|
♀
|
|
</span>
|
|
Female
|
|
</button>
|
|
</div>
|
|
<small className="muted">Shown on the bird profile card as a symbol.</small>
|
|
</div>
|
|
<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 settings-card-transfer">
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Transfer</p>
|
|
<h2>Bird transfer</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">
|
|
Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and
|
|
vet visits attached while changing which flock owns it.
|
|
</p>
|
|
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
|
|
<label>
|
|
Bird to move
|
|
<select
|
|
value={flockTransferForm.birdId}
|
|
onChange={(event) => {
|
|
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
|
|
setTransferError('');
|
|
setTransferNotice(null);
|
|
}}
|
|
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>
|
|
Receiving flock owner email
|
|
<input
|
|
type="email"
|
|
value={flockTransferForm.destinationOwnerEmail}
|
|
onChange={(event) => {
|
|
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
|
|
setTransferError('');
|
|
setTransferNotice(null);
|
|
}}
|
|
placeholder="owner@example.com"
|
|
required
|
|
/>
|
|
</label>
|
|
<button className="primary-button" type="submit" disabled={transferringBird}>
|
|
{transferringBird ? 'Transferring bird...' : 'Transfer bird'}
|
|
</button>
|
|
{transferError ? (
|
|
<p className="error-banner" role="alert">
|
|
{transferError}
|
|
</p>
|
|
) : null}
|
|
{transferNotice ? (
|
|
<article className="summary-card" role="status">
|
|
<strong>Pending transfer invite sent</strong>
|
|
<span>{transferNotice.message}</span>
|
|
{transferNotice.previewUrl ? <a href={transferNotice.previewUrl}>Open invite link</a> : null}
|
|
</article>
|
|
) : null}
|
|
</form>
|
|
</>
|
|
) : null}
|
|
</article>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</section>
|
|
|
|
{showWeightAlertModal ? (
|
|
<div className="app-modal-backdrop" role="presentation" onClick={() => setShowWeightAlertModal(false)}>
|
|
<section
|
|
className="app-modal weight-alert-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="weight-alert-title"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="panel-header">
|
|
<div>
|
|
<p className="eyebrow">Weight alert</p>
|
|
<h2 id="weight-alert-title">Birds needing weight review</h2>
|
|
</div>
|
|
<button className="secondary-button" onClick={() => setShowWeightAlertModal(false)} type="button">
|
|
Close
|
|
</button>
|
|
</div>
|
|
<p className="muted">
|
|
Range alerts use the BirdSupplies species chart as a general reference. Drop alerts compare the two most recent recorded days and
|
|
flag a 5-10% decrease. If a reading is unexpected or concerning, please consult your veterinarian.
|
|
</p>
|
|
<div className="modal-alert-list">
|
|
{outOfRangeBirds.map(({ bird, assessment }) => (
|
|
<article key={`range-${bird.id}`} className="summary-card summary-alert-card">
|
|
<strong>
|
|
{bird.name} is {assessment.status === 'below' ? 'below' : 'above'} the typical range
|
|
</strong>
|
|
<span>
|
|
{bird.species} • Latest weight {formatWeight(bird.latestWeightGrams)} • Typical range{' '}
|
|
{formatRange(assessment.reference.minGrams, assessment.reference.maxGrams)}
|
|
</span>
|
|
</article>
|
|
))}
|
|
{weightDropAlerts.map(({ bird, previousWeight, latestWeight, dropPercent }) => (
|
|
<article key={`drop-${bird.id}`} className="summary-card summary-alert-card">
|
|
<strong>{bird.name} is down {dropPercent.toFixed(1)}% between recent entries</strong>
|
|
<span>
|
|
{formatWeight(previousWeight.weightGrams)} on {formatShortDate(previousWeight.recordedOn)} to{' '}
|
|
{formatWeight(latestWeight.weightGrams)} on {formatShortDate(latestWeight.recordedOn)}
|
|
</span>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default App;
|