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 - OAuth-ready login flow for Google, Microsoft, and Apple
- Multi-workspace model with `standard` household and `rescue` modes - Multi-workspace model with `standard` household and `rescue` modes
- Shared workspace member management for both households and rescues - 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 profiles with name, tag ID, and species
- Bird DOB and gotcha day fields - Bird DOB and gotcha day fields
- Daily weight recordings - Daily weight recordings
+117 -23
View File
@@ -13,7 +13,7 @@ dotenv.config();
type WorkspaceType = 'standard' | 'rescue'; type WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; 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 ProviderKey = 'google' | 'microsoft' | 'apple';
type UserRow = { type UserRow = {
@@ -177,7 +177,7 @@ const switchWorkspaceSchema = z.object({
const workspaceTypeSchema = z.enum(['standard', 'rescue']); const workspaceTypeSchema = z.enum(['standard', 'rescue']);
const workspaceRoleSchema = z.enum(['owner', 'manager', 'staff', 'viewer']); 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({ const workspaceSchema = z.object({
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
@@ -199,6 +199,12 @@ const workspaceMemberSchema = z.object({
role: workspaceRoleSchema, 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({ const birdSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80), tagId: z.string().trim().min(1).max(80),
@@ -236,11 +242,18 @@ const createRandomId = () => crypto.randomUUID();
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url'); const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('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') { if (workspaceType === 'rescue') {
return 'rescue_free' as const; return 'rescue_free' as const;
} }
if (requestedPlan === 'household_macaw') {
return 'household_macaw';
}
return requestedPlan === 'household_plus' ? 'household_plus' : 'household_basic'; 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) => { const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) { if (!authorizationHeader) {
return ''; return '';
@@ -1041,28 +1088,10 @@ app.post('/api/auth/magic-link/request', async (req: Request, res: Response, nex
const redirectTo = parsed.data.redirectTo || frontendBaseUrl; const redirectTo = parsed.data.redirectTo || frontendBaseUrl;
try { try {
await pool.query( const delivery = await issueMagicLinkInvite({
`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({
email, email,
name, name,
magicLinkUrl: verifyUrl.toString(), redirectTo,
}); });
res.status(202).json({ 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) => { 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() : ''; 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 { useEffect, useMemo, useState } from 'react';
import flockPalLandingArt from './assets/flockpal-landing-art.png'; 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 WorkspaceType = 'standard' | 'rescue';
type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer'; type WorkspaceRole = 'owner' | 'manager' | 'staff' | 'viewer';
@@ -103,7 +104,7 @@ type WorkspaceFormState = {
name: string; name: string;
workspaceType: WorkspaceType; workspaceType: WorkspaceType;
billingEmail: string; billingEmail: string;
billingPlan: 'household_basic' | 'household_plus'; billingPlan: HouseholdBillingPlan;
}; };
type WorkspaceMemberFormState = { type WorkspaceMemberFormState = {
@@ -116,7 +117,7 @@ type WorkspaceCreateFormState = {
name: string; name: string;
workspaceType: WorkspaceType; workspaceType: WorkspaceType;
billingEmail: string; billingEmail: string;
billingPlan: 'household_basic' | 'household_plus'; billingPlan: HouseholdBillingPlan;
}; };
type AuthFormState = { type AuthFormState = {
@@ -148,6 +149,7 @@ type PhotoDragState = {
}; };
type AppPage = 'overview' | 'flock' | 'settings'; 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 apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const sessionTokenStorageKey = 'flockpal_auth_token'; const sessionTokenStorageKey = 'flockpal_auth_token';
@@ -353,8 +355,56 @@ const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => {
return url.toString(); return url.toString();
}; };
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is 'household_basic' | 'household_plus' => const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan =>
billingPlan === 'household_basic' || billingPlan === 'household_plus'; 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) => const readFileAsDataUrl = async (file: File) =>
new Promise<string>((resolve, reject) => { new Promise<string>((resolve, reject) => {
@@ -582,14 +632,13 @@ function App() {
notes: '', notes: '',
}); });
const [mergeForm, setMergeForm] = useState({ const [mergeForm, setMergeForm] = useState({
fromOwner: '', birdId: '',
toOwner: '', destinationOwnerEmail: '',
birdName: '',
tagId: '',
notes: '', notes: '',
}); });
const [deletingBird, setDeletingBird] = useState(false); const [deletingBird, setDeletingBird] = useState(false);
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
const selectedBird = useMemo( const selectedBird = useMemo(
() => birds.find((bird) => bird.id === selectedBirdId) ?? null, () => birds.find((bird) => bird.id === selectedBirdId) ?? null,
@@ -605,7 +654,12 @@ function App() {
[allBirdWeights, birds], [allBirdWeights, birds],
); );
const trendCopy = useMemo(() => { const missingFirstWeightCount = useMemo(
() => birds.filter((bird) => bird.latestWeightGrams === null).length,
[birds],
);
const selectedBirdTrendCopy = useMemo(() => {
if (weights.length < 2) { if (weights.length < 2) {
return 'Needs a few more entries before trend detection.'; 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.`; : `Weight is down ${Math.abs(delta).toFixed(1)} g over the current window.`;
}, [weights]); }, [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 overviewChart = useMemo(() => {
const plottedBirds = birds const plottedBirds = birds
.map((bird) => ({ bird, weights: allBirdWeights[bird.id] ?? [] })) .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(); event.preventDefault();
setError(''); 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( 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({ setMergeForm({
fromOwner: '', birdId: '',
toOwner: '', destinationOwnerEmail: '',
birdName: '',
tagId: '',
notes: '', notes: '',
}); });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Unable to save transfer draft.');
}
}; };
const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleWorkspaceSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -1600,17 +1717,11 @@ function App() {
); );
} }
const showWorkspaceSwitcher = authSession.workspaces.length > 1;
return ( return (
<main className="app-shell"> <main className="app-shell">
<aside className="side-nav panel"> <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"> <div className="page-tabs" role="tablist" aria-label="Main navigation">
<button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button"> <button className={`page-tab ${activePage === 'overview' ? 'active' : ''}`} onClick={() => setActivePage('overview')} type="button">
Overview Overview
@@ -1623,11 +1734,8 @@ function App() {
</button> </button>
</div> </div>
{showWorkspaceSwitcher ? (
<section className="workspace-switcher"> <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"> <div className="workspace-switcher-list">
{authSession.workspaces.map((entry) => ( {authSession.workspaces.map((entry) => (
<button <button
@@ -1639,16 +1747,16 @@ function App() {
> >
<span>{entry.workspace.name}</span> <span>{entry.workspace.name}</span>
<small> <small>
{entry.workspace.workspaceType === 'rescue' ? 'Rescue' : entry.workspace.billingPlan === 'household_plus' ? 'Household Plus' : 'Household Basic'} {' '} {formatBillingPlanName(entry.workspace.billingPlan)} {entry.membership.role}
{entry.membership.role}
</small> </small>
</button> </button>
))} ))}
</div> </div>
</section>
) : null}
<button className="secondary-button" onClick={handleLogout} type="button"> <button className="secondary-button" onClick={handleLogout} type="button">
Log out Log out
</button> </button>
</section>
</aside> </aside>
<section className="content-shell"> <section className="content-shell">
@@ -1658,12 +1766,6 @@ function App() {
<p className="eyebrow">Dashboard</p> <p className="eyebrow">Dashboard</p>
<h1>FlockPal dashboard</h1> <h1>FlockPal dashboard</h1>
</div> </div>
<div className="hero-stats">
<article>
<strong>{birds.length}</strong>
<span>Flock members</span>
</article>
</div>
</section> </section>
) : null} ) : null}
@@ -1738,49 +1840,44 @@ function App() {
</section> </section>
<section className="forms-grid"> <section className="forms-grid">
<article className="panel form-panel"> <article className="panel form-panel pulse-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Highlights</p> <p className="eyebrow">Flock health Pulse</p>
<h2>Flock health pulse</h2>
</div> </div>
</div> </div>
<div className="summary-grid"> <div className="summary-grid">
{missingFirstWeightCount > 0 ? (
<article className="summary-card"> <article className="summary-card">
<strong>{birdsWithRecentWeights.length}</strong> <strong>{missingFirstWeightCount}</strong>
<span>Flock members with recent measurements</span>
</article>
<article className="summary-card">
<strong>{birds.filter((bird) => bird.latestWeightGrams === null).length}</strong>
<span>Members still needing a first weight</span> <span>Members still needing a first weight</span>
</article> </article>
) : null}
<article className="summary-card"> <article className="summary-card">
<strong>{selectedBird ? trendCopy : 'Pick a bird'}</strong> <span>Weekly flock changes</span>
<span>Selected flock member trend</span> {flockWeeklyTrendItems.length ? (
</article> <div className="summary-list">
</div> {flockWeeklyTrendItems.map((trendItem) => (
</article> <strong key={trendItem.id} className="summary-trend-row">
<span className="summary-trend-name" style={{ color: trendItem.chartColor }}>
<article className="panel form-panel"> {trendItem.name}
<div className="panel-header"> </span>
<div> <span>: </span>
<p className="eyebrow">Recent activity</p> <span
<h2>Latest check-ins</h2> className={
</div> trendItem.direction === 'positive' ? 'summary-trend-change positive' : 'summary-trend-change negative'
</div> }
<div className="recent-list"> >
{birds {trendItem.formattedChange}
.filter((bird) => bird.latestRecordedOn) </span>
.sort((left, right) => (right.latestRecordedOn ?? '').localeCompare(left.latestRecordedOn ?? '')) </strong>
.slice(0, 5)
.map((bird) => (
<article key={bird.id}>
<strong>{bird.name}</strong>
<span>{formatWeight(bird.latestWeightGrams)}</span>
<small>{formatShortDate(bird.latestRecordedOn)}</small>
</article>
))} ))}
</div> </div>
) : (
<strong>No weekly changes yet</strong>
)}
</article>
</div>
</article> </article>
</section> </section>
</section> </section>
@@ -1909,7 +2006,7 @@ function App() {
<path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" /> <path d={chartPath(weights)} fill="none" stroke="url(#lineGlow)" strokeWidth="4" strokeLinecap="round" />
</svg> </svg>
<div className="chart-footer"> <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> <span>{weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}</span>
</div> </div>
</div> </div>
@@ -2058,6 +2155,7 @@ function App() {
</select> </select>
</label> </label>
{workspaceForm.workspaceType === 'standard' ? ( {workspaceForm.workspaceType === 'standard' ? (
<>
<label> <label>
Household plan Household plan
<select <select
@@ -2069,13 +2167,19 @@ function App() {
}) })
} }
> >
<option value="household_basic">Household Basic</option> <option value="household_basic">Conure</option>
<option value="household_plus">Household Plus</option> <option value="household_plus">Indian Ringneck</option>
<option value="household_macaw">Macaw</option>
</select> </select>
</label> </label>
<article className="summary-card">
<strong>{formatBillingPlanName(workspaceForm.billingPlan)}</strong>
<span>{formatBillingPlanCapacity(workspaceForm.billingPlan)}</span>
</article>
</>
) : ( ) : (
<article className="summary-card"> <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> <span>Rescue workspaces stay free while still supporting shared team access.</span>
</article> </article>
)} )}
@@ -2094,13 +2198,60 @@ function App() {
</form> </form>
</article> </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"> <article className="panel form-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Collaborators</p> <p className="eyebrow">Collaborators</p>
<h2>Shared workspace access</h2> <h2>Shared workspace access</h2>
</div> </div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'collaborators' ? null : 'collaborators'))
}
type="button"
aria-expanded={expandedSettingsSection === 'collaborators'}
>
{expandedSettingsSection === 'collaborators' ? 'Close' : 'Open'}
</button>
</div> </div>
{expandedSettingsSection === 'collaborators' ? (
<>
<p className="muted"> <p className="muted">
Invite other people to help manage this flock. Rescue workspaces support teams, and household workspaces can also support 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. co-caregivers without changing who owns the billing.
@@ -2171,6 +2322,8 @@ function App() {
</article> </article>
)} )}
</div> </div>
</>
) : null}
</article> </article>
<article className="panel form-panel"> <article className="panel form-panel">
@@ -2179,10 +2332,22 @@ function App() {
<p className="eyebrow">New workspace</p> <p className="eyebrow">New workspace</p>
<h2>Add another flock space</h2> <h2>Add another flock space</h2>
</div> </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> </div>
{expandedSettingsSection === 'new-workspace' ? (
<>
<p className="muted"> <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 This is the key piece for someone who helps with a rescue but also keeps their own birds at home. Each workspace stays separate
access and billing. for access and billing.
</p> </p>
<form className="form-panel" onSubmit={handleCreateWorkspace}> <form className="form-panel" onSubmit={handleCreateWorkspace}>
<label> <label>
@@ -2209,6 +2374,7 @@ function App() {
</select> </select>
</label> </label>
{workspaceCreateForm.workspaceType === 'standard' ? ( {workspaceCreateForm.workspaceType === 'standard' ? (
<>
<label> <label>
Household plan Household plan
<select <select
@@ -2220,13 +2386,19 @@ function App() {
}) })
} }
> >
<option value="household_basic">Household Basic</option> <option value="household_basic">Conure</option>
<option value="household_plus">Household Plus</option> <option value="household_plus">Indian Ringneck</option>
<option value="household_macaw">Macaw</option>
</select> </select>
</label> </label>
<article className="summary-card">
<strong>{formatBillingPlanName(workspaceCreateForm.billingPlan)}</strong>
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
</article>
</>
) : ( ) : (
<article className="summary-card"> <article className="summary-card">
<strong>Rescue Free</strong> <strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue workspaces.</span> <span>No billing is applied to rescue workspaces.</span>
</article> </article>
)} )}
@@ -2243,25 +2415,39 @@ function App() {
{creatingWorkspace ? 'Creating workspace...' : 'Create workspace'} {creatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
</button> </button>
</form> </form>
</>
) : null}
</article> </article>
<article className="panel form-panel"> <article className="panel form-panel">
<div className="panel-header"> <div className="panel-header">
<div> <div>
<p className="eyebrow">Flock setup</p> <p className="eyebrow">Bird profiles</p>
<h2>{editingBird ? `Edit ${editingBird.name}` : 'Add a flock member'}</h2> <h2>{editingBird ? `Update ${editingBird.name}` : 'Flock member details'}</h2>
</div> </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"> <div className="button-row">
{selectedBird ? ( {selectedBird ? (
<button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button"> <button className="secondary-button" onClick={() => startEditBird(selectedBird)} type="button">
Edit selected Edit selected profile
</button> </button>
) : null} ) : null}
<button className="secondary-button" onClick={startCreateBird} type="button"> <button className="secondary-button" onClick={startCreateBird} type="button">
New bird New bird profile
</button> </button>
</div> </div>
</div>
<div className="picker-list"> <div className="picker-list">
{birds.map((bird) => ( {birds.map((bird) => (
@@ -2408,9 +2594,11 @@ function App() {
</div> </div>
<button className="primary-button" type="submit" disabled={savingBird}> <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> </button>
</form> </form>
</>
) : null}
</article> </article>
<article className="panel form-panel"> <article className="panel form-panel">
@@ -2419,27 +2607,47 @@ function App() {
<p className="eyebrow">Settings</p> <p className="eyebrow">Settings</p>
<h2>Bird transfer prep</h2> <h2>Bird transfer prep</h2>
</div> </div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'transfer' ? null : 'transfer'))
}
type="button"
aria-expanded={expandedSettingsSection === 'transfer'}
>
{expandedSettingsSection === 'transfer' ? 'Close' : 'Open'}
</button>
</div> </div>
{expandedSettingsSection === 'transfer' ? (
<>
<p className="muted"> <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 This is the first step toward rescue handoffs and owner-to-owner transfers. For now it captures the matching details we would
use to safely move a bird record between accounts. later use to safely move a bird record between accounts.
</p> </p>
<form className="form-panel" onSubmit={handleMergeSubmit}> <form className="form-panel" onSubmit={handleMergeSubmit}>
<label> <label>
Current owner account Bird to transfer
<input value={mergeForm.fromOwner} onChange={(event) => setMergeForm({ ...mergeForm, fromOwner: event.target.value })} required /> <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>
<label> <label>
Destination owner account Destination owner email
<input value={mergeForm.toOwner} onChange={(event) => setMergeForm({ ...mergeForm, toOwner: event.target.value })} required /> <input
</label> type="email"
<label> value={mergeForm.destinationOwnerEmail}
Bird name onChange={(event) => setMergeForm({ ...mergeForm, destinationOwnerEmail: event.target.value })}
<input value={mergeForm.birdName} onChange={(event) => setMergeForm({ ...mergeForm, birdName: event.target.value })} required /> required
</label> />
<label>
Band / Tag info
<input value={mergeForm.tagId} onChange={(event) => setMergeForm({ ...mergeForm, tagId: event.target.value })} required />
</label> </label>
<label> <label>
Transfer notes Transfer notes
@@ -2454,6 +2662,8 @@ function App() {
Save transfer draft Save transfer draft
</button> </button>
</form> </form>
</>
) : null}
</article> </article>
</section> </section>
) : null} ) : null}
+53 -7
View File
@@ -393,14 +393,25 @@ textarea {
.dashboard-grid { .dashboard-grid {
grid-template-columns: 320px 1fr; grid-template-columns: 320px 1fr;
align-items: start;
} }
.forms-grid { .forms-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
} }
.settings-grid { .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, .flock-member-panel,
@@ -412,6 +423,7 @@ textarea {
.panel { .panel {
border-radius: 28px; border-radius: 28px;
padding: 1.5rem; padding: 1.5rem;
align-self: start;
} }
.panel-header { .panel-header {
@@ -422,6 +434,18 @@ textarea {
margin-bottom: 1rem; 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 { .button-row {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -433,12 +457,6 @@ textarea {
gap: 0.9rem; gap: 0.9rem;
} }
.workspace-switcher-header {
display: grid;
gap: 0.2rem;
}
.workspace-switcher-header small,
.workspace-switcher-item small { .workspace-switcher-item small {
color: var(--muted); color: var(--muted);
} }
@@ -681,6 +699,30 @@ textarea {
font-size: 1.05rem; 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 span,
.vet-visit-card small { .vet-visit-card small {
color: var(--muted); color: var(--muted);
@@ -882,6 +924,10 @@ label {
padding: 1rem; padding: 1rem;
} }
.settings-grid {
column-count: 1;
}
.side-nav { .side-nav {
position: static; position: static;
} }