added qr, cleaned up profile views, and added the critical alerts

This commit is contained in:
blaisadmin
2026-05-20 21:54:17 -04:00
parent f2c506ec16
commit 1c0d57299d
9 changed files with 949 additions and 60 deletions
+248 -52
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import birdSilhouette from './assets/bird-silhouette.jpg';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
import defaultBirdPhoto from './assets/yoda-default.png';
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
import QRCode from 'qrcode';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
@@ -29,6 +31,8 @@ type Bird = {
photoDataUrl: string | null;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
publicProfileCode: string | null;
publicProfileEnabled: boolean;
memorializedAt: string | null;
memorializedOn: string | null;
memorialNote: string | null;
@@ -192,6 +196,16 @@ type BirdFormState = {
photoDataUrl: string;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
publicProfileEnabled: boolean;
};
type PublicBirdProfile = {
id: string;
workspaceId: number;
name: string;
gender: BirdGender;
dateOfBirth: string | null;
photoDataUrl: string | null;
};
type MemorializeBirdFormState = {
@@ -317,6 +331,47 @@ type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace'
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
const sessionTokenStorageKey = 'flockpal_auth_token';
const dismissedAlertsStorageKey = 'flockpal_dismissed_alerts';
const getPublicProfileCodeFromPath = () => window.location.pathname.match(/^\/b\/([A-Za-z0-9_-]{8,32})\/?$/)?.[1] ?? '';
const getPublicProfileUrl = (code: string) => `${window.location.origin}/b/${code}`;
const QR_MARGIN = 4;
const createQrPath = (value: string) => {
const qr = QRCode.create(value, { errorCorrectionLevel: 'H' });
const size = qr.modules.size;
const data = qr.modules.data;
const pathParts: string[] = [];
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
if (data[y * size + x]) {
pathParts.push(`M${x + QR_MARGIN},${y + QR_MARGIN}h1v1h-1z`);
}
}
}
return {
path: pathParts.join(''),
size,
viewBoxSize: size + QR_MARGIN * 2,
};
};
const QrCodeWithLogo = ({ value, label }: { value: string; label: string }) => {
const qr = useMemo(() => createQrPath(value), [value]);
const logoSize = Math.max(7, qr.size * 0.18);
const logoPosition = (qr.viewBoxSize - logoSize) / 2;
return (
<svg className="qr-code" viewBox={`0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}`} role="img" aria-label={label}>
<rect width={qr.viewBoxSize} height={qr.viewBoxSize} fill="#fff" />
<path d={qr.path} fill="#111418" />
<g className="qr-bird-mark" aria-hidden="true">
<rect x={logoPosition - 0.45} y={logoPosition - 0.45} width={logoSize + 0.9} height={logoSize + 0.9} rx="1.7" />
<image href={birdSilhouette} x={logoPosition} y={logoPosition} width={logoSize} height={logoSize} preserveAspectRatio="xMidYMid meet" />
</g>
</svg>
);
};
const emptyBirdForm: BirdFormState = {
name: '',
tagId: '',
@@ -331,6 +386,7 @@ const emptyBirdForm: BirdFormState = {
photoDataUrl: '',
notifyOnDob: false,
notifyOnGotchaDay: false,
publicProfileEnabled: false,
};
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
@@ -456,6 +512,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
photoDataUrl: bird.photoDataUrl ?? '',
notifyOnDob: bird.notifyOnDob,
notifyOnGotchaDay: bird.notifyOnGotchaDay,
publicProfileEnabled: bird.publicProfileEnabled,
});
const formatDate = (value: string | null) => {
@@ -1117,6 +1174,10 @@ function App() {
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
const [lostBirdReportNotice, setLostBirdReportNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
const [lostBirdReportSubmitting, setLostBirdReportSubmitting] = useState(false);
const [publicProfileCode] = useState(getPublicProfileCodeFromPath);
const [publicProfile, setPublicProfile] = useState<PublicBirdProfile | null>(null);
const [publicProfileLoading, setPublicProfileLoading] = useState(Boolean(getPublicProfileCodeFromPath()));
const [publicProfileError, setPublicProfileError] = useState('');
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
@@ -1159,6 +1220,7 @@ function App() {
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
const [qrBird, setQrBird] = useState<Bird | null>(null);
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
const [bulkWeightOpen, setBulkWeightOpen] = useState(false);
const [savingBulkWeights, setSavingBulkWeights] = useState(false);
@@ -1236,6 +1298,16 @@ function App() {
const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird);
useEffect(() => {
if (!publicProfile || !authSession || workspace?.id !== publicProfile.workspaceId || !birds.some((bird) => bird.id === publicProfile.id)) {
return;
}
setSelectedBirdId(publicProfile.id);
setActivePage('flock');
window.history.replaceState({}, document.title, '/');
}, [authSession, birds, publicProfile, workspace?.id]);
const missingFirstWeightCount = useMemo(
() => birds.filter((bird) => bird.latestWeightGrams === null).length,
[birds],
@@ -1394,8 +1466,6 @@ function App() {
[activeVetVisitDueBirds, allBirdVetVisits, dismissedAlerts, workspace?.id],
);
const vetVisitDueNames = vetVisitDueBirds.slice(0, 3).map((bird) => bird.name).join(', ');
const vetVisitDueOverflowCount = Math.max(vetVisitDueBirds.length - 3, 0);
const vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]);
const activeMedications = useMemo(
@@ -1785,6 +1855,37 @@ function App() {
void bootstrapSession();
}, []);
useEffect(() => {
if (!publicProfileCode) {
return;
}
const loadPublicProfile = async () => {
try {
setPublicProfileLoading(true);
setPublicProfileError('');
const response = await apiFetch(`/public/birds/${publicProfileCode}`);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Public bird profile not found.'));
}
const data = (await readJsonSafely<{ bird?: PublicBirdProfile }>(response)) ?? {};
if (!data.bird) {
throw new Error('Public bird profile not found.');
}
setPublicProfile(data.bird);
} catch (profileError) {
setPublicProfileError(profileError instanceof Error ? profileError.message : 'Public bird profile not found.');
} finally {
setPublicProfileLoading(false);
}
};
void loadPublicProfile();
}, [publicProfileCode]);
useEffect(() => {
if (!authToken || !workspace?.id) {
setLoading(false);
@@ -2150,6 +2251,21 @@ function App() {
}
};
const handleOpenPublicProfileBird = async () => {
if (!publicProfile || !authSession) {
return;
}
if (workspace?.id !== publicProfile.workspaceId) {
await handleWorkspaceSwitch(publicProfile.workspaceId, 'flock');
return;
}
setSelectedBirdId(publicProfile.id);
setActivePage('flock');
window.history.replaceState({}, document.title, '/');
};
const handleCreateIntegrationToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -3457,6 +3573,55 @@ function App() {
setActivePage('flock');
};
const publicProfileWorkspaceMembership = publicProfile
? authSession?.workspaces.find((entry) => entry.workspace.id === publicProfile.workspaceId) ?? null
: null;
const shouldShowPublicProfilePage =
Boolean(publicProfileCode) &&
(!authSession ||
!publicProfile ||
workspace?.id !== publicProfile.workspaceId ||
!birds.some((bird) => bird.id === publicProfile.id));
if (shouldShowPublicProfilePage) {
return (
<main className="auth-shell public-profile-shell">
<section className="panel public-profile-card">
{publicProfileLoading || authLoading ? (
<p>Loading bird profile...</p>
) : publicProfileError || !publicProfile ? (
<>
<p className="eyebrow">FlockPal</p>
<h1>Public profile unavailable</h1>
<p className="muted">{publicProfileError || 'This bird profile is not available publicly.'}</p>
</>
) : (
<>
<img className="public-profile-photo" src={publicProfile.photoDataUrl || defaultBirdPhoto} alt={publicProfile.name} />
<div className="public-profile-copy">
<h1>
<span>{publicProfile.name}</span>
<span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${publicProfile.gender}`}>
{getBirdGenderSymbol(publicProfile)}
</span>
</h1>
<article className="summary-card">
<span>Hatch Day</span>
<strong>{formatDate(publicProfile.dateOfBirth)}</strong>
</article>
{publicProfileWorkspaceMembership ? (
<button className="primary-button" onClick={handleOpenPublicProfileBird} type="button">
Open full profile
</button>
) : null}
</div>
</>
)}
</section>
</main>
);
}
if (authLoading) {
return (
<main className="auth-shell">
@@ -3770,6 +3935,35 @@ function App() {
<section className="content-shell">
{error ? <p className="error-banner">{error}</p> : null}
{(activePage === 'overview' || activePage === 'flock') && (totalWeightAlerts || vetVisitDueBirds.length) ? (
<section className="top-alert-notification" role="alert" aria-label="Critical flock alert">
<span className="notification-bell" aria-hidden="true" />
<div>
<strong>
{totalWeightAlerts + vetVisitDueBirds.length} critical alert{totalWeightAlerts + vetVisitDueBirds.length === 1 ? '' : 's'}
</strong>
<span>
{totalWeightAlerts ? `${totalWeightAlerts} weight alert${totalWeightAlerts === 1 ? '' : 's'}` : ''}
{totalWeightAlerts && vetVisitDueBirds.length ? ' ' : ''}
{vetVisitDueBirds.length
? `${vetVisitDueBirds.length} annual vet reminder${vetVisitDueBirds.length === 1 ? '' : 's'}`
: ''}
</span>
</div>
<div className="top-alert-actions">
{totalWeightAlerts ? (
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
Review weights
</button>
) : null}
{vetVisitDueBirds.length ? (
<button className="range-alert-button" onClick={handleVetVisitReminderClick} type="button">
Review vet visits
</button>
) : null}
</div>
</section>
) : null}
{activePage === 'overview' ? (
<section className="stack-grid">
@@ -3780,11 +3974,6 @@ function App() {
<h2>30-day flock weight snapshot</h2>
</div>
<div className="button-row overview-alert-actions">
{totalWeightAlerts ? (
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
{totalWeightAlerts} weight alert{totalWeightAlerts === 1 ? '' : 's'}
</button>
) : null}
<p className="muted">
{birdsWithRecentWeights.length} current
{overviewHistoricalSeriesCount > 0 ? `, ${overviewHistoricalSeriesCount} previous-year` : ''}
@@ -3909,43 +4098,12 @@ function App() {
<strong>{missingFirstWeightCount}</strong>
<span>Members still needing a first weight</span>
</article>
) : null}
{totalWeightAlerts ? (
<article className="summary-card summary-alert-card">
<span>Weight alerts</span>
<strong>
{totalWeightAlerts} alert{totalWeightAlerts === 1 ? '' : 's'} need review
</strong>
{outOfRangeBirds.length ? (
<span>
{outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges
</span>
) : null}
{weightDropAlerts.length ? (
<span>
{weightDropAlerts.length} bird{weightDropAlerts.length === 1 ? '' : 's'} down 5-10% between recent entries
</span>
) : null}
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
Review alerts
</button>
) : (
<article className="summary-card">
<span>First weights</span>
<strong>All recorded</strong>
</article>
) : null}
{vetVisitDueBirds.length ? (
<article className="summary-card summary-alert-card">
<span>Vet visit reminder</span>
<strong>
{vetVisitDueBirds.length} member{vetVisitDueBirds.length === 1 ? '' : 's'} need annual visit review
</strong>
<span>
No vet visit logged in the last 365 days for {vetVisitDueNames}
{vetVisitDueOverflowCount ? ` and ${vetVisitDueOverflowCount} more` : ''}.
</span>
<button className="range-alert-button" onClick={handleVetVisitReminderClick} type="button">
Review vet visits
</button>
</article>
) : null}
)}
<article className="summary-card">
<span>Weekly flock changes</span>
{flockWeeklyTrendItems.length ? (
@@ -4247,8 +4405,14 @@ function App() {
<>
<section className="profile-hero">
<img className="profile-photo" src={selectedBird.photoDataUrl || defaultBirdPhoto} alt={`${selectedBird.name}`} />
{selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? (
<button className="qr-profile-button" onClick={() => setQrBird(selectedBird)} type="button" aria-label={`Open QR code for ${selectedBird.name}`}>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 3h7v7H3V3Zm2 2v3h3V5H5Zm9-2h7v7h-7V3Zm2 2v3h3V5h-3ZM3 14h7v7H3v-7Zm2 2v3h3v-3H5Zm10-1h2v2h-2v-2Zm4 0h2v2h-2v-2Zm-5 4h2v2h-2v-2Zm3-2h2v2h-2v-2Zm2 2h2v2h-2v-2Z" />
</svg>
</button>
) : null}
<div className="profile-copy">
<p className="eyebrow">Profile</p>
<h3 className="profile-title">
<span>{selectedBird.name}</span>
<span
@@ -4266,10 +4430,6 @@ function App() {
</section>
<div className="detail-grid">
<article className="detail-card">
<span>Band ID</span>
<strong>{selectedBird.tagId || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Hatch Day</span>
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
@@ -4278,10 +4438,6 @@ function App() {
<span>Gotcha day</span>
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
</article>
<article className="detail-card">
<span>Species</span>
<strong>{selectedBird.species}</strong>
</article>
<article className="detail-card">
<span>Gender</span>
<strong className="detail-gender">
@@ -5333,6 +5489,14 @@ function App() {
placeholder="Optional if unknown"
/>
</label>
<label className="toggle-field">
<input
type="checkbox"
checked={birdForm.publicProfileEnabled}
onChange={(event) => setBirdForm({ ...birdForm, publicProfileEnabled: event.target.checked })}
/>
<span>Enable QR public profile</span>
</label>
<label className="species-picker-field wide-field">
Species
<div className="species-picker">
@@ -5897,6 +6061,38 @@ function App() {
) : null}
</section>
{qrBird?.publicProfileCode ? (
<div className="app-modal-backdrop" role="presentation" onClick={() => setQrBird(null)}>
<section
className="app-modal qr-modal"
role="dialog"
aria-modal="true"
aria-labelledby="qr-modal-title"
onClick={(event) => event.stopPropagation()}
>
<div className="panel-header no-print">
<div>
<p className="eyebrow">QR profile</p>
<h2 id="qr-modal-title">{qrBird.name}</h2>
</div>
<div className="button-row">
<button className="secondary-button" onClick={() => window.print()} type="button">
Print
</button>
<button className="secondary-button" onClick={() => setQrBird(null)} type="button">
Close
</button>
</div>
</div>
<div className="qr-print-card">
<QrCodeWithLogo value={getPublicProfileUrl(qrBird.publicProfileCode)} label={`QR code for ${qrBird.name}`} />
<h3>{qrBird.name}</h3>
<p>{getPublicProfileUrl(qrBird.publicProfileCode)}</p>
</div>
</section>
</div>
) : null}
{showWeightAlertModal ? (
<div className="app-modal-backdrop" role="presentation" onClick={() => setShowWeightAlertModal(false)}>
<section
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+273 -2
View File
@@ -122,6 +122,70 @@ textarea {
gap: 1.5rem;
}
.top-alert-notification {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
padding: 0.85rem 1rem;
border: 1px solid rgba(203, 58, 53, 0.26);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 247, 244, 0.98), rgba(255, 238, 231, 0.96));
box-shadow: 0 16px 30px rgba(203, 58, 53, 0.14);
}
.top-alert-notification div {
display: grid;
gap: 0.1rem;
}
.top-alert-notification strong {
color: var(--accent-red);
}
.top-alert-notification span {
color: var(--muted);
}
.notification-bell {
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(203, 58, 53, 0.12);
border: 1px solid rgba(203, 58, 53, 0.22);
position: relative;
}
.notification-bell::before {
content: "";
position: absolute;
left: 10px;
top: 7px;
width: 12px;
height: 15px;
border: 2px solid var(--accent-red);
border-bottom: 0;
border-radius: 8px 8px 4px 4px;
}
.notification-bell::after {
content: "";
position: absolute;
left: 12px;
top: 22px;
width: 10px;
height: 5px;
border-top: 2px solid var(--accent-red);
border-radius: 50%;
}
.top-alert-actions {
display: flex;
flex-wrap: wrap;
justify-content: end;
gap: 0.6rem;
}
.side-rail {
position: sticky;
top: 2rem;
@@ -155,6 +219,41 @@ textarea {
align-items: stretch;
}
.public-profile-shell {
max-width: 620px;
}
.public-profile-card {
display: grid;
gap: 1.1rem;
justify-items: center;
text-align: center;
}
.public-profile-photo {
width: min(260px, 100%);
aspect-ratio: 1;
object-fit: cover;
border-radius: 28px;
border: 1px solid rgba(39, 105, 179, 0.16);
background: rgba(255, 255, 255, 0.86);
}
.public-profile-copy {
display: grid;
gap: 1rem;
justify-items: center;
}
.public-profile-copy h1 {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
margin: 0;
font-size: 2rem;
}
.auth-hero-card {
min-height: 280px;
align-items: end;
@@ -948,6 +1047,32 @@ textarea {
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
border: 1px solid rgba(39, 105, 179, 0.1);
position: relative;
}
.qr-profile-button {
position: absolute;
top: 0.85rem;
right: 0.85rem;
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid rgba(39, 105, 179, 0.18);
border-radius: 14px;
background: rgba(255, 254, 250, 0.9);
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
}
.qr-profile-button:hover {
transform: translateY(-1px);
border-color: rgba(35, 138, 90, 0.34);
}
.qr-profile-button svg {
width: 24px;
height: 24px;
fill: var(--accent-blue);
}
.profile-copy {
@@ -1371,6 +1496,21 @@ label {
accent-color: var(--accent-green);
}
.toggle-field {
display: flex;
align-items: center;
gap: 0.65rem;
padding-top: 1.75rem;
}
.toggle-field input[type="checkbox"] {
width: 20px;
height: 20px;
margin: 0;
padding: 0;
accent-color: var(--accent-green);
}
.primary-button {
border: 0;
border-radius: 18px;
@@ -1555,11 +1695,79 @@ label {
gap: 1rem;
}
.qr-modal {
width: min(520px, 100%);
}
.qr-print-card {
display: grid;
gap: 0.8rem;
justify-items: center;
text-align: center;
padding: 1rem;
border-radius: 22px;
background: #fffdf9;
}
.qr-code {
width: min(280px, 100%);
height: auto;
image-rendering: pixelated;
}
.qr-bird-mark rect {
fill: rgba(255, 255, 255, 0.96);
}
.qr-print-card h3,
.qr-print-card p {
margin: 0;
}
.qr-print-card p {
max-width: 100%;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 0.9rem;
}
.modal-alert-list {
display: grid;
gap: 0.9rem;
}
@media print {
body {
background: #fff;
}
body::before,
.no-print {
display: none;
}
.app-modal-backdrop {
position: static;
display: block;
padding: 0;
background: #fff;
backdrop-filter: none;
}
.app-modal {
box-shadow: none;
border: 0;
width: 100%;
max-height: none;
overflow: visible;
}
.qr-print-card {
min-height: 100vh;
align-content: center;
}
}
@media (max-width: 980px) {
.app-shell,
.auth-panel,
@@ -1577,6 +1785,7 @@ label {
.app-shell {
padding: 1rem;
gap: 0.85rem;
}
.settings-grid {
@@ -1588,11 +1797,73 @@ label {
grid-column: auto;
}
.side-nav {
position: static;
.top-alert-notification {
grid-template-columns: auto minmax(0, 1fr);
}
.top-alert-actions {
grid-column: 1 / -1;
justify-content: start;
}
.side-rail {
position: static;
gap: 0.55rem;
}
.brand-lockup {
display: none;
}
.side-nav.panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
padding: 0.65rem;
border-radius: 20px;
}
.page-tabs {
grid-template-columns: repeat(auto-fit, minmax(82px, 1fr));
gap: 0.4rem;
}
.page-tab {
min-height: 42px;
padding: 0.55rem 0.65rem;
border-radius: 14px;
text-align: center;
font-size: 0.92rem;
font-weight: 700;
}
.side-nav .secondary-button {
min-height: 42px;
padding: 0.55rem 0.75rem;
border-radius: 14px;
white-space: nowrap;
}
.workspace-switcher {
grid-column: 1 / -1;
gap: 0.5rem;
}
.workspace-switcher-list {
display: flex;
gap: 0.45rem;
overflow-x: auto;
padding-bottom: 0.1rem;
}
.workspace-switcher-item {
min-width: 160px;
padding: 0.55rem 0.7rem;
border-radius: 14px;
}
.workspace-switcher-item small {
display: none;
}
}