Added adoption report and transfer code
Deploy / deploy-dev (push) Successful in 2m31s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
Corey Blais
2026-06-01 18:57:53 -04:00
parent 3053e3bef5
commit d5bb87910e
7 changed files with 845 additions and 58 deletions
+527 -52
View File
@@ -401,7 +401,7 @@ type WeightDropAlert = {
};
type DismissibleAlertType = 'weight-range' | 'weight-drop' | 'vet-visit';
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'audit';
type BirdDetailTab = 'info' | 'weight' | 'vet' | 'notes' | 'reports' | 'audit';
type DismissedAlertMap = Record<string, boolean>;
type PhotoCropState = {
@@ -884,6 +884,14 @@ const getBirdGenderSymbol = (bird: Pick<Bird, 'gender'>) => {
return '?';
};
const escapeReportHtml = (value: string | number | null | undefined) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatDateTime = (value: string | null) => {
if (!value) {
return 'Never';
@@ -1640,12 +1648,21 @@ function App() {
birdId: '',
destinationOwnerEmail: '',
});
const [transferCodeAcceptForm, setTransferCodeAcceptForm] = useState({
code: '',
});
const [transferringBird, setTransferringBird] = useState(false);
const [acceptingTransferCode, setAcceptingTransferCode] = useState(false);
const [transferError, setTransferError] = useState('');
const [transferCodeError, setTransferCodeError] = useState('');
const [transferNotice, setTransferNotice] = useState<{
message: string;
previewUrl?: string | null;
} | null>(null);
const [transferCodeNotice, setTransferCodeNotice] = useState('');
const [adoptionTransferCodes, setAdoptionTransferCodes] = useState<Record<string, string>>({});
const [creatingAdoptionReportCode, setCreatingAdoptionReportCode] = useState(false);
const [adoptionReportError, setAdoptionReportError] = useState('');
const [deletingBird, setDeletingBird] = useState(false);
const [memorializingBird, setMemorializingBird] = useState(false);
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
@@ -1663,6 +1680,7 @@ function App() {
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
[birds, selectedBirdId],
);
const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
const editingBird = useMemo(
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
[birds, editingBirdId],
@@ -4263,6 +4281,358 @@ function App() {
}
};
const handleTransferCodeAcceptSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (acceptingTransferCode) {
return;
}
const code = transferCodeAcceptForm.code.trim();
setError('');
setTransferCodeError('');
setTransferCodeNotice('');
setAcceptingTransferCode(true);
try {
const response = await apiFetch(`/bird-transfer-codes/${encodeURIComponent(code)}/accept`, authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to accept bird transfer code.'));
}
const data =
(await readJsonSafely<{
bird?: Bird;
sourceWorkspaceName?: string;
}>(response)) ?? {};
if (!data.bird) {
throw new Error('Unable to accept bird transfer code.');
}
setBirds((current) => sortBirdsByName([...current.filter((bird) => bird.id !== data.bird!.id), data.bird!]));
setSelectedBirdId(data.bird.id);
setTransferCodeAcceptForm({ code: '' });
setTransferCodeNotice(`${data.bird.name} was transferred into ${workspace?.name ?? 'your active flock'}.`);
} catch (submitError) {
const message = submitError instanceof Error ? submitError.message : 'Unable to accept bird transfer code.';
setTransferCodeError(message);
setError(message);
} finally {
setAcceptingTransferCode(false);
}
};
const handleCreateAdoptionTransferCode = async () => {
if (!selectedBird || creatingAdoptionReportCode) {
return null;
}
const existingCode = adoptionTransferCodes[selectedBird.id];
if (existingCode) {
return existingCode;
}
setAdoptionReportError('');
setCreatingAdoptionReportCode(true);
try {
const response = await apiFetch(`/birds/${selectedBird.id}/transfer-code`, authToken, {
method: 'POST',
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to create adoption transfer code.'));
}
const data =
(await readJsonSafely<{
transferCode?: {
code?: string;
bird?: Bird;
};
}>(response)) ?? {};
const code = data.transferCode?.code;
if (!code) {
throw new Error('Unable to create adoption transfer code.');
}
if (data.transferCode?.bird) {
setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === data.transferCode!.bird!.id ? data.transferCode!.bird! : bird))));
}
setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: code }));
return code;
} catch (codeError) {
const message = codeError instanceof Error ? codeError.message : 'Unable to create adoption transfer code.';
setAdoptionReportError(message);
setError(message);
return null;
} finally {
setCreatingAdoptionReportCode(false);
}
};
const openAdoptionReport = (transferCode: string, reportWindow = window.open('', '_blank'), printFriendly = false) => {
if (!selectedBird) {
return;
}
if (!reportWindow) {
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
return;
}
const qr = createQrPath(transferCode);
const toReportAssetUrl = (value: string) =>
value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://') ? value : new URL(value, window.location.origin).toString();
const reportLogoUrl = toReportAssetUrl(flockPalLandingArt);
const reportPhotoUrl = toReportAssetUrl(selectedBird.photoDataUrl || defaultBirdPhoto);
const profileRows = [
['Name', selectedBird.name],
['Species', selectedBird.species],
['Band/tag ID', selectedBird.tagId || 'Not recorded'],
['Sex', getBirdGenderLabel(selectedBird)],
['Hatch day', formatDate(selectedBird.dateOfBirth)],
['Gotcha day', formatDate(selectedBird.gotchaDay)],
['Favorite snack', selectedBird.favoriteSnack || 'Not recorded'],
['Latest weight', selectedBird.latestWeightGrams ? `${formatWeight(selectedBird.latestWeightGrams)}${selectedBird.latestRecordedOn ? ` on ${formatShortDate(selectedBird.latestRecordedOn)}` : ''}` : 'Pending'],
];
const vetRows = [
['Clinic name', selectedBird.vetClinicName || 'Not recorded'],
['Clinic address', selectedBird.vetClinicAddress || 'Not recorded'],
['Account #', selectedBird.vetAccountNumber || 'Not recorded'],
['Dr. name', selectedBird.vetDoctorName || 'Not recorded'],
];
const detailList = (label: string, value: string | null) => {
const entries = parseBirdProfileList(value);
return entries.length
? `<section><h3>${escapeReportHtml(label)}</h3><ul>${entries.map((entry) => `<li>${escapeReportHtml(entry)}</li>`).join('')}</ul></section>`
: `<section><h3>${escapeReportHtml(label)}</h3><p>Not recorded</p></section>`;
};
const weightRows = weights.length
? weights
.map(
(entry) =>
`<tr><td>${escapeReportHtml(formatDate(entry.recordedOn))}</td><td>${escapeReportHtml(formatWeight(entry.weightGrams))}</td><td>${escapeReportHtml(entry.notes || '')}</td></tr>`,
)
.join('')
: '<tr><td colspan="3">No weights recorded.</td></tr>';
const vetVisitRows = vetVisits.length
? vetVisits
.map(
(visit) =>
`<tr><td>${escapeReportHtml(formatDate(visit.visitedOn))}</td><td>${escapeReportHtml(visit.clinicName)}</td><td>${escapeReportHtml(visit.reason)}</td><td>${escapeReportHtml(visit.notes || '')}</td></tr>`,
)
.join('')
: '<tr><td colspan="4">No vet visits recorded.</td></tr>';
const noteRows = selectedBirdNotes.length
? selectedBirdNotes
.map(
(note) =>
`<article class="note"><strong>${escapeReportHtml(formatDateTime(note.updatedAt))}</strong><p>${escapeReportHtml(note.body)}</p></article>`,
)
.join('')
: '<p>No notes recorded.</p>';
const chartSvg =
selectedBirdChart.points.length || selectedBirdChart.historicalPoints.length
? `<svg viewBox="0 0 ${MEMBER_CHART_WIDTH} ${MEMBER_CHART_HEIGHT}" role="img" aria-label="Weight graph">
${selectedBirdChart.yTicks
.map(
(tick) =>
`<line x1="${MEMBER_CHART_PADDING.left}" y1="${tick.y}" x2="${MEMBER_CHART_WIDTH - MEMBER_CHART_PADDING.right}" y2="${tick.y}" class="grid" />`,
)
.join('')}
${selectedBirdChart.historicalPath ? `<path d="${escapeReportHtml(selectedBirdChart.historicalPath)}" class="historical" />` : ''}
${selectedBirdChart.path ? `<path d="${escapeReportHtml(selectedBirdChart.path)}" class="current" />` : ''}
${selectedBirdChart.points
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="4" class="dot"><title>${escapeReportHtml(point.label)}</title></circle>`)
.join('')}
</svg>`
: '<p>No weight graph available yet.</p>';
const bodyBackground = printFriendly
? 'var(--paper)'
: `radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.28), transparent 22%),
radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.26), transparent 20%),
radial-gradient(circle at 24% 84%, rgba(221, 179, 78, 0.2), transparent 22%),
radial-gradient(circle at 86% 78%, rgba(43, 118, 92, 0.24), transparent 24%),
radial-gradient(circle at 62% 54%, rgba(48, 114, 160, 0.14), transparent 16%),
linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%)`;
const headerBackground = printFriendly
? '#fff'
: 'linear-gradient(135deg, rgba(252, 244, 228, 0.96), rgba(232, 243, 233, 0.9))';
const panelBackground = printFriendly ? '#fff' : 'var(--panel)';
const backgroundOverlayCss = printFriendly
? ''
: `body::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='420' viewBox='0 0 360 420'%3E%3Cg fill='none' stroke-linecap='round' stroke-width='18' opacity='.5'%3E%3Cg stroke='%235bb3b7' transform='translate(54 42) rotate(-18)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3Cg stroke='%237eb773' transform='translate(204 78) rotate(28)'%3E%3Cpath d='M0 -38v76'/%3E%3Cpath d='M-32 -19l64 38'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(312 54) rotate(-38)'%3E%3Cpath d='M0 -28v56'/%3E%3Cpath d='M-24 -14l48 28'/%3E%3C/g%3E%3Cg stroke='%23898b93' transform='translate(118 172) rotate(42)'%3E%3Cpath d='M0 -30v60'/%3E%3Cpath d='M-26 -15l52 30'/%3E%3C/g%3E%3Cg stroke='%23b9c945' transform='translate(278 208) rotate(-12)'%3E%3Cpath d='M0 -36v72'/%3E%3Cpath d='M-31 -18l62 36'/%3E%3C/g%3E%3Cg stroke='%235bb3b7' transform='translate(52 326) rotate(22)'%3E%3Cpath d='M0 -26v52'/%3E%3Cpath d='M-22 -13l44 26'/%3E%3C/g%3E%3Cg stroke='%23f3a24a' transform='translate(186 352) rotate(-48)'%3E%3Cpath d='M0 -34v68'/%3E%3Cpath d='M-29 -17l58 34'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-position: center top;
background-repeat: repeat;
background-size: 360px 420px;
content: "";
inset: 0;
opacity: 0.42;
pointer-events: none;
position: fixed;
z-index: -1;
}`;
reportWindow.document.write(`<!doctype html>
<html>
<head>
<title>FlockPal Adoption Report - ${escapeReportHtml(selectedBird.name)}</title>
<style>
:root {
--ink: #1f2a2a;
--muted: #5d5f59;
--paper: #fffdf9;
--panel: #fbf7ee;
--border: rgba(53, 129, 98, 0.28);
--red: #cb3a35;
--green: #238a5a;
--blue: #2769b3;
--gold: #f0b63f;
}
body {
background: ${bodyBackground};
color: var(--ink);
font-family: Inter, Arial, sans-serif;
line-height: 1.45;
margin: 32px;
min-height: 100vh;
position: relative;
}
${backgroundOverlayCss}
header {
background: ${headerBackground};
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 16px 34px rgba(86, 63, 34, 0.14);
display: grid;
gap: 22px;
grid-template-columns: 210px 1fr 172px;
min-height: 228px;
padding: 18px;
}
h1, h2, h3, p { margin: 0; }
h1 { color: var(--red); font-size: 34px; letter-spacing: 0; }
h2 {
border-bottom: 1px solid var(--border);
color: var(--green);
font-size: 19px;
margin: 28px 0 12px;
padding-bottom: 8px;
}
h3 { color: var(--blue); font-size: 14px; margin: 18px 0 8px; text-transform: uppercase; }
.muted { color: var(--muted); margin-top: 6px; }
.brand-logo {
align-self: center;
height: 210px;
justify-self: start;
object-fit: contain;
width: 210px;
}
.report-title {
align-self: center;
justify-self: center;
text-align: center;
}
.report-title .muted { margin-top: 8px; }
.profile-photo {
aspect-ratio: 1;
background: #fff;
border: 3px solid var(--paper);
border-radius: 18px;
box-shadow: 0 10px 22px rgba(86, 63, 34, 0.16);
height: 132px;
margin: 0 auto 12px;
object-fit: cover;
width: 132px;
}
.qr { align-self: center; justify-self: end; text-align: center; width: 170px; }
.qr svg { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 8px; width: 136px; }
.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; }
.grid { stroke: rgba(53, 129, 98, 0.16); }
.current { fill: none; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 4; }
.historical { fill: none; opacity: .45; stroke: ${escapeReportHtml(selectedBird.chartColor)}; stroke-linecap: round; stroke-width: 3; }
.dot { fill: ${escapeReportHtml(selectedBird.chartColor)}; stroke: white; stroke-width: 2; }
.facts { display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fact { background: ${panelBackground}; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.fact span { color: var(--muted); display: block; font-size: 12px; margin-bottom: 4px; text-transform: uppercase; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.note { border-bottom: 1px solid var(--border); padding: 10px 0; }
.note p { margin-top: 6px; white-space: pre-wrap; }
main { margin-top: 24px; }
@media print {
body { margin: 14mm; }
header { box-shadow: none; break-inside: avoid; }
button { display: none; }
}
</style>
</head>
<body>
<header>
<img class="brand-logo" src="${escapeReportHtml(reportLogoUrl)}" alt="FlockPal logo">
<div class="report-title">
<img class="profile-photo" src="${escapeReportHtml(reportPhotoUrl)}" alt="${escapeReportHtml(selectedBird.name)} profile photo">
<h1>${escapeReportHtml(selectedBird.name)}</h1>
<p class="muted">Adoption Report</p>
<p class="muted">Generated ${escapeReportHtml(formatDateTime(new Date().toISOString()))}</p>
</div>
<div class="qr">
<svg viewBox="0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}" role="img" aria-label="Transfer code QR">
<rect width="${qr.viewBoxSize}" height="${qr.viewBoxSize}" fill="#fff"></rect>
<path d="${escapeReportHtml(qr.path)}" fill="#111418"></path>
</svg>
<p class="code">${escapeReportHtml(transferCode)}</p>
</div>
</header>
<main>
<h2>Flock Member Info</h2>
<section class="facts">
${profileRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
</section>
${detailList('Motivators', selectedBird.motivators)}
${detailList('Demotivators', selectedBird.demotivators)}
<h2>Weight Graph</h2>
${chartSvg}
<h2>Weight History</h2>
<table><thead><tr><th>Date</th><th>Weight</th><th>Notes</th></tr></thead><tbody>${weightRows}</tbody></table>
<h2>Veterinary Clinic Info</h2>
<section class="facts">
${vetRows.map(([label, value]) => `<div class="fact"><span>${escapeReportHtml(label)}</span><strong>${escapeReportHtml(value)}</strong></div>`).join('')}
</section>
<h2>Vet Visit History</h2>
<table><thead><tr><th>Date</th><th>Clinic</th><th>Reason</th><th>Notes</th></tr></thead><tbody>${vetVisitRows}</tbody></table>
<h2>Notes</h2>
${noteRows}
</main>
</body>
</html>`);
reportWindow.document.close();
reportWindow.focus();
};
const handleOpenAdoptionReport = async (printFriendly = false) => {
const reportWindow = window.open('', '_blank');
if (!reportWindow) {
setAdoptionReportError('Unable to open the adoption report. Please allow pop-ups for FlockPal and try again.');
return;
}
const code = selectedBirdAdoptionTransferCode || (await handleCreateAdoptionTransferCode());
if (code) {
openAdoptionReport(code, reportWindow, printFriendly);
} else {
reportWindow.close();
}
};
const saveWorkspaceSettings = async () => {
const response = await apiFetch('/workspace', authToken, {
method: 'PUT',
@@ -5649,7 +6019,7 @@ function App() {
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID
Band ID, if known
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
@@ -6151,6 +6521,19 @@ function App() {
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360l280 280v360q0 33-23.5 56.5T760-120H200Zm320-400v-240H200v560h560v-320H520ZM280-280h400v-80H280v80Zm0-160h240v-80H280v80Zm-80-320v240-240 560-560Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'reports' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('reports')}
type="button"
role="tab"
aria-selected={selectedBirdTab === 'reports'}
aria-label="Reports"
title="Reports"
>
<svg className="report-tab-icon" viewBox="0 -960 960 960" aria-hidden="true" focusable="false">
<path d="M240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM320-240h320v-80H320v80Zm0-160h320v-80H320v80Zm-80-400v200-200 640-640Z" />
</svg>
</button>
<button
className={`bird-detail-tab ${selectedBirdTab === 'audit' ? 'active' : ''}`}
onClick={() => setSelectedBirdTab('audit')}
@@ -6759,6 +7142,51 @@ function App() {
</div>
) : null}
{selectedBirdTab === 'reports' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Reports</p>
<h2>Adoption report</h2>
</div>
</div>
<p className="muted">
Create a print-ready adoption report with profile details, weight history, veterinary clinic info, vet visits,
notes, and a transfer code for accepting {selectedBird.name} into another active flock.
</p>
<div className="detail-grid">
<article className="detail-card">
<span>Transfer code</span>
<strong>{selectedBirdAdoptionTransferCode || 'Not generated'}</strong>
</article>
<article className="detail-card">
<span>Included records</span>
<strong>
{weights.length} weights {vetVisits.length} vet visits {selectedBirdNotes.length} notes
</strong>
</article>
</div>
<div className="button-row">
<button className="secondary-button" onClick={handleCreateAdoptionTransferCode} type="button" disabled={creatingAdoptionReportCode}>
{creatingAdoptionReportCode ? 'Creating code...' : selectedBirdAdoptionTransferCode ? 'Code ready' : 'Create transfer code'}
</button>
<button className="primary-button" onClick={() => handleOpenAdoptionReport(false)} type="button" disabled={creatingAdoptionReportCode}>
{creatingAdoptionReportCode ? 'Preparing report...' : 'Open adoption report'}
</button>
<button className="secondary-button" onClick={() => handleOpenAdoptionReport(true)} type="button" disabled={creatingAdoptionReportCode}>
Print-friendly report
</button>
</div>
{adoptionReportError ? (
<p className="error-banner" role="alert">
{adoptionReportError}
</p>
) : null}
</section>
</div>
) : null}
{selectedBirdTab === 'audit' ? (
<div className="flock-member-sections" role="tabpanel">
<section className="panel inset-panel">
@@ -7544,7 +7972,7 @@ function App() {
<input value={birdForm.name} onChange={(event) => setBirdForm({ ...birdForm, name: event.target.value })} required />
</label>
<label>
Band ID
Band ID, if known
<input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
@@ -8216,56 +8644,103 @@ function App() {
Transfer a bird to another flock by entering the receiving flock owner's email. This keeps the bird record, weight history, and
vet visits attached while changing which flock owns it.
</p>
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
<label>
Bird to move
<select
value={flockTransferForm.birdId}
onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
required
>
<option value="">Select a bird from this flock</option>
{birds.map((bird) => (
<option key={bird.id} value={bird.id}>
{bird.name} {bird.species} {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
</option>
))}
</select>
</label>
<label>
Receiving flock owner email
<input
type="email"
value={flockTransferForm.destinationOwnerEmail}
onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
placeholder="owner@example.com"
required
/>
</label>
<button className="primary-button" type="submit" disabled={transferringBird}>
{transferringBird ? 'Transferring bird...' : 'Transfer bird'}
</button>
{transferError ? (
<p className="error-banner" role="alert">
{transferError}
<div className="settings-nested-stack">
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Owner transfer</p>
<h3>Send to a known flock owner</h3>
</div>
<form className="form-panel" onSubmit={handleFlockTransferSubmit}>
<label>
Bird to move
<select
value={flockTransferForm.birdId}
onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, birdId: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
required
>
<option value="">Select a bird from this flock</option>
{birds.map((bird) => (
<option key={bird.id} value={bird.id}>
{bird.name} {bird.species} {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
</option>
))}
</select>
</label>
<label>
Receiving flock owner email
<input
type="email"
value={flockTransferForm.destinationOwnerEmail}
onChange={(event) => {
setFlockTransferForm({ ...flockTransferForm, destinationOwnerEmail: event.target.value });
setTransferError('');
setTransferNotice(null);
}}
placeholder="owner@example.com"
required
/>
</label>
<button className="primary-button" type="submit" disabled={transferringBird}>
{transferringBird ? 'Transferring bird...' : 'Transfer bird'}
</button>
{transferError ? (
<p className="error-banner" role="alert">
{transferError}
</p>
) : null}
{transferNotice ? (
<article className="summary-card" role="status">
<strong>Pending transfer invite sent</strong>
<span>{transferNotice.message}</span>
{transferNotice.previewUrl ? <a href={transferNotice.previewUrl}>Open invite link</a> : null}
</article>
) : null}
</form>
</section>
<section className="settings-nested-card">
<div className="settings-nested-header">
<p className="eyebrow">Transfer code</p>
<h3>Accept a bird into this flock</h3>
</div>
<p className="muted">
Enter a transfer code from an adoption or handoff report. The bird will move into your active flock automatically.
</p>
) : null}
{transferNotice ? (
<article className="summary-card" role="status">
<strong>Pending transfer invite sent</strong>
<span>{transferNotice.message}</span>
{transferNotice.previewUrl ? <a href={transferNotice.previewUrl}>Open invite link</a> : null}
</article>
) : null}
</form>
<form className="form-panel" onSubmit={handleTransferCodeAcceptSubmit}>
<label>
Transfer code
<input
value={transferCodeAcceptForm.code}
onChange={(event) => {
setTransferCodeAcceptForm({ code: event.target.value });
setTransferCodeError('');
setTransferCodeNotice('');
}}
placeholder="Paste transfer code"
required
/>
</label>
<button className="primary-button" type="submit" disabled={acceptingTransferCode}>
{acceptingTransferCode ? 'Accepting transfer...' : 'Accept bird transfer'}
</button>
{transferCodeError ? (
<p className="error-banner" role="alert">
{transferCodeError}
</p>
) : null}
{transferCodeNotice ? (
<article className="summary-card" role="status">
<strong>Transfer accepted</strong>
<span>{transferCodeNotice}</span>
</article>
) : null}
</form>
</section>
</div>
</>
) : null}
</article>
+38
View File
@@ -1823,6 +1823,44 @@ label {
accent-color: var(--accent-green);
}
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: min(100%, 420px);
margin: 0.35rem 0 0;
padding: 0.85rem 1rem;
border: 1px solid rgba(53, 129, 98, 0.24);
border-radius: 16px;
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 12px 24px rgba(86, 63, 34, 0.1);
}
.checkbox-row input[type="checkbox"] {
width: 20px;
height: 20px;
flex: 0 0 auto;
margin: 0.1rem 0 0;
padding: 0;
accent-color: var(--accent-green);
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
line-height: 1.3;
}
.checkbox-row strong {
color: var(--ink);
font-size: 0.98rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
}
.primary-button {
border: 0;
border-radius: 18px;