From 132b0c1202edf44b7a4beeef389657bb5bb8ce21 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Thu, 9 Apr 2026 18:11:55 -0400 Subject: [PATCH] Further UI improvements --- README.md | 2 +- backend/src/app.ts | 140 +++++- frontend/src/App.tsx | 1082 ++++++++++++++++++++++++---------------- frontend/src/index.css | 60 ++- 4 files changed, 817 insertions(+), 467 deletions(-) diff --git a/README.md b/README.md index 17afbf1..cbdb1a4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/src/app.ts b/backend/src/app.ts index bf494cd..a1e464b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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( + `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( + `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() : ''; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c4582e..a9f1951 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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; 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((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(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 => 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) => { + const handleMergeSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); - 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.`, - ); - setMergeForm({ - fromOwner: '', - toOwner: '', - birdName: '', - tagId: '', - notes: '', - }); + + try { + const response = await apiFetch('/transfers/draft', authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mergeForm), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to save transfer draft.')); + } + + const data = + (await readJsonSafely<{ + bird?: Bird; + destinationOwnerExists?: boolean; + inviteSent?: boolean; + }>(response)) ?? {}; + + const transferBirdName = data.bird?.name || birds.find((bird) => bird.id === mergeForm.birdId)?.name || 'bird'; + const inviteCopy = data.inviteSent + ? `\n\nA FlockPal invite was also sent to ${mergeForm.destinationOwnerEmail} because that email does not have an account yet.` + : data.destinationOwnerExists + ? `\n\n${mergeForm.destinationOwnerEmail} already has a FlockPal account, so no invite was needed.` + : ''; + + window.alert( + `Transfer prep saved for ${transferBirdName}.${inviteCopy}\n\nThis is currently a planning workflow only. Later this page can turn into a real account-to-account transfer flow using verified bird identity and ownership checks.`, + ); + + setMergeForm({ + birdId: '', + destinationOwnerEmail: '', + notes: '', + }); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to save transfer draft.'); + } }; const handleWorkspaceSubmit = async (event: React.FormEvent) => { @@ -1600,17 +1717,11 @@ function App() { ); } + const showWorkspaceSwitcher = authSession.workspaces.length > 1; + return (
@@ -1658,12 +1766,6 @@ function App() {

Dashboard

FlockPal dashboard

-
-
- {birds.length} - Flock members -
-
) : null} @@ -1738,48 +1840,43 @@ function App() {
-
+
-

Highlights

-

Flock health pulse

+

Flock health Pulse

+ {missingFirstWeightCount > 0 ? ( +
+ {missingFirstWeightCount} + Members still needing a first weight +
+ ) : null}
- {birdsWithRecentWeights.length} - Flock members with recent measurements + Weekly flock changes + {flockWeeklyTrendItems.length ? ( +
+ {flockWeeklyTrendItems.map((trendItem) => ( + + + {trendItem.name} + + : + + {trendItem.formattedChange} + + + ))} +
+ ) : ( + No weekly changes yet + )}
-
- {birds.filter((bird) => bird.latestWeightGrams === null).length} - Members still needing a first weight -
-
- {selectedBird ? trendCopy : 'Pick a bird'} - Selected flock member trend -
-
-
- -
-
-
-

Recent activity

-

Latest check-ins

-
-
-
- {birds - .filter((bird) => bird.latestRecordedOn) - .sort((left, right) => (right.latestRecordedOn ?? '').localeCompare(left.latestRecordedOn ?? '')) - .slice(0, 5) - .map((bird) => ( -
- {bird.name} - {formatWeight(bird.latestWeightGrams)} - {formatShortDate(bird.latestRecordedOn)} -
- ))}
@@ -1909,7 +2006,7 @@ function App() {
-

{trendCopy}

+

{selectedBirdTrendCopy}

{weights.length ? `${weights.length} data point${weights.length === 1 ? '' : 's'}` : 'No data yet'}
@@ -2058,24 +2155,31 @@ function App() { {workspaceForm.workspaceType === 'standard' ? ( - + <> + +
+ {formatBillingPlanName(workspaceForm.billingPlan)} + {formatBillingPlanCapacity(workspaceForm.billingPlan)} +
+ ) : (
- Rescue Free + {formatBillingPlanName('rescue_free')} Rescue workspaces stay free while still supporting shared team access.
)} @@ -2094,83 +2198,132 @@ function App() { +
+
+
+

Billing

+

Billing info

+
+
+
+
+ {workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'} + {workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a workspace plan to see bird capacity.'} +
+
+ {workspace?.billingEmail || authSession.user.email} + Billing contact for invoices, receipts, and account notices. +
+
+ + {workspace && formatBillingPlanBirdLimit(workspace.billingPlan) + ? `${birds.length} / ${formatBillingPlanBirdLimit(workspace.billingPlan)} birds` + : `${birds.length} bird${birds.length === 1 ? '' : 's'}`} + + + {workspace && formatBillingPlanBirdLimit(workspace.billingPlan) + ? 'Current bird count against your paid plan allowance.' + : 'Current flock count in this workspace.'} + +
+
+ Stripe integration coming soon + Customer portal, payment method management, invoices, and renewal status will appear here. +
+
+
+

Collaborators

Shared workspace access

-
-

- 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. -

-
- - - - -
- -
- {workspaceMembers.length ? ( - workspaceMembers.map((member) => ( -
- {member.name} - - {member.role} • {member.email || member.inviteEmail} - - {member.acceptedAt ? 'Active access' : 'Invitation pending'} - -
- )) - ) : ( -
- No collaborators yet - Add the people who should be able to help care for birds in this workspace. -
- )}
+ {expandedSettingsSection === 'collaborators' ? ( + <> +

+ 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. +

+
+ + + + +
+ +
+ {workspaceMembers.length ? ( + workspaceMembers.map((member) => ( +
+ {member.name} + + {member.role} • {member.email || member.inviteEmail} + + {member.acceptedAt ? 'Active access' : 'Invitation pending'} + +
+ )) + ) : ( +
+ No collaborators yet + Add the people who should be able to help care for birds in this workspace. +
+ )} +
+ + ) : null}
@@ -2179,238 +2332,273 @@ function App() {

New workspace

Add another flock space

- -

- 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. -

-
- - - {workspaceCreateForm.workspaceType === 'standard' ? ( - - ) : ( -
- Rescue Free - No billing is applied to rescue workspaces. -
- )} - - -
+ + {expandedSettingsSection === 'new-workspace' ? ( + <> +

+ 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. +

+
+ + + {workspaceCreateForm.workspaceType === 'standard' ? ( + <> + +
+ {formatBillingPlanName(workspaceCreateForm.billingPlan)} + {formatBillingPlanCapacity(workspaceCreateForm.billingPlan)} +
+ + ) : ( +
+ {formatBillingPlanName('rescue_free')} + No billing is applied to rescue workspaces. +
+ )} + + +
+ + ) : null}
-

Flock setup

-

{editingBird ? `Edit ${editingBird.name}` : 'Add a flock member'}

-
-
- {selectedBird ? ( - - ) : null} - +

Bird profiles

+

{editingBird ? `Update ${editingBird.name}` : 'Flock member details'}

+
- -
- {birds.map((bird) => ( - - ))} -
- -
- - - - - - - - -
- -

This color will follow this bird across the overview graph and its individual weight trend.

-
- -
-
- {photoCrop ? ( -
- Crop preview - - ) : birdForm.photoDataUrl ? ( - Bird preview - ) : ( - - )} -
-
- -

- {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.')} -

- {photoCrop ? ( -
-

Drag the photo to position it inside the square crop. The saved image will match this preview.

- -
- - -
-

Photos are cropped to a square and optimized automatically to stay within the current upload limit.

-
- ) : birdForm.photoDataUrl ? ( - ) : null} +
-
- - +
+ {birds.map((bird) => ( + + ))} +
+ +
+ + + + + + + + +
+ +

This color will follow this bird across the overview graph and its individual weight trend.

+
+ +
+
+ {photoCrop ? ( +
+ Crop preview + + ) : birdForm.photoDataUrl ? ( + Bird preview + ) : ( + + )} +
+
+ +

+ {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.')} +

+ {photoCrop ? ( +
+

Drag the photo to position it inside the square crop. The saved image will match this preview.

+ +
+ + +
+

Photos are cropped to a square and optimized automatically to stay within the current upload limit.

+
+ ) : birdForm.photoDataUrl ? ( + + ) : null} +
+
+ + + + + ) : null}
@@ -2419,41 +2607,63 @@ function App() {

Settings

Bird transfer prep

- -

- 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. -

-
- - - - -