diff --git a/apps/backend/src/animals/animals.controller.spec.ts b/apps/backend/src/animals/animals.controller.spec.ts new file mode 100644 index 000000000..80a420593 --- /dev/null +++ b/apps/backend/src/animals/animals.controller.spec.ts @@ -0,0 +1,46 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AnimalsController } from './animals.controller'; +import { AnimalsService } from './animals.service'; +import { Animal } from './animals.entity'; + +describe('AnimalsController', () => { + let controller: AnimalsController; + let service: { findAll: jest.Mock }; + + beforeEach(async () => { + service = { findAll: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AnimalsController], + providers: [{ provide: AnimalsService, useValue: service }], + }).compile(); + + controller = module.get(AnimalsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getAnimals', () => { + it('returns the animals from the service', async () => { + const animals = [{ animalId: 1 }, { animalId: 2 }] as Animal[]; + service.findAll.mockResolvedValue(animals); + + await expect(controller.getAnimals()).resolves.toBe(animals); + expect(service.findAll).toHaveBeenCalledTimes(1); + }); + + it('returns an empty array when there are no animals', async () => { + service.findAll.mockResolvedValue([]); + + await expect(controller.getAnimals()).resolves.toEqual([]); + }); + + it('propagates service errors', async () => { + service.findAll.mockRejectedValue(new Error('db down')); + + await expect(controller.getAnimals()).rejects.toThrow('db down'); + }); + }); +}); diff --git a/apps/backend/src/animals/animals.controller.ts b/apps/backend/src/animals/animals.controller.ts new file mode 100644 index 000000000..9a16a70d6 --- /dev/null +++ b/apps/backend/src/animals/animals.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AnimalsService } from './animals.service'; +import { Animal } from './animals.entity'; + +@ApiTags('Animals') +@Controller('animals') +export class AnimalsController { + constructor(private animalsService: AnimalsService) {} + + @Get() + @ApiOperation({ + summary: 'Get all available animals', + description: + 'Returns every animal. All animals in the database are currently treated as available.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The animals, or an empty array when none are available.', + type: [Animal], + }) + async getAnimals(): Promise { + return this.animalsService.findAll(); + } +} diff --git a/apps/backend/src/animals/animals.module.ts b/apps/backend/src/animals/animals.module.ts index 271ff80ce..b43bbd775 100644 --- a/apps/backend/src/animals/animals.module.ts +++ b/apps/backend/src/animals/animals.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Animal } from './animals.entity'; +import { AnimalsController } from './animals.controller'; import { AnimalsService } from './animals.service'; @Module({ imports: [TypeOrmModule.forFeature([Animal])], + controllers: [AnimalsController], providers: [AnimalsService], exports: [AnimalsService], }) diff --git a/apps/backend/src/animals/animals.service.spec.ts b/apps/backend/src/animals/animals.service.spec.ts index dd6df1f83..182efe51e 100644 --- a/apps/backend/src/animals/animals.service.spec.ts +++ b/apps/backend/src/animals/animals.service.spec.ts @@ -45,6 +45,23 @@ describe('AnimalsService', () => { expect(service).toBeDefined(); }); + describe('findAll', () => { + it('returns every animal', async () => { + mockRepo.find.mockResolvedValue([mockAnimal]); + + const result = await service.findAll(); + + expect(result).toEqual([mockAnimal]); + expect(mockRepo.find).toHaveBeenCalledWith(); + }); + + it('returns an empty array when there are no animals', async () => { + mockRepo.find.mockResolvedValue([]); + + await expect(service.findAll()).resolves.toEqual([]); + }); + }); + describe('findByChameleonIdOrFail', () => { it('returns the animal when one with the Chameleon ID exists', async () => { mockRepo.findOneBy.mockResolvedValue(mockAnimal); diff --git a/apps/backend/src/animals/animals.service.ts b/apps/backend/src/animals/animals.service.ts index 2167a65b4..bcbf2525a 100644 --- a/apps/backend/src/animals/animals.service.ts +++ b/apps/backend/src/animals/animals.service.ts @@ -10,6 +10,18 @@ export class AnimalsService { private repo: Repository, ) {} + /** + * Fetches every available Animal. + * + * There is no availability tracking yet, so every Animal in the database is + * treated as available. + * + * @returns All Animals, or an empty array when there are none. + */ + async findAll(): Promise { + return this.repo.find(); + } + /** * Fetches an Animal by its Chameleon animal ID. * diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 24cc54210..4ec6512bf 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -9,6 +9,7 @@ import { CreateAnimals1792897315518 } from '../migrations/1792897315518-CreateAn import { AddVolunteerCoordinatorNotes1793055981679 } from '../migrations/1793055981679-AddVolunteerCoordinatorNotes'; import { ReplaceCanineTrainingWithCompletedTrainings1793150103842 } from '../migrations/1793150103842-ReplaceCanineTrainingWithCompletedTrainings'; import { AddAnimalUpdatesAndFosterTypeArray1793163582751 } from '../migrations/1793163582751-AddAnimalUpdatesAndFosterTypeArray'; +import { AddMatchAdditionalComments1793300000000 } from '../migrations/1793300000000-AddMatchAdditionalComments'; import { AddFarmAnimalsToFosterType1793476100199 } from '../migrations/1793476100199-AddFarmAnimalsToFosterType'; const schemaMigrations = [ @@ -23,6 +24,7 @@ const schemaMigrations = [ AddVolunteerCoordinatorNotes1793055981679, ReplaceCanineTrainingWithCompletedTrainings1793150103842, AddAnimalUpdatesAndFosterTypeArray1793163582751, + AddMatchAdditionalComments1793300000000, AddFarmAnimalsToFosterType1793476100199, ]; diff --git a/apps/backend/src/matches/dtos/create-match.dto.ts b/apps/backend/src/matches/dtos/create-match.dto.ts index 0d9c13491..236d1262b 100644 --- a/apps/backend/src/matches/dtos/create-match.dto.ts +++ b/apps/backend/src/matches/dtos/create-match.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsPositive, MaxLength } from 'class-validator'; +import { IsInt, IsPositive, MaxLength, ValidateIf } from 'class-validator'; import { IsNonEmptyString } from '../../utils/decorators/is-non-empty-string.decorator'; export class CreateMatchDto { @@ -9,4 +9,8 @@ export class CreateMatchDto { @IsNonEmptyString() @MaxLength(255) chameleonAnimalId!: string; + + @ValidateIf((_, value) => value !== undefined && value !== null) + @IsNonEmptyString() + additionalComments?: string; } diff --git a/apps/backend/src/matches/matches.controller.spec.ts b/apps/backend/src/matches/matches.controller.spec.ts index c4b4ef7b5..6a1d56487 100644 --- a/apps/backend/src/matches/matches.controller.spec.ts +++ b/apps/backend/src/matches/matches.controller.spec.ts @@ -6,6 +6,7 @@ import { MatchesService } from './matches.service'; import { MatchStatus } from './matches.types'; import { Match } from './matches.entity'; import { DenyMatchDto } from './dtos/deny-match.dto'; +import { AnimalsService } from '../animals/animals.service'; describe('MatchesController', () => { let controller: MatchesController; @@ -15,6 +16,7 @@ describe('MatchesController', () => { approveMatch: jest.Mock; denyMatch: jest.Mock; }; + let animalsService: { findByChameleonIdOrFail: jest.Mock }; const mockMatch = { matchId: 1, @@ -29,10 +31,14 @@ describe('MatchesController', () => { approveMatch: jest.fn(), denyMatch: jest.fn(), }; + animalsService = { findByChameleonIdOrFail: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ controllers: [MatchesController], - providers: [{ provide: MatchesService, useValue: service }], + providers: [ + { provide: MatchesService, useValue: service }, + { provide: AnimalsService, useValue: animalsService }, + ], }).compile(); controller = module.get(MatchesController); @@ -48,18 +54,38 @@ describe('MatchesController', () => { describe('createMatch', () => { it('passes the body through to the service', async () => { + animalsService.findByChameleonIdOrFail.mockResolvedValue({}); service.create.mockResolvedValue({ matchId: 10 } as Match); const body = { volunteerId: 1, chameleonAnimalId: 'A42' }; const match = await controller.createMatch(body); + expect(animalsService.findByChameleonIdOrFail).toHaveBeenCalledWith( + 'A42', + ); expect(service.create).toHaveBeenCalledWith(body); expect(match.matchId).toEqual(10); }); - // The handler adds no guards of its own, so whatever the service throws - // has to come back out untouched. + it('does not create a match when the animal does not exist', async () => { + const error = new NotFoundException( + 'Animal with Chameleon ID 42 not found', + ); + animalsService.findByChameleonIdOrFail.mockRejectedValue(error); + + await expect( + controller.createMatch({ volunteerId: 1, chameleonAnimalId: 'A42' }), + ).rejects.toThrow(error); + expect(animalsService.findByChameleonIdOrFail).toHaveBeenCalledWith( + 'A42', + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + // Apart from the animal check, whatever the service throws has to come + // back out untouched. it('surfaces a NotFoundException from the service', async () => { + animalsService.findByChameleonIdOrFail.mockResolvedValue({}); service.create.mockRejectedValue( new NotFoundException('Volunteer not found'), ); diff --git a/apps/backend/src/matches/matches.controller.ts b/apps/backend/src/matches/matches.controller.ts index badfcd675..dcbed1e7a 100644 --- a/apps/backend/src/matches/matches.controller.ts +++ b/apps/backend/src/matches/matches.controller.ts @@ -13,11 +13,15 @@ import { Match } from './matches.entity'; import { CreateMatchDto } from './dtos/create-match.dto'; import { validateId } from '../utils/validation.utils'; import { DenyMatchDto } from './dtos/deny-match.dto'; +import { AnimalsService } from '../animals/animals.service'; @ApiTags('Matches') @Controller('matches') export class MatchesController { - constructor(private matchesService: MatchesService) {} + constructor( + private matchesService: MatchesService, + private animalsService: AnimalsService, + ) {} @Post() @ApiOperation({ @@ -40,6 +44,7 @@ export class MatchesController { description: 'No volunteer or animal exists with the given ID.', }) async createMatch(@Body() body: CreateMatchDto): Promise { + await this.animalsService.findByChameleonIdOrFail(body.chameleonAnimalId); return this.matchesService.create(body); } diff --git a/apps/backend/src/matches/matches.entity.ts b/apps/backend/src/matches/matches.entity.ts index 5b8c3bf73..3bb2eeda6 100644 --- a/apps/backend/src/matches/matches.entity.ts +++ b/apps/backend/src/matches/matches.entity.ts @@ -34,6 +34,9 @@ export class Match { @Column({ type: 'enum', enum: MatchStatus }) status!: MatchStatus; + @Column({ name: 'additional_comments', type: 'text', nullable: true }) + additionalComments!: string | null; + @Column({ name: 'denied_reason', type: 'text', nullable: true }) deniedReason!: string | null; } diff --git a/apps/backend/src/matches/matches.service.spec.ts b/apps/backend/src/matches/matches.service.spec.ts index 5cca2f970..2ca25351a 100644 --- a/apps/backend/src/matches/matches.service.spec.ts +++ b/apps/backend/src/matches/matches.service.spec.ts @@ -189,11 +189,36 @@ describe('MatchesService', () => { volunteerId: 1, chameleonAnimalId: 'A42', status: MatchStatus.PENDING, + additionalComments: null, deniedReason: null, }); expect(match.status).toEqual(MatchStatus.PENDING); }); + it('saves additional comments when provided', async () => { + volunteersService.findActiveOrFail.mockResolvedValue( + volunteerWithCoordinator, + ); + emailsService.sendEmail.mockResolvedValue(undefined); + matchRepo.create.mockImplementation((attrs) => attrs); + matchRepo.save.mockImplementation((match) => + Promise.resolve({ matchId: 10, ...match }), + ); + + const match = await service.create({ + volunteerId: 1, + chameleonAnimalId: 'A42', + additionalComments: 'Can pick up on weekends', + }); + + expect(matchRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + additionalComments: 'Can pick up on weekends', + }), + ); + expect(match.additionalComments).toEqual('Can pick up on weekends'); + }); + it("emails the sender address with the volunteer's assigned coordinator cc'd", async () => { volunteersService.findActiveOrFail.mockResolvedValue( volunteerWithCoordinator, diff --git a/apps/backend/src/matches/matches.service.ts b/apps/backend/src/matches/matches.service.ts index 607123e40..23b37ccf0 100644 --- a/apps/backend/src/matches/matches.service.ts +++ b/apps/backend/src/matches/matches.service.ts @@ -55,6 +55,7 @@ export class MatchesService { volunteerId: dto.volunteerId, chameleonAnimalId: dto.chameleonAnimalId, status: MatchStatus.PENDING, + additionalComments: dto.additionalComments ?? null, deniedReason: null, }), ); diff --git a/apps/backend/src/migrations/1793300000000-AddMatchAdditionalComments.ts b/apps/backend/src/migrations/1793300000000-AddMatchAdditionalComments.ts new file mode 100644 index 000000000..0c96ad096 --- /dev/null +++ b/apps/backend/src/migrations/1793300000000-AddMatchAdditionalComments.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMatchAdditionalComments1793300000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "matches" ADD COLUMN "additional_comments" text`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "matches" DROP COLUMN "additional_comments"`, + ); + } +} diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index c9fd0b6ce..e6b800e0c 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -24,6 +24,7 @@ import { CoordinatorUpdateVolunteerDto } from './dtos/coordinator-update-volunte import { Homebase } from '../types'; import { UpdateHomebaseDto } from './dtos/update-homebase.dto'; import { UpdateAnimalPreferencesDto } from './dtos/update-animal-preferences.dto'; +import { AnimalsService } from '../animals/animals.service'; describe('VolunteersController', () => { let controller: VolunteersController; @@ -55,6 +56,7 @@ describe('VolunteersController', () => { coordinatorUpdateVolunteerById: jest.Mock; }; let mockMatchesService: { findByVolunteerId: jest.Mock }; + let mockAnimalsService: { checkAllExistOrFail: jest.Mock }; const mockVolunteers = [ { volunteerId: 1, firstName: 'Jane', lastName: 'Doe' }, @@ -77,12 +79,14 @@ describe('VolunteersController', () => { coordinatorUpdateVolunteerById: jest.fn(), }; mockMatchesService = { findByVolunteerId: jest.fn() }; + mockAnimalsService = { checkAllExistOrFail: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ controllers: [VolunteersController], providers: [ { provide: VolunteersService, useValue: mockVolunteersService }, { provide: MatchesService, useValue: mockMatchesService }, + { provide: AnimalsService, useValue: mockAnimalsService }, ], }).compile(); @@ -564,6 +568,27 @@ describe('VolunteersController', () => { ).toHaveBeenCalledWith(1, dto); }); + it('does not update preferences when an added animal does not exist', async () => { + const error = new NotFoundException( + 'Animals with Chameleon IDs not found: 998, 999', + ); + mockAnimalsService.checkAllExistOrFail.mockRejectedValue(error); + + await expect( + controller.updateAnimalPreferences(1, { + added: ['A55', 'A998', 'A999'], + }), + ).rejects.toThrow(error); + expect(mockAnimalsService.checkAllExistOrFail).toHaveBeenCalledWith([ + 'A55', + 'A998', + 'A999', + ]); + expect( + mockVolunteersService.updateAnimalPreferences, + ).not.toHaveBeenCalled(); + }); + it.each([{}, { added: [] }, { removed: [] }, { added: [], removed: [] }])( 'throws BadRequestException when neither list has IDs: %j', async (body) => { diff --git a/apps/backend/src/volunteers/volunteers.controller.ts b/apps/backend/src/volunteers/volunteers.controller.ts index 79d874f8a..255d329dd 100644 --- a/apps/backend/src/volunteers/volunteers.controller.ts +++ b/apps/backend/src/volunteers/volunteers.controller.ts @@ -24,6 +24,7 @@ import { CreateVolunteerDto } from './dtos/create-volunteer.dto'; import { ConfirmVolunteerDto } from './dtos/confirm-volunteer.dto'; import { UpdateHomebaseDto } from './dtos/update-homebase.dto'; import { Public } from '../aws/cognito/cognito.decorator'; +import { AnimalsService } from '../animals/animals.service'; import { VolunteerWithoutPrivateFields } from './dtos/volunteer-without-private-fields.dto'; @ApiTags('Volunteers') @@ -32,6 +33,7 @@ export class VolunteersController { constructor( private volunteersService: VolunteersService, private matchesService: MatchesService, + private animalsService: AnimalsService, ) {} @Post() @@ -332,6 +334,7 @@ export class VolunteersController { 'At least one of added or removed must be provided', ); } + await this.animalsService.checkAllExistOrFail(dto.added ?? []); return this.volunteersService.updateAnimalPreferences(volunteerId, dto); } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 8f549b0a9..2b19ea97b 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -2,6 +2,7 @@ import axios, { type AxiosInstance } from 'axios'; import { fetchAuthSession } from 'aws-amplify/auth'; import { isAuthEnabled } from '../auth/auth.config'; +import type { Animal, CreateMatchBody, Match } from '../types'; const defaultBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'; @@ -45,6 +46,14 @@ export class ApiClient { }); } + public async getAnimals(): Promise { + return this.get('/api/animals') as Promise; + } + + public async createMatch(body: CreateMatchBody): Promise { + return this.post('/api/matches', body) as Promise; + } + public async getHello(): Promise { return this.get('/api') as Promise; } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index a51df65b5..3b1620328 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -5,6 +5,8 @@ import apiClient from '@api/apiClient'; import Root from '@containers/root'; import NotFound from '@containers/404'; import Test from '@containers/test'; +import AnimalAvailability from '@pages/AnimalAvailability'; +import { ROUTES } from './routes'; const router = createBrowserRouter([ { @@ -16,6 +18,10 @@ const router = createBrowserRouter([ path: '/test', element: , }, + { + path: ROUTES.ANIMAL_AVAILABILITY, + element: , + }, ]); export const App: React.FC = () => { diff --git a/apps/frontend/src/assets/cat.png b/apps/frontend/src/assets/cat.png new file mode 100644 index 000000000..64b29449c Binary files /dev/null and b/apps/frontend/src/assets/cat.png differ diff --git a/apps/frontend/src/assets/mspca-logo.png b/apps/frontend/src/assets/mspca-logo.png new file mode 100644 index 000000000..071f4a36d Binary files /dev/null and b/apps/frontend/src/assets/mspca-logo.png differ diff --git a/apps/frontend/src/components/AnimalCard.tsx b/apps/frontend/src/components/AnimalCard.tsx new file mode 100644 index 000000000..9b3bac5ff --- /dev/null +++ b/apps/frontend/src/components/AnimalCard.tsx @@ -0,0 +1,124 @@ +import { Box, Button, Flex, Icon, Image, Text } from '@chakra-ui/react'; +import { House } from 'lucide-react'; + +import type { Animal } from '../types'; +import { formatAge } from '@utils/animals'; +import RestrictionTag from './RestrictionTag'; +import catImage from '../assets/cat.png'; + +// TODO: swap for animal.picture once animal images are supported. +export const PLACEHOLDER_IMAGE = catImage; + +interface AnimalCardProps { + animal: Animal; + onSeeMore: (animal: Animal) => void; +} + +const AnimalCard: React.FC = ({ animal, onSeeMore }) => ( + + {animal.name} + + + + + + {animal.name} + + + {animal.breed.join(', ')} + + + + + + + + {formatAge(animal.ageYear)} + + + {animal.restrictions && animal.restrictions.length > 0 && ( + + {animal.restrictions.map((label) => ( + + ))} + + )} + + + {animal.location} + + + + + +); + +export default AnimalCard; diff --git a/apps/frontend/src/components/AnimalFilters.tsx b/apps/frontend/src/components/AnimalFilters.tsx new file mode 100644 index 000000000..962e8c5f5 --- /dev/null +++ b/apps/frontend/src/components/AnimalFilters.tsx @@ -0,0 +1,415 @@ +import { useState } from 'react'; +import { + Box, + Button, + Checkbox, + Flex, + Icon, + Popover, + Portal, + Text, +} from '@chakra-ui/react'; +import { ListFilter, X } from 'lucide-react'; + +import { + ANIMAL_TYPES, + EMPTY_FILTERS, + RESTRICTIONS, + getRestriction, + type AnimalFilterState, +} from '@utils/animals'; + +type CategoryKey = keyof AnimalFilterState; + +interface AnimalFiltersProps { + value: AnimalFilterState; + onChange: (filters: AnimalFilterState) => void; + // Cities available to filter by, derived from the fetched animals. + locations: string[]; +} + +const ChevronIcon: React.FC<{ open: boolean }> = ({ open }) => ( + + + +); + +interface FilterOption { + label: string; + // Fill color of the checkbox when checked (theme token). + color: string; +} + +interface FilterSectionProps { + title: string; + options: readonly FilterOption[]; + selected: string[]; + onToggle: (option: string) => void; +} + +const FilterSection: React.FC = ({ + title, + options, + selected, + onToggle, +}) => { + const [open, setOpen] = useState(false); + + return ( + + + setOpen((o) => !o)} + > + + {title} + + + + + + {open && ( + + {options.length === 0 ? ( + + No options available + + ) : ( + options.map((option) => { + const checked = selected.includes(option.label); + return ( + onToggle(option.label)} + alignSelf="stretch" + justifyContent="flex-start" + alignItems="center" + gap="2" + > + + + + + + {option.label} + + + ); + }) + )} + + )} + + ); +}; + +const PanelButton: React.FC<{ + onClick: () => void; + children: React.ReactNode; +}> = ({ onClick, children }) => ( + +); + +const AnimalFilters: React.FC = ({ + value, + onChange, + locations, +}) => { + const [open, setOpen] = useState(false); + // Edits are held in a draft until Apply is pressed. + const [draft, setDraft] = useState(value); + + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) setDraft(value); + setOpen(isOpen); + }; + + const toggleDraft = (key: CategoryKey, option: string) => + setDraft((prev) => ({ + ...prev, + [key]: prev[key].includes(option) + ? prev[key].filter((o) => o !== option) + : [...prev[key], option], + })); + + // While the panel is open the chips mirror the draft selection, so they show + // up immediately as options are ticked. Apply is what filters the grid. + const shown = open ? draft : value; + + const removeChip = (key: CategoryKey, option: string) => { + const next = { ...shown, [key]: shown[key].filter((o) => o !== option) }; + if (open) setDraft(next); + else onChange(next); + }; + + // Animal type selections filter the grid but don't get a chip. + const chips = (Object.keys(shown) as CategoryKey[]) + .filter((key) => key !== 'types') + .flatMap((key) => + shown[key].map((label) => ({ + key, + label, + color: + key === 'restrictions' + ? getRestriction(label)?.color ?? 'neutrals.black' + : 'neutrals.black', + })), + ); + + return ( + + + handleOpenChange(e.open)} + positioning={{ placement: 'bottom-start' }} + > + + + + + + + + + + Filters + + + + ({ + label, + color: 'neutral.solid', + }))} + selected={draft.types} + onToggle={(o) => toggleDraft('types', o)} + /> + toggleDraft('restrictions', o)} + /> + ({ + label, + color: 'neutral.solid', + }))} + selected={draft.locations} + onToggle={(o) => toggleDraft('locations', o)} + /> + + + handleOpenChange(false)}> + Cancel + + { + onChange(draft); + setOpen(false); + }} + > + Apply + + + + + + + + + + {chips.map((chip) => ( + + ))} + + ); +}; + +export default AnimalFilters; diff --git a/apps/frontend/src/components/AnimalModal.tsx b/apps/frontend/src/components/AnimalModal.tsx new file mode 100644 index 000000000..446cb793e --- /dev/null +++ b/apps/frontend/src/components/AnimalModal.tsx @@ -0,0 +1,344 @@ +import { useState } from 'react'; +import { + Button, + Dialog, + Flex, + Icon, + Image, + Portal, + Text, + Textarea, +} from '@chakra-ui/react'; +import { ExternalLink, House } from 'lucide-react'; + +import apiClient from '@api/apiClient'; +import type { Animal } from '../types'; +import { formatAge } from '@utils/animals'; +import { PLACEHOLDER_IMAGE } from './AnimalCard'; +import RestrictionTag from './RestrictionTag'; + +// TODO: replace with the logged-in volunteer's ID once the frontend has access +// to the current user's profile. +const CURRENT_VOLUNTEER_ID = 1; + +interface AnimalModalProps { + animal: Animal | null; + onClose: () => void; +} + +// Form state lives in its own component so it resets whenever a different +// animal is opened. +const AnimalModalBody: React.FC<{ animal: Animal; onClose: () => void }> = ({ + animal, + onClose, +}) => { + const [comments, setComments] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [requested, setRequested] = useState(false); + const [error, setError] = useState(null); + + const handleRequest = async () => { + setSubmitting(true); + setError(null); + try { + await apiClient.createMatch({ + volunteerId: CURRENT_VOLUNTEER_ID, + chameleonAnimalId: animal.chameleonAnimalId, + additionalComments: comments.trim() || undefined, + }); + setRequested(true); + } catch { + setError('Could not submit your request. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {animal.name} + {/* Space reserved for the map, which is out of scope for now. */} + + + + {animal.location} + + + + + + + + + + + + + + {animal.name} + + + {animal.breed.join(', ')} + + + + + + + {/* TODO: Figma shows "5 Month Foster" (foster length), which + the schema doesn't have. Showing the animal's age. */} + + {formatAge(animal.ageYear)} + + + + {animal.restrictions && animal.restrictions.length > 0 && ( + + {animal.restrictions.map((label) => ( + + ))} + + )} + + + {animal.fosterBioMemo} + {animal.fosterBioMemo ? '\n' : ''} + {`Weight: ${animal.weight} lbs`} + + + + + + {requested ? ( + + Request submitted! + + ) : ( +