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
+331
View File
@@ -8,6 +8,8 @@
"name": "flockpal-frontend",
"version": "0.1.0",
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
"react": "18.3.1",
"react-dom": "18.3.1"
},
@@ -1144,6 +1146,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1151,6 +1162,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
@@ -1192,6 +1212,30 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
@@ -1239,6 +1283,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001786",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
@@ -1260,6 +1313,35 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1292,6 +1374,21 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.331",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
@@ -1299,6 +1396,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1348,6 +1451,19 @@
"node": ">=6"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1373,6 +1489,24 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1405,6 +1539,18 @@
"node": ">=6"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1460,6 +1606,51 @@
"dev": true,
"license": "MIT"
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1467,6 +1658,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -1496,6 +1696,23 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1531,6 +1748,21 @@
"node": ">=0.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -1595,6 +1827,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1605,6 +1843,32 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@@ -1619,6 +1883,12 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1710,12 +1980,73 @@
}
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}
+2
View File
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
"react": "18.3.1",
"react-dom": "18.3.1"
},
+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;
}
}