From 1c0d57299ddbf29a58b55d88fd8add8fce1e6026 Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Wed, 20 May 2026 21:54:17 -0400 Subject: [PATCH] added qr, cleaned up profile views, and added the critical alerts --- backend/src/app.ts | 42 +++ backend/src/db/schema.ts | 8 + backend/src/repositories/birdRepository.ts | 49 ++- backend/src/types.ts | 2 + frontend/package-lock.json | 331 +++++++++++++++++++++ frontend/package.json | 2 + frontend/src/App.tsx | 300 +++++++++++++++---- frontend/src/assets/bird-silhouette.jpg | Bin 0 -> 8059 bytes frontend/src/index.css | 275 ++++++++++++++++- 9 files changed, 949 insertions(+), 60 deletions(-) create mode 100644 frontend/src/assets/bird-silhouette.jpg diff --git a/backend/src/app.ts b/backend/src/app.ts index f838c59..88a117e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -44,6 +44,7 @@ import { deleteMedicationForBird, deleteVetVisitForBird, getBirdById, + getBirdByPublicProfileCode, listBirds, listDueBirdMilestoneReminders, listMemorializedBirds, @@ -231,6 +232,8 @@ const lostBirdReportSchema = z.object({ message: z.string().trim().max(1000).optional().or(z.literal('')), }); +const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/); + const birdSchema = z.object({ name: z.string().trim().min(1).max(120), tagId: z.string().trim().max(80).optional().or(z.literal('')), @@ -245,6 +248,7 @@ const birdSchema = z.object({ photoDataUrl: z.union([photoDataUrlSchema, photoUrlSchema, z.literal('')]).optional(), notifyOnDob: z.boolean().optional(), notifyOnGotchaDay: z.boolean().optional(), + publicProfileEnabled: z.boolean().optional(), }); const memorializeBirdSchema = z.object({ @@ -325,6 +329,7 @@ const hashToken = (token: string) => crypto.createHash('sha256').update(token).d const createSessionToken = () => crypto.randomBytes(32).toString('hex'); const photoAccessSecret = process.env.PHOTO_ACCESS_SECRET?.trim() || process.env.S3_SECRET_ACCESS_KEY?.trim() || createSessionToken(); const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`; +const createPublicProfileCode = () => crypto.randomBytes(9).toString('base64url'); const createRandomId = () => crypto.randomUUID(); const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url'); const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url'); @@ -579,6 +584,8 @@ const normalizeBird = (row: BirdRow) => ({ photoUpdatedAt: row.photo_updated_at, notifyOnDob: row.notify_on_dob, notifyOnGotchaDay: row.notify_on_gotcha_day, + publicProfileCode: row.public_profile_code ?? null, + publicProfileEnabled: row.public_profile_enabled ?? false, memorializedAt: row.memorialized_at, memorializedOn: row.memorialized_on, memorialNote: row.memorial_note, @@ -588,6 +595,15 @@ const normalizeBird = (row: BirdRow) => ({ latestRecordedOn: row.latest_recorded_on, }); +const normalizePublicBirdProfile = (row: BirdRow) => ({ + id: row.id, + workspaceId: row.workspace_id, + name: row.name, + gender: row.gender, + dateOfBirth: row.date_of_birth, + photoDataUrl: getBirdPhotoUrl(row), +}); + const normalizeWeight = (row: WeightRow) => ({ id: row.id, birdId: row.bird_id, @@ -1949,6 +1965,28 @@ app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, re } }); +app.get('/api/public/birds/:publicProfileCode', async (req: Request, res: Response, next: NextFunction) => { + const parsed = publicProfileCodeSchema.safeParse(req.params.publicProfileCode); + + if (!parsed.success) { + res.status(404).json({ error: 'Public bird profile not found.' }); + return; + } + + try { + const bird = await getBirdByPublicProfileCode(parsed.data); + + if (!bird) { + res.status(404).json({ error: 'Public bird profile not found.' }); + return; + } + + res.json({ bird: normalizePublicBirdProfile(bird) }); + } catch (error) { + next(error); + } +}); + app.get('/api/auth/providers', (_req: Request, res: Response) => { res.json({ providers: Object.values(oauthProviders).map((provider) => ({ @@ -2858,6 +2896,8 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o photoUpdatedAt: photoStorage.photoUpdatedAt, notifyOnDob: parsed.data.notifyOnDob ?? false, notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false, + publicProfileCode: createPublicProfileCode(), + publicProfileEnabled: parsed.data.publicProfileEnabled ?? false, }); uploadedObjectKeyToCleanup = null; @@ -2998,6 +3038,8 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR photoUpdatedAt: photoStorage.photoUpdatedAt, notifyOnDob: parsed.data.notifyOnDob ?? false, notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false, + publicProfileCode: existingBird.public_profile_code ?? createPublicProfileCode(), + publicProfileEnabled: parsed.data.publicProfileEnabled ?? false, }); if (!bird) { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 83751c1..875fd1d 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -225,6 +225,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => { photo_updated_at TIMESTAMPTZ, notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE, notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE, + public_profile_code VARCHAR(32), + public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE, memorialized_at TIMESTAMPTZ, memorialized_on DATE, memorial_note VARCHAR(1000), @@ -247,6 +249,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ADD COLUMN IF NOT EXISTS photo_updated_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS public_profile_code VARCHAR(32), + ADD COLUMN IF NOT EXISTS public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS memorialized_on DATE, ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000), @@ -305,6 +309,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ON birds (photo_object_key) WHERE photo_object_key IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_public_profile_code + ON birds (public_profile_code) + WHERE public_profile_code IS NOT NULL; + CREATE TABLE IF NOT EXISTS pending_bird_transfers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index ba4cfa1..d194430 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -33,6 +33,8 @@ const birdSelectFields = ` birds.photo_updated_at, birds.notify_on_dob, birds.notify_on_gotcha_day, + birds.public_profile_code, + birds.public_profile_enabled, birds.memorialized_at, birds.memorialized_on::text, birds.memorial_note, @@ -62,6 +64,27 @@ export const getBirdById = async (birdId: string, workspaceId: number) => { return result.rows[0] ?? null; }; +export const getBirdByPublicProfileCode = async (publicProfileCode: string) => { + const result = await db.query( + `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.public_profile_code = $1 + AND birds.public_profile_enabled = TRUE + AND birds.memorialized_at IS NULL`, + [publicProfileCode], + ); + + return result.rows[0] ?? null; +}; + export const listBirds = async (workspaceId: number) => { const result = await db.query( `SELECT @@ -274,6 +297,8 @@ export const createBird = async ({ photoUpdatedAt = null, notifyOnDob, notifyOnGotchaDay, + publicProfileCode = null, + publicProfileEnabled = false, }: { birdId?: string; workspaceId: number; @@ -293,11 +318,13 @@ export const createBird = async ({ photoUpdatedAt?: string | null; notifyOnDob: boolean; notifyOnGotchaDay: boolean; + publicProfileCode?: string | null; + publicProfileEnabled?: boolean; }) => { const result = await db.query( - `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day) - VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, + `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) + VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, [ birdId ?? null, workspaceId, @@ -317,6 +344,8 @@ export const createBird = async ({ photoUpdatedAt, notifyOnDob, notifyOnGotchaDay, + publicProfileCode, + publicProfileEnabled, ], ); @@ -342,6 +371,8 @@ export const updateBird = async ({ photoUpdatedAt = null, notifyOnDob, notifyOnGotchaDay, + publicProfileCode, + publicProfileEnabled, }: { birdId: string; workspaceId: number; @@ -361,6 +392,8 @@ export const updateBird = async ({ photoUpdatedAt?: string | null; notifyOnDob: boolean; notifyOnGotchaDay: boolean; + publicProfileCode: string | null; + publicProfileEnabled: boolean; }) => { const result = await db.query( `UPDATE birds @@ -379,11 +412,13 @@ export const updateBird = async ({ photo_content_type = $14, photo_updated_at = $15, notify_on_dob = $16, - notify_on_gotcha_day = $17 + notify_on_gotcha_day = $17, + public_profile_code = $18, + public_profile_enabled = $19 WHERE id = $1 - AND workspace_id = $18 + AND workspace_id = $20 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -416,6 +451,8 @@ export const updateBird = async ({ photoUpdatedAt, notifyOnDob, notifyOnGotchaDay, + publicProfileCode, + publicProfileEnabled, workspaceId, ], ); diff --git a/backend/src/types.ts b/backend/src/types.ts index d8e4103..dc576f5 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -111,6 +111,8 @@ export type BirdRow = { photo_updated_at: string | null; notify_on_dob: boolean; notify_on_gotcha_day: boolean; + public_profile_code: string | null; + public_profile_enabled: boolean; memorialized_at: string | null; memorialized_on: string | null; memorial_note: string | null; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b70de40..5d32688 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 3a75f40..ee368d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9330ce9..c1fe052 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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; @@ -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 ( + + + + + + ); +}; 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(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(null); + const [publicProfileLoading, setPublicProfileLoading] = useState(Boolean(getPublicProfileCodeFromPath())); + const [publicProfileError, setPublicProfileError] = useState(''); const [workspace, setWorkspace] = useState(null); const [activeMembership, setActiveMembership] = useState(null); const [workspaceMembers, setWorkspaceMembers] = useState([]); @@ -1159,6 +1220,7 @@ function App() { const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState(null); const [showWeightAlertModal, setShowWeightAlertModal] = useState(false); + const [qrBird, setQrBird] = useState(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) => { 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 ( +
+
+ {publicProfileLoading || authLoading ? ( +

Loading bird profile...

+ ) : publicProfileError || !publicProfile ? ( + <> +

FlockPal

+

Public profile unavailable

+

{publicProfileError || 'This bird profile is not available publicly.'}

+ + ) : ( + <> + {publicProfile.name} +
+

+ {publicProfile.name} + + {getBirdGenderSymbol(publicProfile)} + +

+
+ Hatch Day + {formatDate(publicProfile.dateOfBirth)} +
+ {publicProfileWorkspaceMembership ? ( + + ) : null} +
+ + )} +
+
+ ); + } + if (authLoading) { return (
@@ -3770,6 +3935,35 @@ function App() {
{error ?

{error}

: null} + {(activePage === 'overview' || activePage === 'flock') && (totalWeightAlerts || vetVisitDueBirds.length) ? ( +
+
+ ) : null} {activePage === 'overview' ? (
@@ -3780,11 +3974,6 @@ function App() {

30-day flock weight snapshot

- {totalWeightAlerts ? ( - - ) : null}

{birdsWithRecentWeights.length} current {overviewHistoricalSeriesCount > 0 ? `, ${overviewHistoricalSeriesCount} previous-year` : ''} @@ -3909,43 +4098,12 @@ function App() { {missingFirstWeightCount} Members still needing a first weight - ) : null} - {totalWeightAlerts ? ( -

- Weight alerts - - {totalWeightAlerts} alert{totalWeightAlerts === 1 ? '' : 's'} need review - - {outOfRangeBirds.length ? ( - - {outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges - - ) : null} - {weightDropAlerts.length ? ( - - {weightDropAlerts.length} bird{weightDropAlerts.length === 1 ? '' : 's'} down 5-10% between recent entries - - ) : null} - + ) : ( +
+ First weights + All recorded
- ) : null} - {vetVisitDueBirds.length ? ( -
- Vet visit reminder - - {vetVisitDueBirds.length} member{vetVisitDueBirds.length === 1 ? '' : 's'} need annual visit review - - - No vet visit logged in the last 365 days for {vetVisitDueNames} - {vetVisitDueOverflowCount ? ` and ${vetVisitDueOverflowCount} more` : ''}. - - -
- ) : null} + )}
Weekly flock changes {flockWeeklyTrendItems.length ? ( @@ -4247,8 +4405,14 @@ function App() { <>
{`${selectedBird.name}`} + {selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? ( + + ) : null}
-

Profile

{selectedBird.name}
-
- Band ID - {selectedBird.tagId || 'Not recorded'} -
Hatch Day {formatDate(selectedBird.dateOfBirth)} @@ -4278,10 +4438,6 @@ function App() { Gotcha day {formatDate(selectedBird.gotchaDay)}
-
- Species - {selectedBird.species} -
Gender @@ -5333,6 +5489,14 @@ function App() { placeholder="Optional if unknown" /> +

+ {qrBird?.publicProfileCode ? ( +
setQrBird(null)}> +
event.stopPropagation()} + > +
+
+

QR profile

+

{qrBird.name}

+
+
+ + +
+
+
+ +

{qrBird.name}

+

{getPublicProfileUrl(qrBird.publicProfileCode)}

+
+
+
+ ) : null} + {showWeightAlertModal ? (
setShowWeightAlertModal(false)}>
+ykgo5)29@P;`7yL|?QHl8A@WeMnF}9bG73 zVNECbAaQ7_nlIWPL$Cx7-Y)^GVNjM}cOwT~2a*js0JDohM!PT^U6BkN(hLQ*wvw}; z!{~St9!>R8qvHb!6d2tSya)~xzKe#TV6{aTD$WvYkN5FM!(g^_jGrr!7^tSFuVZ8a z7J`sbelX{qcJCpCJxlQWwbE!b9h!j-k?arEGcz-T>gq%F^&vtJ2qlz2^`S!u6m=nj zoZ2GBPBaBc#*nBOB0)`r>ElZbrdoo*a%%6PQOFO-q+oL3A}$IEMF*nsXabcYWI_)r zgi}+qKp|mBGTMhqB#XLB@Gj_}#m-VM5M%Wb-xg3&K;hV;_hRS0y9M-9EDNJQ5Z^$dmLU{OU;r@~ zO{JnCL_dfR1W82Ve5e?7AjB6#MnOnuGBN;-f)EKp8!Z4Mbn_KX^CuI738;@JV<^Nx zAL^3lzxr7RQ}KtE)@~`39#TW!STur1qcFjE4TGKjM52En8Y7ZE<-HB*gD3f5{0Tzq z;HYTjFbC$2_I05O$q{X! z&(|n$Q4kn{zlBIHOW{79`iChjWhFcsgogz5!%?%eRYVN^H``(f{+j_r*k6Nu5yM~U z`byUqG4MssU)A-Mt}kNXi=4lz>nmMf#K0Fh|8Law&r2ejAiNpUgjc`?K41gLNJ&Xc zNyHfyV^s%|scuC1$QXlS?* zY-VYyZ=qvgs4oHm$;!$uU$%U$y!=}IO{$ypzql5Fms&Xfq(T3M9S?wD23C z3V;BRBnY&4O)d&%kK*IJB_#%RETyfRE4g;Wt*T8ad z;eCFU_$t5}NPh6?wEw#v2$$B~klwyq;CewFAAgvgH?W$7XA+e>yfdoiLR1h6k?cKg zjS0n>>DKo*4#WLN>`RR9qW^AIE2H|OQ}toj=W`FSVG~2XHd_sx28m|2H;W3j#>FKc zU24+prbD16!*Q{f&C@cVaeUTI+D`n2)JZmoXqU9)cx`KUvlq+A7yN%65)s1?a zs1Nw{uC>5FxcKIW6ch*ZuvlyJnVUDCP3!Bgq)3Qb_t6)+Xo)mG)GAe@zB~{Lp?aZtXcC-522&v z;l{29mBZ4;cgo2E|$ zlTAcrK9^}#xnVrXAL?C?PP#ngc5_>L%^vIt=l%=G1t4yNitJ;7m0yWe(Xkd=P2$-} z?7r~+pDiwyq>Ql!iwNf9^aktkry5Sb07w2ZOHj(F*13`lh6ee`$q=lEGpX#vj4iat zxUjaTuJShF+k1CkhbT5CvUWsbf9yubF}Ib{{5K5~SD}NP6#THE#?OK-ya{liyJ|X2 z`LbA!IZeN6-|NX_fB!;{Ks6Eww@X zxu@x@P)R=tdW7rAbMU=a4Z~I*;`TYugnQ3ge;5DayT8srd)&cY+P3apbv+sPq%?zQ zuFceVH!!Er`*dYgXiYMQLo#I;|L9}cuAN9NE`2wxJUk6b=r+;SEVxg1*>lCO5I!;E z?s|!t$t&(L>B(!pv1J`Tulb$e$O{p7M6avyFRd{r-E?#)zR57aDAGkaxWa!V8PeEhMKL(YW5#6|};8^>z0t&cm|&!3LXzUYD^8 zvC)n_v_bCX1HjTZ%FmWZ?%TwRQ)_AHPEWa@p5sXg4*e;LO}7U2yRAKt*U`}=7m%qF z;$eng%Wle}`0L+e5tW+9nv7jIwwa#eJ=%#nwXq}7H^OpW>HYMmz3{5xOV8s;jQtju zO0SKz%#0jLTmU5N>g&Ff7sZ!qtstyPi?xkBw1^Lt zKQnx{!W`UYI=g&p;r7FWs7(<-Xlp=Ccd-X1ucZ@lmDwJDrnGK|kSnmP)-I2l9;ygg?{&e@rI z2(1&n6+T?SuQ_}OQS&?Km!)WLtL#ao4F6@8gjeCX`pZGg;tOdL`Zk2VNY`?nQDmhHWH8K)n-|8PfHy)|FD{$)m1;&=uRE2S zwE#q%Z|2xG#M)+Egi4v^A?!JvRk2X&%^jp=JxlZYRDt!=$f{uM+J9Nz542c_@^|W&#Wl8QFvN; z;E@NogT;uVtWIoV-Ec=oWtC?LA_h?&$a?xKV7RmZ#aumT_4BmVO8I`a6Ltl^usrOG zVz=9x+1qW;YcAI)?tiP}GE&6=pZ?jXpb!?>K6iP8_i)?HvqLSo)Mj|o*k;?-D1~g* z)-3tWsaJw9doQrsSfTJ6ckzf)Yi+d)IHC3085(vmIRz20$+c(RV=<#xJfo63vNXmmZhcsIWr zU3VtJ_2$z8Y(d|#re_ZQu`qDLY$oTF#(K{K!5vtB_~R3d$Lo0h-B8GEVbP9lBh~T2 z0lFNImwi!}ZXq-?SO?k`fEnv>DAajBp!T@}EsY1vR!hl(9gL#68AQC zB+@+=v1^$;AD_oR%pV1W$1LDS$DO=`&~8wYV*PLqL(k$8vAxk*$*hX|BPNyRF{(E4 zZgG`Wj~>V7+I`c9yFlyHNm6#q2)hqb43V~L5BTYR=Q&HFTZz&;+Z>gAO-+9Hpj>U3 z;>aOqIbKOnV`5Ak8|4+xYcz_|4wu+|{PGYR3~6yFWc~8PIaOXxYZ7gvdudr&vA_Z$ z(_wozwa#-4m+8>Io8fLf5`P9OSnqOk^6L6*NO*$hpddy{C;9S~EIK{ZOzuwMyu5z$ zNoN1mCt+7IhQ^J43i#`}A;gY4G1}v96G>Ekn&06m?ko%G6ubcJ z#!bbSj2}Nb$)Bv4p06adOq?H3^4H|1%;oRmkx&ajXNNgbkXABeFl&B&B-$FP^({7$ zlCaY_(`_s?Po=FJ=zMFx!LYdX z3a^WeS#j$!D^H}DZz*)BGPuCZu)QN-th@X9f{WX9LG^;@29n73bQDgxj1fR&<3_9b zHKFrajZ<+4Ij89~Yxe@HZN9Z#DbwuNdV)Mwedjv8@nY@r2BSA8wbT#BPbVf&vrBdj z_Py3S+xKcYAtk*FD>&IGIJI*`Kbv*7Vyhs3Hg?222Fu7^o%fXIrD49c9w~G4&E@`B z@5cC-Q%r-?2}TFwPMYOEZYy+{$!@zo<=w3-7Q1Q*m;N8>oi^Mzh67G?Bv)_SWIaRiO-%&!8d4W4>Rt; zY4^B*g}XyAfpg-=WcOB2f^)^A(rR{HVw9t;QxR={n(`R>f%v}a&S)vD6_{`|!YjG0 z%rUj3C!pbul?`da4ie#5S@l~{QLEP0>|+x_7~|p_*+8^m^TBpfHX9JW{ZSL&an{lR4C)z)tEc-HFn6>|&N zxfJE~ZGr|T47_W;ce-SU-ScN0=@tDPzMG4*+3kF$Mn_>wZiT9kJn@X_#4B%jzRNR= zja*t=f}yoMdBk~NV@0{~By&KfE{b)GZf+7~^t2T@#VO6fbfVtXrZXAk)6g6gqjiN; z!&KS6DO0yui(PQss9%2eo$gz+&SUUCF6{UzI?l1xL8ZvVf%DjlQAM7asIGDj!!H0U z9=bCe