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
|
- 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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user