Further UI improvements
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user