Further UI improvements

This commit is contained in:
Corey Blais
2026-04-09 18:11:55 -04:00
parent 5b0304ee93
commit 132b0c1202
4 changed files with 817 additions and 467 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
- OAuth-ready login flow for Google, Microsoft, and Apple
- Multi-workspace model with `standard` household and `rescue` modes
- Shared workspace member management for both households and rescues
- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, and `household_plus`
- Separate per-workspace billing plan foundation with `rescue_free`, `household_basic`, `household_plus`, and `household_macaw`
- Bird profiles with name, tag ID, and species
- Bird DOB and gotcha day fields
- Daily weight recordings
+117 -23
View File
@@ -13,7 +13,7 @@ dotenv.config();
type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type ProviderKey = 'google' | 'microsoft' | 'apple';
type UserRow = {
@@ -177,7 +177,7 @@ const switchWorkspaceSchema = z.object({
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']);
const billingPlanSchema = z.enum(['household_basic', 'household_plus']);
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
const workspaceSchema = z.object({
name: z.string().trim().min(1).max(160),
@@ -199,6 +199,12 @@ const workspaceMemberSchema = z.object({
role: workspaceRoleSchema,
});
const transferDraftSchema = z.object({
birdId: z.string().uuid(),
destinationOwnerEmail: z.string().trim().email().max(255),
notes: z.string().trim().max(1000).optional().or(z.literal('')),
});
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80),
@@ -236,11 +242,18 @@ const createRandomId = () => crypto.randomUUID();
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
const resolveBillingPlan = (workspaceType: WorkspaceType, requestedPlan?: BillingPlan | 'household_basic' | 'household_plus') => {
const resolveBillingPlan = (
workspaceType: WorkspaceType,
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw',
) => {
if (workspaceType === 'rescue') {
return 'rescue_free' as const;
}
if (requestedPlan === 'household_macaw') {
return 'household_macaw';
}
return requestedPlan === 'household_plus' ? 'household_plus' : 'household_basic';
};
@@ -825,6 +838,40 @@ const sendMagicLink = async ({
};
};
const issueMagicLinkInvite = async ({
email,
name,
redirectTo = frontendBaseUrl,
}: {
email: string;
name: string | null;
redirectTo?: string;
}) => {
await pool.query(
`DELETE FROM magic_link_tokens
WHERE expires_at <= CURRENT_TIMESTAMP`,
);
const rawToken = createSessionToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
await pool.query(
`INSERT INTO magic_link_tokens (email, name, token_hash, redirect_to, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
[email, name, tokenHash, redirectTo, expiresAt],
);
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
verifyUrl.searchParams.set('token', rawToken);
return sendMagicLink({
email,
name,
magicLinkUrl: verifyUrl.toString(),
});
};
const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) {
return '';
@@ -1041,28 +1088,10 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
const redirectTo = parsed.data.redirectTo || frontendBaseUrl;
try {
await pool.query(
`DELETE FROM magic_link_tokens
WHERE expires_at <= CURRENT_TIMESTAMP`,
);
const rawToken = createSessionToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
await pool.query(
`INSERT INTO magic_link_tokens (email, name, token_hash, redirect_to, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
[email, name, tokenHash, redirectTo, expiresAt],
);
const verifyUrl = new URL(`${backendBaseUrl}/api/auth/magic-link/verify`);
verifyUrl.searchParams.set('token', rawToken);
const delivery = await sendMagicLink({
const delivery = await issueMagicLinkInvite({
email,
name,
magicLinkUrl: verifyUrl.toString(),
redirectTo,
});
res.status(202).json({
@@ -1076,6 +1105,71 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
}
});
app.post('/api/transfers/draft', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
const parsed = transferDraftSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid transfer draft payload', details: parsed.error.flatten() });
return;
}
try {
const birdResult = await pool.query<BirdRow>(
`SELECT ${birdSelectFields}
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE birds.id = $1
AND birds.workspace_id = $2`,
[parsed.data.birdId, req.auth!.workspace.id],
);
if (!birdResult.rowCount) {
res.status(404).json({ error: 'That bird could not be found in this workspace.' });
return;
}
const destinationOwnerEmail = normalizeEmail(parsed.data.destinationOwnerEmail);
const existingUser = await pool.query<UserRow>(
`SELECT id, email, password_hash, name, created_at
FROM users
WHERE email = $1`,
[destinationOwnerEmail],
);
let invitePreviewUrl: string | null = null;
let inviteDelivery: 'email' | 'preview' | null = null;
if (!existingUser.rowCount) {
const delivery = await issueMagicLinkInvite({
email: destinationOwnerEmail,
name: null,
redirectTo: frontendBaseUrl,
});
invitePreviewUrl = delivery.previewUrl;
inviteDelivery = delivery.delivered ? 'email' : 'preview';
}
res.status(201).json({
ok: true,
bird: normalizeBird(birdResult.rows[0]),
destinationOwnerEmail,
destinationOwnerExists: Boolean(existingUser.rowCount),
inviteSent: !existingUser.rowCount,
invitePreviewUrl,
inviteDelivery,
});
} catch (error) {
next(error);
}
});
app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next: NextFunction) => {
const rawToken = typeof req.query.token === 'string' ? req.query.token.trim() : '';
+308 -98
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
@@ -103,7 +104,7 @@ type WorkspaceFormState = {
name: string;
workspaceType: WorkspaceType;
billingEmail: string;
billingPlan: 'household_basic' | 'household_plus';
billingPlan: HouseholdBillingPlan;
};
type WorkspaceMemberFormState = {
@@ -116,7 +117,7 @@ type WorkspaceCreateFormState = {
name: string;
workspaceType: WorkspaceType;
billingEmail: string;
billingPlan: 'household_basic' | 'household_plus';
billingPlan: HouseholdBillingPlan;
};
type AuthFormState = {
@@ -148,6 +149,7 @@ type PhotoDragState = {
};
type AppPage = 'overview' | 'flock' | 'settings';
type SettingsSection = 'collaborators' | 'new-workspace' | 'flock-member' | 'transfer';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const sessionTokenStorageKey = 'flockpal_auth_token';
@@ -353,8 +355,56 @@ const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => {
return url.toString();
};
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is 'household_basic' | 'household_plus' =>
billingPlan === 'household_basic' || billingPlan === 'household_plus';
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan =>
billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw';
const formatBillingPlanName = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return 'Rescue Free';
}
if (billingPlan === 'household_basic') {
return 'Conure';
}
if (billingPlan === 'household_plus') {
return 'Indian Ringneck';
}
return 'Macaw';
};
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return 'No billing is applied to rescue workspaces.';
}
if (billingPlan === 'household_basic') {
return 'Permits up to 4 birds in the workspace.';
}
if (billingPlan === 'household_plus') {
return 'Permits 5 to 10 birds in the workspace.';
}
return 'Permits 11 or more birds in the workspace.';
};
const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
if (billingPlan === 'household_basic') {
return '4';
}
if (billingPlan === 'household_plus') {
return '10';
}
if (billingPlan === 'household_macaw') {
return '11+';
}
return null;
};
const readFileAsDataUrl = async (file: File) =>
new Promise<string>((resolve, reject) => {
@@ -582,14 +632,13 @@ function App() {
notes: '',
});
const [mergeForm, setMergeForm] = useState({
fromOwner: '',
toOwner: '',
birdName: '',
tagId: '',
birdId: '',
destinationOwnerEmail: '',
notes: '',
});
const [deletingBird, setDeletingBird] = useState(false);
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
const selectedBird = useMemo(
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
@@ -605,7 +654,12 @@ function App() {
[allBirdWeights, birds],
);
const trendCopy = useMemo(() => {
const missingFirstWeightCount = useMemo(
() => birds.filter((bird) => bird.latestWeightGrams === null).length,
[birds],
);
const selectedBirdTrendCopy = useMemo(() => {
if (weights.length < 2) {
return 'Needs a few more entries before trend detection.';
}
@@ -623,6 +677,41 @@ function App() {
: `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
}, [weights]);
const flockWeeklyTrendItems = useMemo(() => {
return birds
.map((bird) => {
const birdWeights = allBirdWeights[bird.id] ?? [];
if (!birdWeights.length) {
return null;
}
const latestWeight = birdWeights[birdWeights.length - 1];
const weekStart = new Date(`${latestWeight.recordedOn}T00:00:00`);
weekStart.setDate(weekStart.getDate() - 7);
const weeklyWeights = birdWeights.filter(
(entry) => new Date(`${entry.recordedOn}T00:00:00`) >= weekStart,
);
if (weeklyWeights.length < 2) {
return null;
}
const startingWeight = weeklyWeights[0].weightGrams;
const percentChange = startingWeight === 0 ? 0 : ((latestWeight.weightGrams - startingWeight) / startingWeight) * 100;
return {
id: bird.id,
name: bird.name,
chartColor: bird.chartColor,
formattedChange: `${percentChange >= 0 ? '+' : ''}${percentChange.toFixed(1)}%`,
direction: percentChange >= 0 ? 'positive' : 'negative',
} as const;
})
.filter((trend): trend is NonNullable<typeof trend> => trend !== null);
}, [allBirdWeights, birds]);
const overviewChart = useMemo(() => {
const plottedBirds = birds
.map((bird) => ({ bird, weights: allBirdWeights[bird.id] ?? [] }))
@@ -1358,19 +1447,47 @@ function App() {
}
};
const handleMergeSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const handleMergeSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
try {
const response = await apiFetch('/transfers/draft', authToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mergeForm),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save transfer draft.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
destinationOwnerExists?: boolean;
inviteSent?: boolean;
}>(response)) ?? {};
const transferBirdName = data.bird?.name || birds.find((bird) => bird.id === mergeForm.birdId)?.name || 'bird';
const inviteCopy = data.inviteSent
? `\n\nA FlockPal invite was also sent to ${mergeForm.destinationOwnerEmail} because that email does not have an account yet.`
: data.destinationOwnerExists
? `\n\n${mergeForm.destinationOwnerEmail} already has a FlockPal account, so no invite was needed.`
: '';
window.alert(
`Transfer prep saved for ${mergeForm.birdName || 'bird'}.\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`,
`Transfer prep saved for ${transferBirdName}.${inviteCopy}\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`,
);
setMergeForm({
fromOwner: '',
toOwner: '',
birdName: '',
tagId: '',
birdId: '',
destinationOwnerEmail: '',
notes: '',
});
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save transfer draft.');
}
};
const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -1600,17 +1717,11 @@ function App() {
);
}
const showWorkspaceSwitcher = authSession.workspaces.length > 1;
return (
<main className="app-shell">
<aside className="side-nav panel">
<div>
<p className="eyebrow">Bird health tracker</p>
<h2>{workspace?.name || 'FlockPal'}</h2>
<p className="muted">
{workspace?.workspaceType === 'rescue' ? 'Rescue workspace' : 'Household workspace'}
{activeMembership ? `${activeMembership.role}` : ''}
</p>
</div>
<div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview
@@ -1623,11 +1734,8 @@ function App() {
</button>
</div>
{showWorkspaceSwitcher ? (
<section className="workspace-switcher">
<div className="workspace-switcher-header">
<strong>{authSession.user.name}</strong>
<small>{authSession.user.email}</small>
</div>
<div className="workspace-switcher-list">
{authSession.workspaces.map((entry) => (
<button
@@ -1639,16 +1747,16 @@ function App() {
>
<span>{entry.workspace.name}</span>
<small>
{entry.workspace.workspaceType === 'rescue' ? 'Rescue' : entry.workspace.billingPlan === 'household_plus' ? 'Household Plus' : 'Household Basic'} {' '}
{entry.membership.role}
{formatBillingPlanName(entry.workspace.billingPlan)} {entry.membership.role}
</small>
</button>
))}
</div>
</section>
) : null}
<button className="secondary-button" onClick={handleLogout} type="button">
Log out
</button>
</section>
</aside>
<section className="content-shell">
@@ -1658,12 +1766,6 @@ function App() {
<p className="eyebrow">Dashboard</p>
<h1>FlockPal dashboard</h1>
</div>
<div className="hero-stats">
<article>
<strong>{birds.length}</strong>
<span>Flock members</span>
</article>
</div>
</section>
) : null}
@@ -1738,49 +1840,44 @@ function App() {
</section>
<section className="forms-grid">
<article className="panel form-panel">
<article className="panel form-panel pulse-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Highlights</p>
<h2>Flock health pulse</h2>
<p className="eyebrow">Flock health Pulse</p>
</div>
</div>
<div className="summary-grid">
{missingFirstWeightCount > 0 ? (
<article className="summary-card">
<strong>{birdsWithRecentWeights.length}</strong>
<span>Flock members with recent measurements</span>
</article>
<article className="summary-card">
<strong>{birds.filter((bird) => bird.latestWeightGrams === null).length}</strong>
<strong>{missingFirstWeightCount}</strong>
<span>Members still needing a first weight</span>
</article>
) : null}
<article className="summary-card">
<strong>{selectedBird ? trendCopy : 'Pick a bird'}</strong>
<span>Selected flock member trend</span>
</article>
</div>
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Recent activity</p>
<h2>Latest check-ins</h2>
</div>
</div>
<div className="recent-list">
{birds
.filter((bird) => bird.latestRecordedOn)
.sort((left, right) => (right.latestRecordedOn ?? '').localeCompare(left.latestRecordedOn ?? ''))
.slice(0, 5)
.map((bird) => (
<article key={bird.id}>
<strong>{bird.name}</strong>
<span>{formatWeight(bird.latestWeightGrams)}</span>
<small>{formatShortDate(bird.latestRecordedOn)}</small>
</article>
<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>
@@ -1909,7 +2006,7 @@ function App() {
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
</svg>
<div className="chart-footer">
<p>{trendCopy}</p>
<p>{selectedBirdTrendCopy}</p>
<span>{weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}</span>
</div>
</div>
@@ -2058,6 +2155,7 @@ function App() {
</select>
</label>
{workspaceForm.workspaceType === 'standard' ? (
<>
<label>
Household plan
<select
@@ -2069,13 +2167,19 @@ function App() {
})
}
>
<option value="household_basic">Household Basic</option>
<option value="household_plus">Household Plus</option>
<option value="household_basic">Conure</option>
<option value="household_plus">Indian Ringneck</option>
<option value="household_macaw">Macaw</option>
</select>
</label>
<article className="summary-card">
<strong>{formatBillingPlanName(workspaceForm.billingPlan)}</strong>
<span>{formatBillingPlanCapacity(workspaceForm.billingPlan)}</span>
</article>
</>
) : (
<article className="summary-card">
<strong>Rescue Free</strong>
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>Rescue workspaces stay free while still supporting shared team access.</span>
</article>
)}
@@ -2094,13 +2198,60 @@ function App() {
</form>
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Billing</p>
<h2>Billing info</h2>
</div>
</div>
<div className="summary-grid">
<article className="summary-card">
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a workspace plan to see bird capacity.'}</span>
</article>
<article className="summary-card">
<strong>{workspace?.billingEmail || authSession.user.email}</strong>
<span>Billing contact for invoices, receipts, and account notices.</span>
</article>
<article className="summary-card">
<strong>
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
? `${birds.length} / ${formatBillingPlanBirdLimit(workspace.billingPlan)} birds`
: `${birds.length} bird${birds.length === 1 ? '' : 's'}`}
</strong>
<span>
{workspace && formatBillingPlanBirdLimit(workspace.billingPlan)
? 'Current bird count against your paid plan allowance.'
: 'Current flock count in this workspace.'}
</span>
</article>
<article className="summary-card">
<strong>Stripe integration coming soon</strong>
<span>Customer portal, payment method management, invoices, and renewal status will appear here.</span>
</article>
</div>
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Collaborators</p>
<h2>Shared workspace access</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'collaborators' ? null : 'collaborators'))
}
type="button"
aria-expanded={expandedSettingsSection === 'collaborators'}
>
{expandedSettingsSection === 'collaborators' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'collaborators' ? (
<>
<p className="muted">
Invite other people to help manage this flock. Rescue workspaces support teams, and household workspaces can also support
co-caregivers without changing who owns the billing.
@@ -2171,6 +2322,8 @@ function App() {
</article>
)}
</div>
</>
) : null}
</article>
<article className="panel form-panel">
@@ -2179,10 +2332,22 @@ function App() {
<p className="eyebrow">New workspace</p>
<h2>Add another flock space</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'new-workspace' ? null : 'new-workspace'))
}
type="button"
aria-expanded={expandedSettingsSection === 'new-workspace'}
>
{expandedSettingsSection === 'new-workspace' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'new-workspace' ? (
<>
<p className="muted">
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate for
access and billing.
This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate
for access and billing.
</p>
<form className="form-panel" onSubmit={handleCreateWorkspace}>
<label>
@@ -2209,6 +2374,7 @@ function App() {
</select>
</label>
{workspaceCreateForm.workspaceType === 'standard' ? (
<>
<label>
Household plan
<select
@@ -2220,13 +2386,19 @@ function App() {
})
}
>
<option value="household_basic">Household Basic</option>
<option value="household_plus">Household Plus</option>
<option value="household_basic">Conure</option>
<option value="household_plus">Indian Ringneck</option>
<option value="household_macaw">Macaw</option>
</select>
</label>
<article className="summary-card">
<strong>{formatBillingPlanName(workspaceCreateForm.billingPlan)}</strong>
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
</article>
</>
) : (
<article className="summary-card">
<strong>Rescue Free</strong>
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue workspaces.</span>
</article>
)}
@@ -2243,25 +2415,39 @@ function App() {
{creatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
</button>
</form>
</>
) : null}
</article>
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Flock setup</p>
<h2>{editingBird ? `Edit ${editingBird.name}` : 'Add a flock member'}</h2>
<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
Edit selected profile
</button>
) : null}
<button className="secondary-button" onClick={startCreateBird} type="button">
New bird
New bird profile
</button>
</div>
</div>
<div className="picker-list">
{birds.map((bird) => (
@@ -2408,9 +2594,11 @@ function App() {
</div>
<button className="primary-button" type="submit" disabled={savingBird}>
{savingBird ? 'Saving...' : editingBird ? 'Save changes' : 'Save flock member'}
{savingBird ? 'Saving...' : editingBird ? 'Save profile changes' : 'Save bird profile'}
</button>
</form>
</>
) : null}
</article>
<article className="panel form-panel">
@@ -2419,27 +2607,47 @@ function App() {
<p className="eyebrow">Settings</p>
<h2>Bird transfer prep</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'transfer' ? null : 'transfer'))
}
type="button"
aria-expanded={expandedSettingsSection === 'transfer'}
>
{expandedSettingsSection === 'transfer' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'transfer' ? (
<>
<p className="muted">
This is the first step toward rescue handoffs and owner-to-owner transfers. For now it captures the matching details we would later
use to safely move a bird record between accounts.
This is the first step toward rescue handoffs and owner-to-owner transfers. For now it captures the matching details we would
later use to safely move a bird record between accounts.
</p>
<form className="form-panel" onSubmit={handleMergeSubmit}>
<label>
Current owner account
<input value={mergeForm.fromOwner} onChange={(event) => setMergeForm({ ...mergeForm, fromOwner: event.target.value })} required />
Bird to transfer
<select
value={mergeForm.birdId}
onChange={(event) => setMergeForm({ ...mergeForm, birdId: event.target.value })}
required
>
<option value="">Select a bird from this flock</option>
{birds.map((bird) => (
<option key={bird.id} value={bird.id}>
{bird.name} {bird.species} Band {bird.tagId}
</option>
))}
</select>
</label>
<label>
Destination owner account
<input value={mergeForm.toOwner} onChange={(event) => setMergeForm({ ...mergeForm, toOwner: event.target.value })} required />
</label>
<label>
Bird name
<input value={mergeForm.birdName} onChange={(event) => setMergeForm({ ...mergeForm, birdName: event.target.value })} required />
</label>
<label>
Band / Tag info
<input value={mergeForm.tagId} onChange={(event) => setMergeForm({ ...mergeForm, tagId: event.target.value })} required />
Destination owner email
<input
type="email"
value={mergeForm.destinationOwnerEmail}
onChange={(event) => setMergeForm({ ...mergeForm, destinationOwnerEmail: event.target.value })}
required
/>
</label>
<label>
Transfer notes
@@ -2454,6 +2662,8 @@ function App() {
Save transfer draft
</button>
</form>
</>
) : null}
</article>
</section>
) : null}
+53 -7
View File
@@ -393,14 +393,25 @@ textarea {
.dashboard-grid {
grid-template-columns: 320px 1fr;
align-items: start;
}
.forms-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
.settings-grid {
align-items: start;
display: block;
column-count: 2;
column-gap: 1.5rem;
}
.settings-grid > .panel {
display: inline-grid;
width: 100%;
margin: 0 0 1.5rem;
break-inside: avoid;
}
.flock-member-panel,
@@ -412,6 +423,7 @@ textarea {
.panel {
border-radius: 28px;
padding: 1.5rem;
align-self: start;
}
.panel-header {
@@ -422,6 +434,18 @@ textarea {
margin-bottom: 1rem;
}
.pulse-panel .panel-header {
margin-bottom: 0.25rem;
}
.pulse-panel {
align-content: start;
}
.pulse-panel .summary-card {
align-content: start;
}
.button-row {
display: flex;
gap: 0.75rem;
@@ -433,12 +457,6 @@ textarea {
gap: 0.9rem;
}
.workspace-switcher-header {
display: grid;
gap: 0.2rem;
}
.workspace-switcher-header small,
.workspace-switcher-item small {
color: var(--muted);
}
@@ -681,6 +699,30 @@ textarea {
font-size: 1.05rem;
}
.summary-list {
display: grid;
gap: 0.2rem;
}
.summary-trend-row {
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
align-items: baseline;
}
.summary-trend-name {
color: var(--ink);
}
.summary-trend-change.positive {
color: var(--accent-green);
}
.summary-trend-change.negative {
color: var(--accent-red);
}
.vet-visit-card span,
.vet-visit-card small {
color: var(--muted);
@@ -882,6 +924,10 @@ label {
padding: 1rem;
}
.settings-grid {
column-count: 1;
}
.side-nav {
position: static;
}