├── .nvmrc ├── etl ├── .gitignore └── src │ ├── 1_extract │ ├── address-main.jq │ ├── geoapify-location-invalid.pat │ ├── geoapify.jq │ ├── nyc │ │ └── fridge-nyc.jq │ ├── cfm │ │ └── fridge-cfm.jq │ └── excel │ │ ├── table-contactSheet.jq │ │ └── table-masterlist.jq │ ├── 2_transform │ └── align-keys.mjs │ └── 3_load │ ├── create-reports.mjs │ └── create-fridges.mjs ├── .gitattributes ├── .yarnrc ├── .mailmap ├── .prettierignore ├── src ├── components │ ├── atoms │ │ ├── MapToggle │ │ │ ├── index.js │ │ │ └── MapToggle.jsx │ │ ├── NextLink │ │ │ ├── index.js │ │ │ └── NextLink.jsx │ │ ├── PageHero │ │ │ ├── index.js │ │ │ ├── PageHero.test.jsx │ │ │ └── PageHero.jsx │ │ ├── SoftWrap │ │ │ ├── index.js │ │ │ ├── SoftWrap.js │ │ │ └── SoftWrap.test.js │ │ ├── TitleCard │ │ │ ├── index.js │ │ │ ├── TitleCard.test.jsx │ │ │ └── TitleCard.jsx │ │ ├── ButtonLink │ │ │ ├── index.js │ │ │ ├── ButtonLink.jsx │ │ │ └── ButtonLink.test.jsx │ │ ├── FeedbackCard │ │ │ ├── index.js │ │ │ └── FeedbackCard.test.jsx │ │ ├── PageFooter │ │ │ ├── index.js │ │ │ ├── PageFooter.test.jsx │ │ │ └── PageFooter.jsx │ │ ├── ParagraphCard │ │ │ ├── index.js │ │ │ ├── ParagraphCard.test.jsx │ │ │ └── ParagraphCard.jsx │ │ ├── PamphletParagraph │ │ │ ├── index.js │ │ │ ├── PamphletParagraph.test.jsx │ │ │ └── PamphletParagraph.jsx │ │ └── index.js │ ├── molecules │ │ ├── AppBar │ │ │ └── index.js │ │ ├── FridgeInformation │ │ │ └── index.js │ │ └── index.js │ └── organisms │ │ ├── index.js │ │ ├── dialog │ │ └── components │ │ │ ├── PanelConfirm.jsx │ │ │ ├── PanelNotes.jsx │ │ │ └── PanelMaintainer.jsx │ │ └── browse │ │ ├── components │ │ ├── MapMarkerList.jsx │ │ └── SearchMap.jsx │ │ ├── model │ │ └── markersFrom.js │ │ ├── Map.jsx │ │ └── List.jsx ├── lib │ ├── createEmotionCache.js │ ├── search.js │ ├── geo.js │ ├── geo.test.js │ ├── analytics.js │ ├── data.js │ ├── browser.js │ └── analytics.test.js ├── pages │ ├── user │ │ └── fridge │ │ │ ├── add.page.jsx │ │ │ └── report │ │ │ └── [fridgeId].test.jsx │ ├── _app.page.js │ ├── pamphlet │ │ ├── get-involved │ │ │ ├── service-fridges.page.jsx │ │ │ ├── become-a-driver.page.jsx │ │ │ ├── source-food.page.jsx │ │ │ ├── donate-to-a-fridge.page.jsx │ │ │ └── join-a-community-group.page.jsx │ │ ├── get-involved.page.jsx │ │ └── best-practices.page.jsx │ ├── fridge │ │ └── [id].page.jsx │ ├── demo │ │ └── styles.page.jsx │ ├── browse.page.jsx │ ├── _document.test.js │ └── _document.page.js ├── model │ ├── view │ │ ├── dialog │ │ │ └── yup.js │ │ ├── prop-types.js │ │ ├── component │ │ │ └── prop-types.js │ │ └── index.js │ └── data │ │ └── fridge │ │ ├── prop-types.js │ │ └── yup │ │ └── index.js └── theme │ ├── typography.js │ ├── index.js │ └── palette.js ├── jsconfig.json ├── public ├── favicon.ico ├── hero │ ├── about.webp │ ├── index.webp │ ├── source_food.webp │ ├── best_practices.webp │ ├── get-involved.webp │ ├── start_a_fridge.webp │ ├── become_a_driver.webp │ ├── service_fridges.webp │ ├── donate_to_a_fridge.webp │ └── join_a_community_group.webp ├── brand │ ├── favicon │ │ └── favicon-32x32.png │ └── logo.svg ├── paragraph │ └── pamphlet │ │ └── about │ │ ├── support_the_fridges.webp │ │ ├── technology_empowers_us.webp │ │ └── independence_for_each_fridge.webp ├── feedback │ ├── happyFridge.svg │ ├── emailError.svg │ └── emailSuccess.svg └── card │ ├── title │ ├── startFridge.svg │ ├── becomeDriver.svg │ ├── serviceFridge.svg │ ├── joinCommunity.svg │ ├── donate.svg │ └── sourceFood.svg │ └── paragraph │ ├── plumAndFridge.svg │ ├── apple.svg │ └── jumpingBlueberries.svg ├── .env.production ├── mock └── server-routes.json ├── .env.development ├── .prettierrc.json ├── .editorconfig ├── .lintstagedrc.js ├── .gitignore ├── .vscode └── launch.json ├── svgo.config.js ├── jest.config.js ├── next.config.js ├── docs ├── review-criteria.md ├── LICENSE ├── architecture-decisions.md ├── etl │ └── address_format.md ├── README.md ├── architecture-reference.md ├── CODE_OF_CONDUCT.md └── commit-convention.md ├── ci └── commit-msg.mjs ├── eslint.config.js ├── .github └── workflows │ └── dev.yaml └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.20.0 2 | -------------------------------------------------------------------------------- /etl/.gitignore: -------------------------------------------------------------------------------- 1 | temp/ 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | network-timeout 500000 -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Ioana Tiplea 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # next.js 2 | /.next/ 3 | /out/ 4 | -------------------------------------------------------------------------------- /src/components/atoms/MapToggle/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './MapToggle'; 2 | -------------------------------------------------------------------------------- /src/components/atoms/NextLink/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './NextLink'; 2 | -------------------------------------------------------------------------------- /src/components/atoms/PageHero/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PageHero'; 2 | -------------------------------------------------------------------------------- /src/components/atoms/SoftWrap/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './SoftWrap'; 2 | -------------------------------------------------------------------------------- /src/components/atoms/TitleCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './TitleCard'; 2 | -------------------------------------------------------------------------------- /src/components/molecules/AppBar/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './AppBar'; 2 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/atoms/ButtonLink/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ButtonLink'; 2 | -------------------------------------------------------------------------------- /src/components/atoms/FeedbackCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './FeedbackCard'; 2 | -------------------------------------------------------------------------------- /src/components/atoms/PageFooter/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PageFooter'; 2 | -------------------------------------------------------------------------------- /src/components/atoms/ParagraphCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ParagraphCard'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/components/atoms/PamphletParagraph/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PamphletParagraph'; 2 | -------------------------------------------------------------------------------- /src/components/molecules/FridgeInformation/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './FridgeInformation'; 2 | -------------------------------------------------------------------------------- /public/hero/about.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/about.webp -------------------------------------------------------------------------------- /public/hero/index.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/index.webp -------------------------------------------------------------------------------- /public/hero/source_food.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/source_food.webp -------------------------------------------------------------------------------- /public/hero/best_practices.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/best_practices.webp -------------------------------------------------------------------------------- /public/hero/get-involved.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/get-involved.webp -------------------------------------------------------------------------------- /public/hero/start_a_fridge.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/start_a_fridge.webp -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_FF_API_URL=https://api-prod.communityfridgefinder.com 2 | NEXT_PUBLIC_ANALYTICS_ID=G-85BD8F5WGD 3 | -------------------------------------------------------------------------------- /mock/server-routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "/v1/*": "/$1", 3 | "/v1/fridges/:fridgeId/reports": "/fridges/:fridgeId/reports" 4 | } 5 | -------------------------------------------------------------------------------- /public/hero/become_a_driver.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/become_a_driver.webp -------------------------------------------------------------------------------- /public/hero/service_fridges.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/service_fridges.webp -------------------------------------------------------------------------------- /public/hero/donate_to_a_fridge.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/donate_to_a_fridge.webp -------------------------------------------------------------------------------- /public/brand/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/brand/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/hero/join_a_community_group.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/hero/join_a_community_group.webp -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_FF_API_URL=http://127.0.0.1:3050 2 | NEXT_PUBLIC_FLAG_useLocalDatabase=1 3 | NEXT_PUBLIC_ANALYTICS_ID=G-4H99PPWYCC 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /src/components/molecules/index.js: -------------------------------------------------------------------------------- 1 | export { default as AppBar } from './AppBar'; 2 | export { default as FridgeInformation } from './FridgeInformation'; 3 | -------------------------------------------------------------------------------- /public/paragraph/pamphlet/about/support_the_fridges.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/paragraph/pamphlet/about/support_the_fridges.webp -------------------------------------------------------------------------------- /etl/src/1_extract/address-main.jq: -------------------------------------------------------------------------------- 1 | [ 2 | .[] | 3 | { 4 | mainId, 5 | address: (.locationStreet + ", " + .locationCity + ", " + .locationState + " " + .locationZip), 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /public/paragraph/pamphlet/about/technology_empowers_us.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/paragraph/pamphlet/about/technology_empowers_us.webp -------------------------------------------------------------------------------- /public/paragraph/pamphlet/about/independence_for_each_fridge.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FridgeFinder/CFM_Frontend/HEAD/public/paragraph/pamphlet/about/independence_for_each_fridge.webp -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | console.log('Running "yarn style" on commit files. Please wait...'); 2 | 3 | export default { 4 | '*': ['prettier --write --ignore-unknown'], 5 | '*.svg': ['svgo --quiet'], 6 | }; 7 | -------------------------------------------------------------------------------- /etl/src/1_extract/geoapify-location-invalid.pat: -------------------------------------------------------------------------------- 1 | "locationName"\: "",=; 2 | "locationName"\: "Manhattan",=; 3 | "locationName"\: "Elmhurst",=; 4 | "locationName"\: "Brooklyn",=; 5 | "locationName"\: "New York",=; 6 | -------------------------------------------------------------------------------- /src/lib/createEmotionCache.js: -------------------------------------------------------------------------------- 1 | import createCache from '@emotion/cache'; 2 | 3 | const createEmotionCache = () => { 4 | return createCache({ key: 'css', prepend: true }); 5 | }; 6 | 7 | export default createEmotionCache; 8 | -------------------------------------------------------------------------------- /src/components/organisms/index.js: -------------------------------------------------------------------------------- 1 | export { default as DialogUpdateFridgeStatus } from './DialogUpdateFridgeStatus'; 2 | export { default as BrowseList } from './browse/List'; 3 | export { default as BrowseMap } from './browse/Map'; 4 | -------------------------------------------------------------------------------- /etl/src/1_extract/geoapify.jq: -------------------------------------------------------------------------------- 1 | [ 2 | .[] | .[] | 3 | { 4 | id: .id | tonumber, 5 | locationName: .name, 6 | locationStreet: (.housenumber + " " + .street), 7 | locationCity: .city, 8 | locationState: .state_code, 9 | locationZip: .postcode, 10 | locationGeoLat: .lat | tonumber, 11 | locationGeoLng: .lon | tonumber, 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /etl/src/1_extract/nyc/fridge-nyc.jq: -------------------------------------------------------------------------------- 1 | [ 2 | .[] | 3 | { 4 | id, 5 | fridgeName: .name, 6 | fridgeVerified: false, 7 | fridgeNotes: "", 8 | 9 | maintainerName: "", 10 | maintainerEmail: "", 11 | maintainerOrganization: "", 12 | maintainerPhone: "", 13 | maintainerInstagram: .instagram, 14 | maintainerWebsite: "", 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /etl/src/1_extract/cfm/fridge-cfm.jq: -------------------------------------------------------------------------------- 1 | [ 2 | .[] | 3 | { 4 | id, 5 | fridgeName: .name, 6 | fridgeVerified: false, 7 | fridgeNotes: "", 8 | 9 | maintainerName: "", 10 | maintainerEmail: "", 11 | maintainerOrganization: "", 12 | maintainerPhone: "", 13 | maintainerInstagram: (.data | .[] | select(."-name" == "ig handle").value ), 14 | } 15 | ] 16 | -------------------------------------------------------------------------------- /src/pages/user/fridge/add.page.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Head from 'next/head'; 3 | 4 | export async function getStaticProps() { 5 | return { props: {} }; 6 | } 7 | 8 | export default function AddFridgePage() { 9 | return ( 10 | <> 11 | 12 | CFM: Add a Fridge to the database 13 | 14 |

CFM: Add a Fridge to the database

15 | 16 | ); 17 | } 18 | AddFridgePage.propTypes = PropTypes.exact({}).isRequired; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # mac os 2 | .DS_Store 3 | 4 | # dependencies 5 | /node_modules 6 | 7 | # testing 8 | /coverage 9 | 10 | # next.js 11 | /.next/ 12 | /out/ 13 | 14 | # production 15 | /build 16 | 17 | # debug 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | .pnpm-debug.log* 22 | 23 | # eslint 24 | .eslintcache 25 | 26 | # local env files 27 | .env*.local 28 | 29 | # git backups 30 | *.orig 31 | 32 | # mock server 33 | /mock/fake-data.json 34 | 35 | # editor 36 | /.vscode/ 37 | !/.vscode/launch.json 38 | -------------------------------------------------------------------------------- /src/lib/search.js: -------------------------------------------------------------------------------- 1 | const rxPunctuationAndDigits = /[.,/#!$%^&*;:{}=\-_`~()'\d]/g; 2 | 3 | export function wordRank(sentences, excludedWords) { 4 | const words = sentences 5 | .join(' ') 6 | .replaceAll(rxPunctuationAndDigits, '') 7 | .split(/\s/) 8 | .map((word) => word.toLowerCase()) 9 | .filter((word) => word.length > 2) 10 | .filter((word) => !excludedWords.includes(word)); 11 | 12 | const wordCounts = {}; 13 | words.forEach((word) => (wordCounts[word] = 1 + (wordCounts[word] ?? 0))); 14 | return wordCounts; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/atoms/index.js: -------------------------------------------------------------------------------- 1 | export { default as ButtonLink } from './ButtonLink'; 2 | export { default as FeedbackCard } from './FeedbackCard'; 3 | export { default as MapToggle } from './MapToggle'; 4 | export { default as NextLink } from './NextLink'; 5 | export { default as PageFooter } from './PageFooter'; 6 | export { default as PageHero } from './PageHero'; 7 | export { default as PamphletParagraph } from './PamphletParagraph'; 8 | export { default as ParagraphCard } from './ParagraphCard'; 9 | export { default as SoftWrap } from './SoftWrap'; 10 | export { default as TitleCard } from './TitleCard'; 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "CFM: API+Next+Browser", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "yarn dev", 9 | "serverReadyAction": { 10 | "pattern": "started server on .+, url: http://localhost:([0-9]+)", 11 | "uriFormat": "http://localhost:%s", 12 | "action": "debugWithChrome" 13 | } 14 | }, 15 | { 16 | "name": "CFM: Browser", 17 | "type": "pwa-chrome", 18 | "request": "launch", 19 | "url": "http://localhost:4000" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /svgo.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | multipass: true, 3 | plugins: [ 4 | 'preset-default', 5 | 'convertStyleToAttrs', 6 | { 7 | name: 'removeAttributesBySelector', 8 | params: { 9 | selectors: [ 10 | { 11 | selector: '[stroke-opacity="1"]', 12 | attributes: ['path', 'circle'], 13 | }, 14 | { 15 | selector: '[stroke-dasharray="none"]', 16 | attributes: ['path', 'circle'], 17 | }, 18 | ], 19 | }, 20 | }, 21 | { 22 | name: 'sortAttrs', 23 | params: { 24 | xmlnsOrder: 'alphabetical', 25 | }, 26 | }, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /etl/src/1_extract/excel/table-contactSheet.jq: -------------------------------------------------------------------------------- 1 | [ 2 | .[] | with_entries(select( .key != "" )) | 3 | { 4 | backstory: ."Backstory / notes", 5 | contactPerson: ."CF point of contact", 6 | contactNotes: ."Contact Notes", 7 | contactPriority: (."Invite" != ""), 8 | contactInterested: (."Is interested in being involved" == "TRUE"), 9 | contactInitiated: (."has been contacted" == "TRUE"), 10 | fridgeOpen: (."is open" == "TRUE"), 11 | fridgeName: ."fridge name", 12 | address: ((.address | rtrimstr("\n")) + ", New York, NY"), 13 | fridgeNotes: ."additional information", 14 | } 15 | ] 16 | | [to_entries | .[] | .["id"] = (.key + 1000) | . + .value | del(.value,.key)] 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | import nextJest from 'next/jest.js'; 2 | 3 | const createJestConfig = nextJest({ 4 | dir: './' /** path to next.config.js and .env files in your test environment */, 5 | }); 6 | 7 | /** 8 | * Configuration passed directly to Jest. 9 | * 10 | * @type {import('jest').Config} 11 | */ 12 | const customJestConfig = { 13 | testEnvironment: 'jest-environment-jsdom', 14 | moduleDirectories: ['node_modules', 'src'], 15 | 16 | setupFilesAfterEnv: ['@testing-library/jest-dom', '@testing-library/react'], 17 | }; 18 | 19 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 20 | export default createJestConfig(customJestConfig); 21 | -------------------------------------------------------------------------------- /src/model/view/dialog/yup.js: -------------------------------------------------------------------------------- 1 | import apiFridge from 'model/data/fridge/yup/index.js'; 2 | 3 | export const dialogContact = apiFridge.Contact; 4 | 5 | export const dialogLocation = apiFridge.Location.omit(['geoLat', 'geoLng']); 6 | 7 | export const dialogFridge = apiFridge.Fridge.omit([ 8 | 'id', 9 | 'verified', 10 | 'tags', 11 | 'location', 12 | ]).shape({ 13 | location: dialogLocation.required(), 14 | }); 15 | 16 | export const dialogReport = apiFridge.Report.omit(['timestamp']); 17 | 18 | const dialogDataValidation = { 19 | Contact: dialogContact, 20 | Fridge: dialogFridge, 21 | Location: dialogLocation, 22 | Report: dialogReport, 23 | }; 24 | export default dialogDataValidation; 25 | -------------------------------------------------------------------------------- /src/model/view/prop-types.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { 3 | fieldsFridge as fieldsFridge_dm, 4 | fieldsLocation, 5 | fieldsReport, 6 | } from 'model/data/fridge/prop-types'; 7 | 8 | const typesLocation = PropTypes.exact(fieldsLocation); 9 | const typesReport = PropTypes.exact(fieldsReport); 10 | 11 | const fieldsFridge = { 12 | ...fieldsFridge_dm, 13 | report: typesReport, 14 | }; 15 | const typesFridge = PropTypes.exact(fieldsFridge); 16 | 17 | const viewValidator = { 18 | fields: { 19 | fridge: fieldsFridge, 20 | report: fieldsReport, 21 | }, 22 | Fridge: typesFridge, 23 | Report: typesReport, 24 | Location: typesLocation, 25 | }; 26 | 27 | export default viewValidator; 28 | -------------------------------------------------------------------------------- /etl/src/1_extract/excel/table-masterlist.jq: -------------------------------------------------------------------------------- 1 | [ 2 | .[] | map_values( if . == "TRUE" then true 3 | elif . == "FALSE" then false 4 | else . 5 | end) | 6 | { 7 | contactPriority: .priorityInvite, 8 | fridgeName: ."fridge name", 9 | address: ((.address | rtrimstr("\n")) + ", " + ."city state zip"), 10 | hours: .hours, 11 | hasFreezer: .freezer , 12 | maintainerScratchPad: .link, 13 | infoRules: .rules, 14 | infoScratchPad: .information, 15 | infoDonation: .donation, 16 | imageURL: .imageURL, 17 | searchBorough: .borough, 18 | searchNeighborhood: .neighborhood 19 | } 20 | ] 21 | | [to_entries | .[] | .["id"] = (.key + 1000) | . + .value | del(.value,.key)] 22 | -------------------------------------------------------------------------------- /public/feedback/happyFridge.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/atoms/PageFooter/PageFooter.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import PageFooter from './PageFooter'; 3 | 4 | describe('PageFooter', () => { 5 | it('renders the copyright notice', () => { 6 | render(); 7 | expect( 8 | screen.getByText(/Fridge Finder. All rights reserved./i) 9 | ).toBeInTheDocument(); 10 | const scrollLink = screen.queryByTitle(/Top of page/i); 11 | expect(scrollLink).toBeInTheDocument(); 12 | }); 13 | 14 | it('renders without the scroll button when scrollButton is false', () => { 15 | render(); 16 | const scrollLink = screen.queryByTitle(/Top of page/i); 17 | expect(scrollLink).not.toBeInTheDocument(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | reactStrictMode: true, 3 | 4 | images: { 5 | remotePatterns: [ 6 | new URL('https://community-fridge-map-images-prod.s3.amazonaws.com/**'), 7 | ], 8 | }, 9 | pageExtensions: ['page.jsx', 'page.js'], 10 | 11 | compiler: { 12 | emotion: true, 13 | }, 14 | modularizeImports: { 15 | '@mui/material': { 16 | transform: '@mui/material/{{member}}', 17 | }, 18 | '@mui/icons-material': { 19 | transform: '@mui/icons-material/{{member}}', 20 | }, 21 | 'components/atoms': { 22 | transform: 'components/atoms/{{member}}', 23 | }, 24 | 'components/molecules': { 25 | transform: 'components/molecules/{{member}}', 26 | }, 27 | 'components/organisms': { 28 | transform: 'components/organisms/{{member}}', 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/lib/geo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Distance in meters between two geo coordinates 3 | * 4 | * From https://stackoverflow.com/questions/43167417/calculate-distance-between-two-points-in-leaflet 5 | * By https://stackoverflow.com/users/4496505/gaurav-mukherjee 6 | */ 7 | export function deltaInMeters(origin, destination) { 8 | const lon1 = toRadian(origin[1]), 9 | lat1 = toRadian(origin[0]), 10 | lon2 = toRadian(destination[1]), 11 | lat2 = toRadian(destination[0]); 12 | 13 | const deltaLat = lat2 - lat1; 14 | const deltaLon = lon2 - lon1; 15 | 16 | const a = 17 | Math.pow(Math.sin(deltaLat / 2), 2) + 18 | Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(deltaLon / 2), 2); 19 | const c = 2 * Math.asin(Math.sqrt(a)); 20 | const EARTH_RADIUS = 6371; 21 | return c * EARTH_RADIUS * 1000; 22 | } 23 | 24 | function toRadian(degree) { 25 | return (degree * Math.PI) / 180; 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/typography.js: -------------------------------------------------------------------------------- 1 | const typography = { 2 | fontFamily: [ 3 | 'Inter', 4 | '"Helvetica Neue"', 5 | 'HelveticaNeue', 6 | 'Helvetica', 7 | '"TeX Gyre"', 8 | 'TeXGyre', 9 | 'Arial', 10 | 'sans-serif', 11 | ].join(','), 12 | h1: { fontSize: '36pt', fontWeight: 700, margin: '12pt 0 12pt 0' }, 13 | h2: { fontSize: '28pt', fontWeight: 700 }, 14 | h3: { fontSize: '28pt', fontWeight: 400 }, 15 | h4: { fontSize: '18pt', fontWeight: 700 }, 16 | h5: { fontSize: '18pt', fontWeight: 600 }, 17 | h6: { fontSize: '16pt', fontWeight: 600 }, 18 | body1: { fontSize: '18pt', fontWeight: 400 }, 19 | body2: { fontSize: '16pt', fontWeight: 400 }, 20 | button: { fontSize: '18pt', fontWeight: 700 }, 21 | caption: { fontSize: '15pt', fontWeight: 700, letterSpacing: 0.5 }, 22 | footer: { fontSize: '65%', fontWeight: 600 }, 23 | }; 24 | 25 | export default typography; 26 | -------------------------------------------------------------------------------- /docs/review-criteria.md: -------------------------------------------------------------------------------- 1 | # Review: Fridge Finder 2 | 3 | 1. UI Design 4 | - code implements all the elements from the linked Figma node 5 | - uses rounded MUI icons 6 | 7 | 1. Code Quality 8 | - there are no linting warnings in the shell terminal 9 | - there are no errors in the browser console 10 | - [no superfluous React import](https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html#removing-unused-react-imports) 11 | - must not import `styled` from '@mui/material/styles' or '@emotion/styled' 12 | 13 | 1. Test 14 | - ui component has a snapshot test 15 | - ui behavior is tested 16 | 17 | 1. File Structure 18 | - ui component is in the correct directory `/atoms, /molecules, /organisms` 19 | - ui component file name communicates its purpose 20 | - library function is in a `/lib` module 21 | - library function is documented 22 | 23 | 1. Commit 24 | - atomic commit 25 | -------------------------------------------------------------------------------- /src/lib/geo.test.js: -------------------------------------------------------------------------------- 1 | import { deltaInMeters } from './geo'; 2 | 3 | describe('deltaInMeters', () => { 4 | it('calculates zero distance for identical coordinates', () => { 5 | const origin = [40.7128, -74.006]; // New York City 6 | const destination = [40.7128, -74.006]; 7 | expect(deltaInMeters(origin, destination)).toBeCloseTo(0, 5); 8 | }); 9 | 10 | it('calculates correct distance between two known points', () => { 11 | const nyc = [40.7128, -74.006]; 12 | const la = [34.0522, -118.2437]; 13 | const distance = deltaInMeters(nyc, la); 14 | expect(distance).toBeGreaterThan(3930000); // ~3936 km 15 | expect(distance).toBeLessThan(3960000); 16 | }); 17 | 18 | it('calculates small distance accurately', () => { 19 | const pointA = [51.5007, -0.1246]; // London Eye 20 | const pointB = [51.5014, -0.1419]; // Buckingham Palace 21 | const distance = deltaInMeters(pointA, pointB); 22 | expect(distance).toBeGreaterThan(1000); 23 | expect(distance).toBeLessThan(2000); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/atoms/TitleCard/TitleCard.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import TitleCard from './TitleCard'; 3 | 4 | describe('TitleCard', () => { 5 | const mockImg = { 6 | src: '/hero/get-involved.webp', 7 | alt: 'Mock image', 8 | }; 9 | const mockTitle = 'Mock Title'; 10 | const mockLink = '/mock-link'; 11 | 12 | it('renders TitleCard correctly', () => { 13 | const { getByText, getByAltText, getByLabelText } = render( 14 | 15 | ); 16 | 17 | // the image is rendered with the correct alt text 18 | expect(getByAltText('Mock image')).toBeInTheDocument(); 19 | 20 | // the title is rendered with the correct text 21 | expect(getByText('Mock Title')).toBeInTheDocument(); 22 | 23 | // the link is rendered with the correct href and aria-label 24 | const linkElement = getByLabelText('Mock Title'); 25 | expect(linkElement).toBeInTheDocument(); 26 | expect(linkElement).toHaveAttribute('href', '/mock-link'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/lib/analytics.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Google Analytics module. 3 | * @module lib/analytics 4 | * 5 | * From https://github.com/vercel/next.js/tree/canary/examples/with-google-analytics 6 | */ 7 | 8 | /** 9 | * @constant {string} TRACKING_ID - Analytics tracking id. 10 | * 11 | * Local, Staging, and Production each have their own id. Therefore the id is set in the environment configuration. 12 | */ 13 | const TRACKING_ID = process.env?.NEXT_PUBLIC_ANALYTICS_ID ?? ''; 14 | 15 | /** 16 | * Track a view for the specified URL. 17 | * @param {string} url - The url of the page. 18 | */ 19 | const view = (url) => { 20 | window.gtag('config', TRACKING_ID, { page_path: url }); 21 | }; 22 | 23 | /** 24 | * Track a specific event. 25 | * @param {string} type - The type of event, such as a Google Ads conversion event or a Google Analytics 4 event 26 | * @param {string} parameters - Object of name/value pairs that describes the event 27 | */ 28 | const event = (type, parameters) => { 29 | window.gtag('event', type, parameters); 30 | }; 31 | 32 | export default Object.freeze({ TRACKING_ID, view, event }); 33 | -------------------------------------------------------------------------------- /ci/commit-msg.mjs: -------------------------------------------------------------------------------- 1 | // Invoked on the commit-msg git hook by simple-git-hooks. 2 | 3 | import { readFileSync } from 'fs'; 4 | import colors from 'picocolors'; 5 | 6 | // get $1 from commit-msg script 7 | const msgFilePath = process.argv[2]; 8 | const msgFileContents = readFileSync(msgFilePath, 'utf-8'); 9 | const commitTitle = msgFileContents.split(/\r?\n/)[0]; 10 | 11 | const commitRE = 12 | /^(revert: )?(feat|fix|refactor|test|perf|style|asset|doc|ci|chore|wip)(\(.+\))?: [A-Z].{1,68}[^.]$/; 13 | 14 | if (!commitRE.test(commitTitle)) { 15 | console.log(); 16 | console.error( 17 | ` ${colors.bgRed(colors.white(' ERROR '))} ${colors.white( 18 | `Invalid commit title format or length.` 19 | )}\n\n` + 20 | colors.white( 21 | ` Commit messages must under 70 characters and have the following format:\n\n` 22 | ) + 23 | ` ${colors.green(`feat: Add 'comments' option`)}\n` + 24 | ` ${colors.green(`fix: Handle events on blur (close #28)`)}\n\n` + 25 | colors.white(` See ./docs/commit-convention.md for more details.\n`) 26 | ); 27 | process.exit(1); 28 | } 29 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 Fridge Finder. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/atoms/PageHero/PageHero.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import PageHero from './PageHero'; 3 | 4 | describe('PageHero', () => { 5 | const mockImg = { 6 | src: '/hero/index.webp', 7 | alt: 'Mock image', 8 | }; 9 | 10 | const mockButton = { 11 | to: '/path', 12 | 'aria-label': 'Learn more about the Fridge Finder project', 13 | title: 'Click Me', 14 | variant: 'contained', 15 | }; 16 | 17 | it('renders correctly with image and button', () => { 18 | const { getByAltText, getByText } = render( 19 | 20 | ); 21 | expect(getByAltText('Mock image')).toBeInTheDocument(); 22 | expect(getByText('Click Me')).toBeInTheDocument(); 23 | expect(getByText('Click Me')).toHaveAttribute( 24 | 'aria-label', 25 | 'Learn more about the Fridge Finder project' 26 | ); 27 | }); 28 | 29 | it('renders correctly without button', () => { 30 | const { getByAltText, queryByRole } = render(); 31 | expect(getByAltText('Mock image')).toBeInTheDocument(); 32 | expect(queryByRole('button')).toBeNull(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/lib/data.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple object check. 3 | * @param item 4 | * @returns {boolean} 5 | * 6 | * From https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge 7 | * By https://stackoverflow.com/users/2938161/salakar 8 | */ 9 | export function isObject(item) { 10 | return item && typeof item === 'object' && !Array.isArray(item); 11 | } 12 | 13 | /** 14 | * Deep merge two objects. 15 | * @param target 16 | * @param ...sources 17 | * 18 | * From https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge 19 | * By https://stackoverflow.com/users/2938161/salakar 20 | */ 21 | export function mergeDeep(target, ...sources) { 22 | if (!sources.length) return target; 23 | const source = sources.shift(); 24 | 25 | if (isObject(target) && isObject(source)) { 26 | for (const key in source) { 27 | if (isObject(source[key])) { 28 | if (!target[key]) Object.assign(target, { [key]: {} }); 29 | mergeDeep(target[key], source[key]); 30 | } else { 31 | Object.assign(target, { [key]: source[key] }); 32 | } 33 | } 34 | } 35 | 36 | return mergeDeep(target, ...sources); 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/_app.page.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Head from 'next/head'; 3 | import { CacheProvider } from '@emotion/react'; 4 | import CssBaseline from '@mui/material/CssBaseline'; 5 | import { ThemeProvider } from '@mui/material/styles'; 6 | 7 | import AppBar from 'components/molecules/AppBar'; 8 | 9 | import createEmotionCache from 'lib/createEmotionCache'; 10 | import theme from 'theme'; 11 | 12 | const clientSideEmotionCache = createEmotionCache(); 13 | 14 | export default function MyApp({ 15 | Component, 16 | emotionCache = clientSideEmotionCache, 17 | pageProps, 18 | }) { 19 | return ( 20 | <> 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | MyApp.propTypes = { 35 | Component: PropTypes.elementType.isRequired, 36 | emotionCache: PropTypes.object, 37 | pageProps: PropTypes.object.isRequired, 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/atoms/PageHero/PageHero.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Image from 'next/legacy/image'; 3 | import { typesNextFillImage } from 'model/view/component/prop-types'; 4 | import { Box } from '@mui/material'; 5 | import { ButtonLink } from 'components/atoms'; 6 | 7 | export default function PageHero({ img, button }) { 8 | return ( 9 | 19 | 20 | {button && ( 21 | 33 | )} 34 | 35 | ); 36 | } 37 | PageHero.propTypes = { 38 | img: typesNextFillImage.isRequired, 39 | button: PropTypes.shape(ButtonLink.propTypes), 40 | }; 41 | -------------------------------------------------------------------------------- /src/lib/browser.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | /* eslint-disable */ 4 | export function useWindowHeight() { 5 | const [availableHeight, setAvailableHeight] = useState(0); 6 | const calculateAvailableHeight = () => 7 | window.innerHeight - document.getElementById('AppBar').offsetHeight; 8 | 9 | useEffect(() => { 10 | function handleResize() { 11 | setAvailableHeight(calculateAvailableHeight()); 12 | } 13 | 14 | setAvailableHeight(calculateAvailableHeight()); 15 | 16 | window.addEventListener('resize', handleResize); 17 | return () => window.removeEventListener('resize', handleResize); 18 | }, []); 19 | 20 | return availableHeight; 21 | } 22 | /* eslint-enable */ 23 | 24 | export function geolocation() { 25 | return new Promise((resolve, reject) => { 26 | if (location.protocol === 'https:' && navigator.geolocation) { 27 | navigator.geolocation.getCurrentPosition((position) => { 28 | if (position.coords) { 29 | resolve({ 30 | lat: position.coords.latitude, 31 | lng: position.coords.longitude, 32 | }); 33 | } else { 34 | reject(new Error('browser did not return coordinates')); 35 | } 36 | }); 37 | } else { 38 | reject(new Error('browser does not support geolocation api')); 39 | } 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/theme/index.js: -------------------------------------------------------------------------------- 1 | import { createTheme, responsiveFontSizes } from '@mui/material/styles'; 2 | 3 | import { applyAlpha, designColor, default as palette } from './palette'; 4 | import typography from './typography'; 5 | 6 | const theme = responsiveFontSizes( 7 | createTheme({ 8 | palette, 9 | typography, 10 | spacing: 4, 11 | props: { 12 | MuiAppBar: { 13 | color: designColor.blue.light, 14 | }, 15 | }, 16 | components: { 17 | MuiAppBar: { 18 | defaultProps: { 19 | color: 'secondary', 20 | }, 21 | }, 22 | MuiButton: { 23 | styleOverrides: { 24 | root: { 25 | borderRadius: 45, 26 | '&:hover': { 27 | borderColor: designColor.blue.dark, 28 | }, 29 | '&.MuiButton-outlined': { 30 | color: designColor.neroGray, 31 | borderColor: designColor.blue.dark, 32 | }, 33 | '&.Mui-disabled': { 34 | color: designColor.white, 35 | backgroundColor: applyAlpha('cc', designColor.neroGray), 36 | }, 37 | }, 38 | }, 39 | variants: [ 40 | { 41 | props: { size: 'wide' }, 42 | style: { minWidth: 300 }, 43 | }, 44 | ], 45 | }, 46 | }, 47 | }) 48 | ); 49 | 50 | export default theme; 51 | -------------------------------------------------------------------------------- /src/lib/analytics.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | describe('Google Analytics module', () => { 4 | const ORIGINAL_ENV = process.env; 5 | 6 | beforeEach(() => { 7 | jest.resetModules(); 8 | process.env = { ...ORIGINAL_ENV }; 9 | }); 10 | 11 | afterAll(() => { 12 | process.env = ORIGINAL_ENV; 13 | }); 14 | 15 | it('uses the production ID when NODE_ENV is production', () => { 16 | process.env.NODE_ENV = 'production'; 17 | process.env.NEXT_PUBLIC_ANALYTICS_ID = 'PROD-123'; 18 | const googleAnalytics = require( 19 | path.resolve(__dirname, '../lib/analytics') 20 | ).default; 21 | expect(googleAnalytics.TRACKING_ID).toBe('PROD-123'); 22 | }); 23 | 24 | it('uses the development ID when NODE_ENV is development', () => { 25 | process.env.NODE_ENV = 'development'; 26 | process.env.NEXT_PUBLIC_ANALYTICS_ID = 'DEV-456'; 27 | const googleAnalytics = require( 28 | path.resolve(__dirname, '../lib/analytics') 29 | ).default; 30 | expect(googleAnalytics.TRACKING_ID).toBe('DEV-456'); 31 | }); 32 | 33 | it('falls back to empty string if NEXT_PUBLIC_ANALYTICS_ID is undefined', () => { 34 | process.env.NODE_ENV = 'production'; 35 | delete process.env.NEXT_PUBLIC_ANALYTICS_ID; 36 | const googleAnalytics = require( 37 | path.resolve(__dirname, '../lib/analytics') 38 | ).default; 39 | expect(googleAnalytics.TRACKING_ID).toBe(''); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/atoms/SoftWrap/SoftWrap.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const regxSentence = /(\p{Terminal_Punctuation})/gu; 4 | const shortSentenceLength = 5; 5 | 6 | /** 7 | * Soft wrap every sentence so it wraps at the period and not in the middle. 8 | * @param paragraph Sentences separated by punctuation such as ".!?:;" and others used by foreign languages 9 | */ 10 | export default function SoftWrap(props) { 11 | if (!props) return null; 12 | const { text } = props; 13 | if (!text) return null; 14 | 15 | const chunks = text.split(regxSentence); 16 | chunks.push(''); // balance out the empty text element after ending punctuation 17 | 18 | let sentence = ''; 19 | const lines = []; 20 | for (let ix = 0; ix < chunks.length; ix += 2) { 21 | sentence += chunks[ix] + chunks[ix + 1]; 22 | if (sentence.length > shortSentenceLength) { 23 | lines.push(sentence); 24 | sentence = ''; 25 | } 26 | } 27 | 28 | // the last sentence goes on a line regardless of length 29 | if (sentence !== '') { 30 | lines.push(sentence); 31 | } 32 | 33 | return lines.length === 1 34 | ? lines[0] 35 | : lines.map((line, ix) => ( 36 | 41 | {line}  42 | 43 | )); 44 | } 45 | SoftWrap.propTypes = { 46 | text: PropTypes.string.isRequired, 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/atoms/ButtonLink/ButtonLink.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { Button } from '@mui/material'; 3 | import { NextLink } from 'components/atoms'; 4 | 5 | export default function ButtonLink(props) { 6 | const { title, sx = {}, ...buttonProps } = props; 7 | return ( 8 | 15 | ); 16 | } 17 | 18 | const toPathname = PropTypes.exact({ 19 | pathname: PropTypes.string.isRequired, 20 | query: PropTypes.object.isRequired, 21 | }); 22 | 23 | ButtonLink.propTypes = { 24 | /** 25 | * The title of the button. 26 | * 27 | * title='GO TO FRIDGE' 28 | */ 29 | title: PropTypes.string.isRequired, 30 | 31 | /** 32 | * The URL or pathname object to navigate to. 33 | * 34 | * to='/about' 35 | * to={{ pathname: '/blog/[slug]', query: { slug: post.slug }, }} 36 | */ 37 | to: PropTypes.oneOfType([PropTypes.string, toPathname]).isRequired, 38 | 39 | /** 40 | * Text describing the content of the link. 41 | * eg: aria-label='Learn more about the Fridge Finder project.' 42 | */ 43 | 'aria-label': PropTypes.string.isRequired, 44 | 45 | /*** 46 | * Style of button from theme 47 | */ 48 | variant: PropTypes.oneOf(['outlined', 'contained']).isRequired, 49 | 50 | /*** 51 | * MUI sx object containing css styles 52 | */ 53 | sx: PropTypes.object, 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/atoms/MapToggle/MapToggle.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { Button } from '@mui/material'; 3 | import { 4 | MapOutlined as MapIcon, 5 | FormatListBulletedOutlined as ListIcon, 6 | } from '@mui/icons-material'; 7 | 8 | export default function MapToggle({ currentView, setView }) { 9 | return ( 10 | 42 | ); 43 | } 44 | MapToggle.propTypes = { 45 | currentView: PropTypes.symbol, 46 | setView: PropTypes.func, 47 | }; 48 | 49 | MapToggle.view = Object.freeze({ 50 | map: Symbol(0), 51 | list: Symbol(1), 52 | }); 53 | -------------------------------------------------------------------------------- /public/feedback/emailError.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/model/data/fridge/prop-types.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export const typeTag = PropTypes.string.isRequired; 4 | 5 | export const fieldsLocation = { 6 | name: PropTypes.string, 7 | street: PropTypes.string.isRequired, 8 | city: PropTypes.string.isRequired, 9 | state: PropTypes.string.isRequired, 10 | zip: PropTypes.string.isRequired, 11 | geoLat: PropTypes.number.isRequired, 12 | geoLng: PropTypes.number.isRequired, 13 | }; 14 | 15 | export const fieldsMaintainer = { 16 | name: PropTypes.string, 17 | email: PropTypes.string, 18 | organization: PropTypes.string, 19 | phone: PropTypes.string, 20 | website: PropTypes.string, 21 | instagram: PropTypes.string, 22 | }; 23 | 24 | export const fieldsFridge = { 25 | id: PropTypes.string.isRequired, 26 | name: PropTypes.string.isRequired, 27 | location: PropTypes.exact(fieldsLocation).isRequired, 28 | tags: PropTypes.arrayOf(typeTag), 29 | maintainer: PropTypes.exact(fieldsMaintainer), 30 | photoUrl: PropTypes.string, 31 | notes: PropTypes.string, 32 | verified: PropTypes.bool, 33 | }; 34 | 35 | export const typeCondition = PropTypes.oneOf([ 36 | 'good', 37 | 'dirty', 38 | 'out of order', 39 | 'not at location', 40 | 'ghost', 41 | ]); 42 | export const typeFoodPercentage = PropTypes.oneOf([0, 1, 2, 3]); 43 | 44 | export const fieldsReport = { 45 | timestamp: PropTypes.string.isRequired, 46 | condition: typeCondition.isRequired, 47 | foodPercentage: typeFoodPercentage.isRequired, 48 | photoUrl: PropTypes.string, 49 | notes: PropTypes.string, 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/atoms/TitleCard/TitleCard.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { 3 | Card, 4 | CardContent, 5 | CardMedia, 6 | CardActionArea, 7 | Typography, 8 | } from '@mui/material'; 9 | import { NextLink } from 'components/atoms'; 10 | 11 | export default function TitleCard({ img, title, link }) { 12 | return ( 13 | 20 | 31 | 40 | 41 | 42 | {title} 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | const imgShape = { 51 | src: PropTypes.string.isRequired, 52 | alt: PropTypes.string.isRequired, 53 | }; 54 | 55 | TitleCard.propTypes = { 56 | img: PropTypes.shape(imgShape).isRequired, 57 | title: PropTypes.string.isRequired, 58 | link: PropTypes.string.isRequired, 59 | }; 60 | -------------------------------------------------------------------------------- /public/feedback/emailSuccess.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/organisms/dialog/components/PanelConfirm.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Stack, 4 | StepContent, 5 | StepLabel, 6 | Typography, 7 | } from '@mui/material'; 8 | import typesValidation from './prop-types'; 9 | 10 | export default function PanelConfirm({ buttonTitle, handleBack, handleNext }) { 11 | buttonTitle = buttonTitle.toLowerCase(); 12 | return ( 13 | <> 14 | Confirm 15 | 16 | 22 | 23 | Verify the details and click {buttonTitle} to confirm. 24 | 25 | 31 | 39 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | PanelConfirm.propTypes = typesValidation.PanelConfirm; 54 | -------------------------------------------------------------------------------- /src/theme/palette.js: -------------------------------------------------------------------------------- 1 | export const applyAlpha = (alpha, color) => color + alpha; 2 | 3 | export const pinColor = { 4 | itemsFull: '#97ed7d', 5 | itemsMany: '#ffe55c', 6 | itemsFew: '#ffd4ff', 7 | itemsEmpty: '#ffffff', 8 | fridgeNotAtLocation: '#d3d3d3', 9 | fridgeOperation: '#222', 10 | fridgeGhost: '#e3f2fd', 11 | reportUnavailable: '#d3d3d3', 12 | }; 13 | 14 | const grayscale = { 15 | gradient: [ 16 | '#FFFFFF', //0] white 17 | '#F6F6F6', //1] whiteSmoke 18 | '#D8D8D8', //2] lightSilver - veryLightGray 19 | '#B4B4B4', //3] magneticGray 20 | '#222222', //4] neroGray 21 | ], 22 | }; 23 | 24 | export const designColor = { 25 | white: grayscale.gradient[0], 26 | whiteSmoke: grayscale.gradient[1], 27 | lightSilver: grayscale.gradient[2], 28 | magneticGray: grayscale.gradient[3], 29 | neroGray: grayscale.gradient[4], 30 | black: '#000000', 31 | blue: { 32 | dark: '#1543D4', 33 | darkShade: ['#040B25'], 34 | light: '#88B3FF', 35 | }, 36 | }; 37 | 38 | const palette = { 39 | type: 'light', 40 | white: designColor.white, 41 | black: designColor.black, 42 | primary: { 43 | main: designColor.blue.dark, 44 | }, 45 | secondary: { 46 | main: designColor.blue.light, 47 | }, 48 | background: { 49 | default: designColor.white, 50 | paper: designColor.whiteSmoke, 51 | }, 52 | text: { 53 | primary: designColor.neroGray, 54 | secondary: applyAlpha('cc', designColor.neroGray), 55 | disabled: designColor.magneticGray, 56 | hint: applyAlpha('cc', designColor.neroGray), 57 | }, 58 | icon: applyAlpha('cc', designColor.neroGray), 59 | divider: designColor.neroGray, 60 | }; 61 | 62 | export default palette; 63 | -------------------------------------------------------------------------------- /src/components/organisms/browse/components/MapMarkerList.jsx: -------------------------------------------------------------------------------- 1 | import { Marker, Popup } from 'react-leaflet'; 2 | import { Stack, Typography } from '@mui/material'; 3 | import { ButtonLink } from 'components/atoms'; 4 | 5 | export default function MapMarkerList({ markerDataList }) { 6 | return markerDataList.map(({ marker, popup }, index) => { 7 | const { 8 | id, 9 | name: fridgeName, 10 | location: { street, city, state, zip }, 11 | } = popup; 12 | 13 | return ( 14 | 15 | 16 | {fridgeName} 17 |
18 | 23 | {street} 24 |
25 | {city}, {state} {zip} 26 |
27 | 28 | 36 | 44 | 45 |
46 |
47 | ); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/pages/pamphlet/get-involved/service-fridges.page.jsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { PageHero, PamphletParagraph, PageFooter } from 'components/atoms'; 3 | 4 | const pageContent = { 5 | pageHero: { 6 | img: { 7 | src: '/hero/service_fridges.webp', 8 | alt: 'Man inspecting fridge', 9 | }, 10 | }, 11 | content: [ 12 | { 13 | variant: 'h1', 14 | title: 'Taking care of fridges', 15 | body: [ 16 | 'Fridges should be cleaned daily. If you see trash at a fridge location, take the trash with you to dispose of it properly. If the fridge is in need of a repair, you can alert our community on Fridge Finder by submitting a status report for that fridge. Do you have repair skills that can fix broken refrigerators? Can you help build fridge shelters? Contact us.', 17 | ], 18 | button: { 19 | title: 'Service Fridges', 20 | to: { 21 | pathname: '/user/contact', 22 | query: { subject: 'Fridge Service Interest' }, 23 | }, 24 | 'aria-label': 'Interested in fixing and maintaining a fridge', 25 | variant: 'contained', 26 | }, 27 | }, 28 | ], 29 | }; 30 | 31 | export default function ServiceFridgesPage() { 32 | const { pageHero, content } = pageContent; 33 | return ( 34 | <> 35 | 36 | Fridge Finder: Service fridges 37 | 38 | 39 | 40 | 41 | {content.map((paragraph, index) => ( 42 | 0} 46 | /> 47 | ))} 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/pamphlet/get-involved/become-a-driver.page.jsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { PageHero, PamphletParagraph, PageFooter } from 'components/atoms'; 3 | 4 | const pageContent = { 5 | pageHero: { 6 | img: { 7 | src: '/hero/become_a_driver.webp', 8 | alt: 'Food carrier picking up lunch bags', 9 | }, 10 | }, 11 | content: [ 12 | { 13 | title: 'Become a Driver', 14 | variant: 'h1', 15 | body: [ 16 | 'Volunteer drivers have the capacity to support many fridges at once by transporting food to multiple locations. These driving routes are often coordinated between fridge organizers, food donors, and drivers. If you have access to a bike or vehicle, you can rescue food and feed people in need. The impact is immediate! Anyone is welcome to coordinate these efforts on their own, but if you would like to request our driver support, contact Fridge Finder.', 17 | ], 18 | button: { 19 | title: 'Become a Driver', 20 | to: { 21 | pathname: '/user/contact', 22 | query: { subject: 'Transport Food' }, 23 | }, 24 | 'aria-label': 'Become a Driver', 25 | variant: 'contained', 26 | }, 27 | }, 28 | ], 29 | }; 30 | 31 | export default function BecomeADriverPage() { 32 | const { pageHero, content } = pageContent; 33 | return ( 34 | <> 35 | 36 | Fridge Finder: Become a Driver 37 | 38 | 39 | 40 | {content.map((paragraph, index) => ( 41 | 0} 45 | /> 46 | ))} 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/fridge/[id].page.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Head from 'next/head'; 3 | import { FridgeInformation } from 'components/molecules'; 4 | 5 | export async function getServerSideProps(context) { 6 | return { 7 | props: await getFridgeRecord({ id: context.params.id }), 8 | }; 9 | } 10 | 11 | export default function FridgePage(props) { 12 | return ( 13 | <> 14 | 15 | {'Fridge Finder: ' + props.fridge.name} 16 | 17 | 18 | 19 | ); 20 | } 21 | FridgePage.propTypes = FridgeInformation.propTypes; 22 | 23 | const baseUrl = process.env.NEXT_PUBLIC_FF_API_URL + '/v1/fridges/'; 24 | 25 | /* eslint-disable */ 26 | async function getAllFridgeIds() { 27 | return fetch(baseUrl, { headers: { Accept: 'application/json' } }) 28 | .then((response) => response.json()) 29 | .then((json) => json.map((fridge) => fridge.id)) 30 | .catch((err) => { 31 | console.error(err); 32 | return []; 33 | }); 34 | } 35 | /* eslint-enable */ 36 | 37 | async function getFridgeRecord({ id }) { 38 | const responses = await Promise.all([ 39 | fetch(baseUrl + id, { headers: { Accept: 'application/json' } }), 40 | fetch(baseUrl + id + '/reports', { 41 | headers: { Accept: 'application/json' }, 42 | }), 43 | ]); 44 | for (const response of responses) { 45 | if (!response.ok) { 46 | console.error( 47 | `ERROR ${response.url} ${response.status}: ${response.statusText}` 48 | ); 49 | return {}; 50 | } 51 | } 52 | const [fridge, reports] = await Promise.all(responses.map((r) => r.json())); 53 | const report = reports.length > 0 ? reports[0] : null; 54 | if (report) { 55 | delete report.id; 56 | delete report.fridgeId; 57 | } 58 | return { fridge, report }; 59 | } 60 | getFridgeRecord.propTypes = { 61 | id: PropTypes.string.isRequired, 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/atoms/PageFooter/PageFooter.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { designColor } from 'theme/palette'; 3 | import { Box, Typography } from '@mui/material'; 4 | 5 | export default function PageFooter({ scrollButton = true }) { 6 | const sxFooter = { 7 | padding: 2, 8 | backgroundColor: designColor.magneticGray, 9 | width: '100%', 10 | display: 'flex', 11 | flexFlow: 'row wrap', 12 | rowGap: '0.2em', 13 | }; 14 | 15 | return ( 16 | 17 | 18 | 19 | © 2022-2025 Fridge Finder. All rights reserved. 20 | 21 | 22 | We may use cookies for storing information to help provide you with a 23 | better, faster, and safer experience and for SEO purposes. 24 | 25 | 26 | ); 27 | } 28 | PageFooter.propTypes = { 29 | scrollButton: PropTypes.bool, 30 | }; 31 | 32 | function PageScroll({ display }) { 33 | return display ? ( 34 | \') center no-repeat', 46 | boxShadow: '0 0.25rem 0.5rem 0 #222', 47 | opacity: 0.6, 48 | }} 49 | /> 50 | ) : null; 51 | } 52 | PageScroll.propTypes = { 53 | display: PropTypes.bool, 54 | }; 55 | -------------------------------------------------------------------------------- /docs/architecture-decisions.md: -------------------------------------------------------------------------------- 1 | # Architecture Decisions 2 | 3 | ## 2022-05-26 -- CSS Engine 4 | 5 | [MUI recommends the use of emotion for CSS styling](https://mui.com/material-ui/guides/styled-engine/). 6 | 7 | > Warning: Using styled-components as an engine at this moment is not working when used in a SSR projects. The reason is that the babel-plugin-styled-components is not picking up correctly the usages of the styled() utility inside the @mui packages. For more details, take a look at this issue. We strongly recommend using emotion for SSR projects. 8 | 9 | ## 2022-07-17 -- Deprecate the use of styled() 10 | 11 | The following functions have been deprecated because they are slow to render, cause issues with css caching, and cannot be rendered server side. Use the MUI/System `sx` prop instead. 12 | 13 | ```js 14 | import { styled } from '@mui/material/styles'; 15 | import { styled } from '@emotion'; 16 | ``` 17 | 18 | ## 2022-07-17 -- API field sizes 19 | 20 | Tag : 140 characters. The size of a twitter hash tag. 21 | 22 | Location.street : 55 characters. The longest street name in the U.S. is 38 characters long: "Jean Baptiste Point du Sable Lake Shore Drive" located in Chicago, Illinois. eg: 1001 Jean Baptiste Point du Sable Lake Shore Drive #33 23 | 24 | Location.city : 35 characters. The longest city name in the U.S. is "Village of Grosse Pointe Shores" in Michigan. 25 | 26 | Maintainer.name : 70 characters. https://stackoverflow.com/questions/30485/what-is-a-reasonable-length-limit-on-person-name-fields 27 | 28 | ## 2022-07-23 -- Standardize to bash shell 29 | 30 | Standardizing all script commands for bash has proven more complicated than it is worth. The node runtime and `yarn dev` execute commands via the system shell. Overriding this to use bash has proven difficult because the configuration steps differ between the various node versions and yarn versions. In addition the bash directory path is not POSIX compliant in Windows and MacOS. `/usr/bin/bash` wont work in either. 31 | -------------------------------------------------------------------------------- /etl/src/2_transform/align-keys.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'node:fs'; 2 | import { deltaInMeters } from '../../../src/lib/geo.mjs'; 3 | 4 | const tableMainFileName = 'table/main.json'; 5 | const tableInputFileName = 'table/excel.json'; 6 | const tableOutputFileName = 'temp/out.json'; 7 | const boundInMeters = 90; 8 | 9 | let errorCount = 0; 10 | function LogError(error) { 11 | if (error) { 12 | errorCount++; 13 | console.log(error); 14 | } 15 | } 16 | 17 | async function main() { 18 | const mainTable = JSON.parse(readFileSync(tableMainFileName)).map((e) => ({ 19 | mainId: e.mainId, 20 | destination: [e.locationGeoLat, e.locationGeoLng], 21 | })); 22 | const inputTable = JSON.parse(readFileSync(tableInputFileName)); 23 | 24 | for (const input of inputTable) { 25 | const origin = [input.locationGeoLat, input.locationGeoLng]; 26 | for (const main of mainTable) { 27 | if (deltaInMeters(origin, main.destination) < boundInMeters) { 28 | input.id = main.mainId; 29 | delete input.locationName; 30 | delete input.locationStreet; 31 | delete input.locationCity; 32 | delete input.locationState; 33 | delete input.locationZip; 34 | delete input.locationGeoLat; 35 | delete input.locationGeoLng; 36 | 37 | delete input.fridgeName; 38 | delete input.address; 39 | break; 40 | } 41 | } 42 | if (input.id >= 1000) { 43 | LogError({ 44 | id: input.id, 45 | errors: ['key could not be found in main table'], 46 | }); 47 | } 48 | } 49 | 50 | if (errorCount === 0) { 51 | const recordCount = inputTable.length; 52 | for (let id = 0; id < mainTable.length; ++id) { 53 | inputTable.push({ id }); 54 | } 55 | writeFileSync(tableOutputFileName, JSON.stringify(inputTable)); 56 | console.log( 57 | `successfully wrote ${recordCount} records to ${tableOutputFileName}` 58 | ); 59 | } 60 | return errorCount; 61 | } 62 | process.exit(await main()); 63 | -------------------------------------------------------------------------------- /src/pages/pamphlet/get-involved/source-food.page.jsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { PageHero, PamphletParagraph, PageFooter } from 'components/atoms'; 3 | 4 | const pageContent = { 5 | pageHero: { 6 | img: { 7 | src: '/hero/source_food.webp', 8 | alt: 'Boxes of food stacked up in front of Community Focus', 9 | }, 10 | }, 11 | content: [ 12 | { 13 | variant: 'h1', 14 | title: 'Source Food', 15 | body: [ 16 | 'Sourcing food donations is possible by building relationships with businesses that have surplus products. Fridge organizers are able to redirect food waste from bakeries, grocery stores, pantries, cafes,restaurants, and more. That way, perfectly good food can provide nutrition to people in need, instead of being thrown away. Businesses have many incentives to partner with community fridges. Excess food causes a negative environmental impact and inefficiencies within our economy, which community fridges can help resolve. The best way to approach a business about donating food would be to present materials about community fridges and create a proposal.', 17 | ], 18 | button: { 19 | title: 'Source Food', 20 | to: { 21 | pathname: '/user/contact', 22 | query: { subject: 'Sourcing Food Inquiry' }, 23 | }, 24 | 'aria-label': 'Sourcing Food Inquiry', 25 | variant: 'contained', 26 | size: 'wide', 27 | }, 28 | }, 29 | ], 30 | }; 31 | 32 | export default function SourceFoodPage() { 33 | const { pageHero, content } = pageContent; 34 | return ( 35 | <> 36 | 37 | Fridge Finder: Source Food 38 | 39 | 40 | 41 | 42 | {content.map((paragraph, index) => ( 43 | 0} 47 | /> 48 | ))} 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/atoms/FeedbackCard/FeedbackCard.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, fireEvent } from '@testing-library/react'; 3 | import FeedbackCard from './FeedbackCard'; 4 | 5 | describe('FeedbackCard', () => { 6 | it('renders EmailSuccess variant correctly', () => { 7 | render(); 8 | expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( 9 | 'Success!' 10 | ); 11 | expect(screen.getByText('Your email was sent.')).toBeInTheDocument(); 12 | expect( 13 | screen.getByRole('link', { name: 'Go to Home page' }) 14 | ).toHaveAttribute('href', '/'); 15 | expect(screen.getByAltText('Email success image')).toBeInTheDocument(); 16 | }); 17 | 18 | it('renders FridgeStatusSuccess variant correctly', () => { 19 | const slug = '/fridge/test-fridge-123'; 20 | render(); 21 | expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( 22 | 'Success!' 23 | ); 24 | expect( 25 | screen.getByText('You have successfully submitted a status report!') 26 | ).toBeInTheDocument(); 27 | expect( 28 | screen.getByRole('link', { name: 'View Fridge status' }) 29 | ).toHaveAttribute('href', slug); 30 | expect(screen.getByAltText('Happy fridge image')).toBeInTheDocument(); 31 | }); 32 | 33 | it('renders Error variant correctly and triggers callback', () => { 34 | const mockFn = jest.fn(); 35 | render(); 36 | expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( 37 | 'Error!' 38 | ); 39 | expect(screen.getByText('Error!')).toBeInTheDocument(); 40 | const button = screen.getByRole('button', { 41 | name: 'Return to the form and try again', 42 | }); 43 | expect(button).toHaveTextContent('TRY AGAIN'); 44 | fireEvent.click(button); 45 | expect(mockFn).toHaveBeenCalled(); 46 | expect(screen.getByAltText('Email error image')).toBeInTheDocument(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /public/card/title/startFridge.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/organisms/browse/model/markersFrom.js: -------------------------------------------------------------------------------- 1 | import Leaflet from 'leaflet'; 2 | import { pinColor } from 'theme/palette'; 3 | import { 4 | svgDecorationDirty, 5 | svgDecorationOutOfOrder, 6 | svgUrlPinGhost, 7 | svgUrlPinLocation, 8 | svgUrlPinNoReport, 9 | svgUrlPinNotAtLocation, 10 | } from 'theme/icons'; 11 | 12 | export default function markersFrom(fridgeList) { 13 | return fridgeList.map((fridge) => { 14 | const { id, name, location, report } = fridge; 15 | const { condition, foodPercentage } = report ?? { 16 | condition: 'no report', 17 | foodPercentage: 0, 18 | }; 19 | 20 | return { 21 | marker: { 22 | title: name, 23 | position: [location.geoLat, location.geoLng], 24 | icon: iconFrom(condition, foodPercentage), 25 | riseOnHover: true, 26 | riseOffset: 50, 27 | }, 28 | popup: { id, name, location }, 29 | }; 30 | }); 31 | } 32 | 33 | const colorFrom = Object.freeze({ 34 | 0: pinColor.itemsEmpty, 35 | 1: pinColor.itemsFew, 36 | 2: pinColor.itemsMany, 37 | 3: pinColor.itemsFull, 38 | }); 39 | const decorationFrom = Object.freeze({ 40 | good: '', 41 | dirty: svgDecorationDirty, 42 | 'out of order': svgDecorationOutOfOrder, 43 | }); 44 | 45 | const iconCache = {}; 46 | function getLeafletIcon(hash, svgUrl) { 47 | let icon; 48 | if (hash in iconCache) { 49 | icon = iconCache[hash]; 50 | } else { 51 | icon = new Leaflet.Icon({ 52 | iconUrl: svgUrl, 53 | popupAnchor: [0, -24], 54 | iconSize: [40, 40], 55 | }); 56 | iconCache[hash] = icon; 57 | } 58 | return icon; 59 | } 60 | 61 | function iconFrom(condition, foodPercentage) { 62 | switch (condition) { 63 | case 'not at location': 64 | return getLeafletIcon(condition, svgUrlPinNotAtLocation()); 65 | case 'no report': 66 | return getLeafletIcon(condition, svgUrlPinNoReport()); 67 | case 'ghost': 68 | return getLeafletIcon(condition, svgUrlPinGhost()); 69 | default: 70 | return getLeafletIcon( 71 | condition + foodPercentage, 72 | svgUrlPinLocation(colorFrom[foodPercentage], decorationFrom[condition]) 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/model/view/component/prop-types.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | /** 4 | * next/legacy/image props 5 | * 6 | * From https://nextjs.org/docs/pages/api-reference/components/image-legacy 7 | * By https://github.com/bernardm 8 | */ 9 | export const typesNextImage = PropTypes.exact({ 10 | src: PropTypes.string.isRequired, 11 | alt: PropTypes.string.isRequired, 12 | width: PropTypes.number.isRequired, 13 | height: PropTypes.number.isRequired, 14 | layout: PropTypes.oneOf(['intrinsic', 'fixed', 'responsive']), 15 | }); 16 | export const typesNextFillImage = PropTypes.exact({ 17 | src: PropTypes.string.isRequired, 18 | alt: PropTypes.string.isRequired, 19 | }); 20 | 21 | /** 22 | * Formik props 23 | * 24 | * From https://jaredpalmer.com/formik/docs/api/formik#formik-render-methods-and-props 25 | * By https://github.com/ClementParis016 26 | */ 27 | export const typesFormik = PropTypes.exact({ 28 | dirty: PropTypes.bool.isRequired, 29 | errors: PropTypes.object.isRequired, 30 | handleBlur: PropTypes.func.isRequired, 31 | handleChange: PropTypes.func.isRequired, 32 | handleReset: PropTypes.func.isRequired, 33 | handleSubmit: PropTypes.func.isRequired, 34 | isSubmitting: PropTypes.bool.isRequired, 35 | isValid: PropTypes.bool.isRequired, 36 | isValidating: PropTypes.bool.isRequired, 37 | resetForm: PropTypes.func.isRequired, 38 | setErrors: PropTypes.func.isRequired, 39 | setFieldError: PropTypes.func.isRequired, 40 | setFieldTouched: PropTypes.func.isRequired, 41 | submitForm: PropTypes.func.isRequired, 42 | submitCount: PropTypes.number.isRequired, 43 | setFieldValue: PropTypes.func.isRequired, 44 | setStatus: PropTypes.func.isRequired, 45 | setSubmitting: PropTypes.func.isRequired, 46 | setTouched: PropTypes.func.isRequired, 47 | setValues: PropTypes.func.isRequired, 48 | status: PropTypes.any, 49 | touched: PropTypes.object.isRequired, 50 | values: PropTypes.object.isRequired, 51 | validateForm: PropTypes.func.isRequired, 52 | validateField: PropTypes.func.isRequired, 53 | }); 54 | 55 | const typesViewComponent = { 56 | Formik: typesFormik, 57 | NextImage: typesNextImage, 58 | NextFillImage: typesNextFillImage, 59 | }; 60 | export default typesViewComponent; 61 | -------------------------------------------------------------------------------- /src/components/organisms/browse/components/SearchMap.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { useState } from 'react'; 3 | import { Box, InputBase, IconButton } from '@mui/material'; 4 | import { 5 | Search as SearchIcon, 6 | Cancel as CancelIcon, 7 | ArrowBackIosNew as ArrowBackIcon, 8 | } from '@mui/icons-material'; 9 | 10 | import { applyAlpha, designColor } from 'theme/palette'; 11 | 12 | const flexStyles = { 13 | display: 'flex', 14 | justifyContent: 'center', 15 | alignItems: 'center', 16 | px: 3, 17 | }; 18 | 19 | export default function SearchMap({ setShowSearchMap }) { 20 | const [searchQuery, setSearchQuery] = useState(''); 21 | 22 | function handleSearch(e) { 23 | e.preventDefault(); 24 | } 25 | 26 | return ( 27 | 38 | setShowSearchMap(false)} 41 | > 42 | 43 | 44 | 56 | 57 | setSearchQuery(e.target.value)} 61 | sx={{ mx: 1 }} 62 | fullWidth 63 | /> 64 | {searchQuery.length > 0 && ( 65 | setSearchQuery('')} 68 | sx={{ p: '5px' }} 69 | > 70 | 71 | 72 | )} 73 | 74 | 75 | ); 76 | } 77 | SearchMap.propTypes = { 78 | setShowSearchMap: PropTypes.func, 79 | }; 80 | -------------------------------------------------------------------------------- /src/model/data/fridge/yup/index.js: -------------------------------------------------------------------------------- 1 | import { array, boolean, date, number, object, string } from 'yup'; 2 | 3 | // fridge database records 4 | export const ValuesTag = string().max(140).trim().required(); 5 | export const ValuesTags = array().of(ValuesTag).nullable(); 6 | 7 | export const ValuesLocation = object({ 8 | name: string().max(70).trim().optional(), 9 | street: string().max(55).trim().required(), 10 | city: string().max(35).trim().required(), 11 | state: string().length(2).uppercase().required(), 12 | zip: string() 13 | .matches(/(^\d{5}$)|(^\d{5}-\d{4}$)/) 14 | .required(), 15 | geoLat: number().required(), 16 | geoLng: number().required(), 17 | }); 18 | 19 | export const ValuesMaintainer = object({ 20 | name: string().max(70).trim().optional(), 21 | email: string().email().lowercase().optional(), 22 | organization: string().max(80).trim().optional(), 23 | phone: string() 24 | .matches(/^\(\d{3}\) \d{3}-\d{4}$/) 25 | .optional(), 26 | website: string().url().optional(), 27 | instagram: string().url().optional(), 28 | }).nullable(); 29 | 30 | export const ValuesFridge = object({ 31 | id: string().min(4).max(60).required(), 32 | name: string().min(4).max(60).trim().required(), 33 | location: ValuesLocation.required(), 34 | tags: ValuesTag.optional(), 35 | maintainer: ValuesMaintainer.optional(), 36 | photoUrl: string().url().optional(), 37 | notes: string().min(1).max(700).trim().optional(), 38 | verified: boolean().default(false), 39 | }); 40 | 41 | export const ValuesReport = object({ 42 | timestamp: date().required(), 43 | condition: string() 44 | .oneOf(['good', 'dirty', 'out of order', 'not at location', 'ghost']) 45 | .required(), 46 | foodPercentage: number().integer().oneOf([0, 1, 2, 3]).required(), 47 | photoUrl: string().url().optional(), 48 | notes: string().min(0).max(300).trim().optional(), 49 | }); 50 | 51 | // website contact form data 52 | export const ValuesContact = object({ 53 | name: string().max(70).trim().required(), 54 | email: string().email().required(), 55 | subject: string().max(70).trim().required(), 56 | message: string().max(2048).trim().required(), 57 | }); 58 | 59 | const valuesDataFridge = { 60 | Contact: ValuesContact, 61 | Fridge: ValuesFridge, 62 | Location: ValuesLocation, 63 | Report: ValuesReport, 64 | Tag: ValuesTag, 65 | Tags: ValuesTags, 66 | }; 67 | export default valuesDataFridge; 68 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginNext from '@next/eslint-plugin-next'; 3 | import pluginReact from 'eslint-plugin-react'; 4 | import pluginReactHooks from 'eslint-plugin-react-hooks'; 5 | import babelParser from '@babel/eslint-parser'; 6 | import js from '@eslint/js'; 7 | 8 | export default [ 9 | js.configs.recommended, 10 | { 11 | files: ['src/**/*.{js,jsx}'], 12 | languageOptions: { 13 | ecmaVersion: 'latest', 14 | sourceType: 'module', 15 | globals: { 16 | ...globals.browser, 17 | ...globals.node, 18 | ...globals.jest, 19 | React: true, 20 | L: 'readonly', // for leaflet@1.8 21 | }, 22 | parser: babelParser, // for JSX parsing 23 | parserOptions: { 24 | requireConfigFile: false, // skip check for babel.config.js 25 | babelOptions: { 26 | plugins: ['@babel/plugin-syntax-jsx'], 27 | }, 28 | ecmaFeatures: { jsx: true }, 29 | }, 30 | }, 31 | plugins: { 32 | '@next/next': pluginNext, 33 | 'react-hooks': pluginReactHooks, 34 | react: pluginReact, 35 | }, 36 | rules: { 37 | ...pluginNext.configs['core-web-vitals'].rules, 38 | ...pluginNext.configs['recommended'].rules, 39 | ...pluginReact.configs['jsx-runtime'].rules, 40 | ...pluginReact.configs['recommended'].rules, 41 | ...pluginReactHooks.configs['recommended'].rules, 42 | 'no-restricted-imports': [ 43 | 'error', 44 | { 45 | paths: [ 46 | { 47 | name: '@emotion/styled', 48 | message: 'Please use MUI/System instead.', 49 | }, 50 | { 51 | name: '@mui/material/styles', 52 | importNames: ['styled'], 53 | message: 'Please use MUI/System instead.', 54 | }, 55 | ], 56 | 57 | patterns: ['@mui/*/*/*', '!@mui/material/test-utils/*'], 58 | }, 59 | ], 60 | 'react/prop-types': 'error', 61 | 'react/react-in-jsx-scope': 'off', 62 | }, 63 | settings: { 64 | react: { 65 | version: '19.1.1', 66 | }, 67 | }, 68 | }, 69 | { 70 | files: ['{etl,ci}/**/*.mjs'], 71 | languageOptions: { 72 | ecmaVersion: 'latest', 73 | sourceType: 'module', 74 | globals: { 75 | ...globals.node, 76 | ...globals.jest, 77 | }, 78 | }, 79 | }, 80 | ]; 81 | -------------------------------------------------------------------------------- /src/components/atoms/PamphletParagraph/PamphletParagraph.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import PamphletParagraph from './PamphletParagraph'; 3 | 4 | const mockTitle = 'Mock Title'; 5 | const mockVariant = 'h2'; 6 | const mockImg = { 7 | src: '/paragraph/pamphlet/about/independence_for_each_fridge.webp', 8 | alt: 'Mock image', 9 | width: 414, 10 | height: 276, 11 | }; 12 | const mockBody = ['Paragraph 1', 'Paragraph 2']; 13 | const mockButton = { 14 | variant: 'contained', 15 | to: '/path', 16 | 'aria-label': 'Button', 17 | title: 'Click Me', 18 | }; 19 | 20 | describe('PamphletParagraph', () => { 21 | it('renders correctly with all props', () => { 22 | const { getByText, getByAltText } = render( 23 | 31 | ); 32 | 33 | // the title is rendered with the correct text and variant 34 | expect(getByText('Mock Title')).toBeInTheDocument(); 35 | expect(getByText('Mock Title')).toHaveProperty('tagName', 'H2'); 36 | 37 | // the image is rendered with the correct alt text 38 | expect(getByAltText('Mock image')).toBeInTheDocument(); 39 | 40 | // the paragraphs are rendered with the correct text 41 | expect(getByText('Paragraph 1')).toBeInTheDocument(); 42 | expect(getByText('Paragraph 2')).toBeInTheDocument(); 43 | 44 | // the button is rendered with the correct text, variant, and aria-label 45 | const button = getByText('Click Me'); 46 | expect(button).toBeInTheDocument(); 47 | expect(button).toHaveAttribute('aria-label', 'Button'); 48 | }); 49 | 50 | it('renders correctly without optional props', () => { 51 | const { queryByAltText, queryByText, getByText } = render( 52 | 53 | ); 54 | 55 | // the title is rendered with the correct text and variant 56 | expect(getByText('Mock Title')).toBeInTheDocument(); 57 | 58 | // the image is not rendered 59 | expect(queryByAltText('Mock image')).toBeNull(); 60 | 61 | // the paragraphs are not rendered 62 | expect(queryByText('Paragraph 1')).toBeNull(); 63 | expect(queryByText('Paragraph 2')).toBeNull(); 64 | 65 | // the button is not rendered 66 | expect(queryByText('Click Me')).toBeNull(); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/components/atoms/NextLink/NextLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Link from 'next/link'; 4 | 5 | /** 6 | * This component allows Next/Link to function correctly when it is passed into another react component using props. 7 | * 8 | * @see https://nextjs.org/docs/routing/introduction 9 | * @see https://github.com/mui/material-ui/blob/master/examples/nextjs-with-typescript/src/Link.tsx 10 | * 11 | * @param {Object} props 12 | * @param {Element} ref 13 | */ 14 | function NextLinkFn(props, ref) { 15 | const { 16 | to, 17 | linkAs, 18 | replace, 19 | scroll, 20 | shallow, 21 | prefetch = false, 22 | locale, 23 | ...attributes 24 | } = props; 25 | 26 | return ( 27 | 39 | ); 40 | } 41 | NextLinkFn.displayName = 'NextLink'; 42 | 43 | const toPathname = PropTypes.shape({ 44 | pathname: PropTypes.string.isRequired, 45 | query: PropTypes.object.isRequired, 46 | }); 47 | 48 | NextLinkFn.propTypes = { 49 | /** 50 | * The URL or pathname object to navigate to. 51 | * 52 | * to='/about' 53 | * to={{ pathname: '/blog/[slug]', query: { slug: post.slug }, }} 54 | */ 55 | to: PropTypes.oneOfType([PropTypes.string, toPathname]).isRequired, 56 | 57 | /** 58 | * Optional HTML attributes for the tag 59 | */ 60 | attributes: PropTypes.object, 61 | 62 | /** 63 | * Optional decorator for the path that will be shown in the browser URL bar. 64 | */ 65 | linkAs: PropTypes.string, 66 | 67 | /** 68 | * Allows for providing a different locale. 69 | */ 70 | locale: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), 71 | 72 | /** 73 | * Prefetch the page in the background. 74 | */ 75 | prefetch: PropTypes.bool, 76 | 77 | /** 78 | * Replace the current history state instead of adding a new url into the stack. 79 | */ 80 | replace: PropTypes.bool, 81 | 82 | /** 83 | * Scroll to the top of the page after a navigation. 84 | */ 85 | scroll: PropTypes.bool, 86 | 87 | /** 88 | * Update the path of the current page without rerunning getStaticProps, getServerSideProps or getInitialProps. 89 | */ 90 | shallow: PropTypes.bool, 91 | }; 92 | 93 | export default React.forwardRef(NextLinkFn); 94 | -------------------------------------------------------------------------------- /etl/src/3_load/create-reports.mjs: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import http from 'http'; 3 | const { randomInt } = await import('crypto'); 4 | 5 | const apiHost = '127.0.0.1'; 6 | const apiPort = 3050; 7 | 8 | main(); 9 | 10 | function main() { 11 | const httpGetOptions = { 12 | hostname: apiHost, 13 | port: apiPort, 14 | path: '/v1/fridges', 15 | method: 'GET', 16 | }; 17 | 18 | const req = http.request(httpGetOptions, (res) => { 19 | let body = ''; 20 | 21 | res.on('data', (chunk) => (body += chunk)); 22 | res.on('end', () => { 23 | try { 24 | let json = JSON.parse(body); 25 | for (let fridge of json) { 26 | updatePhotoFor(fridge); 27 | createReportFor(fridge); 28 | } 29 | } catch (error) { 30 | console.error(error.message); 31 | } 32 | }); 33 | }); 34 | 35 | req.on('error', (error) => { 36 | console.error(error); 37 | }); 38 | 39 | req.end(); 40 | } 41 | 42 | function createReportFor({ id }) { 43 | // 1/10 chance of no report 44 | if (oneIn(10)) { 45 | return; 46 | } 47 | 48 | const data = { 49 | fridgeId: id, 50 | timestamp: faker.date.recent(), 51 | condition: oneIn(50) 52 | ? 'not at location' 53 | : faker.helpers.arrayElement(['good', 'dirty', 'out of order', 'ghost']), 54 | foodPercentage: faker.helpers.arrayElement([0, 1, 2, 3]), 55 | photoUrl: '/card/paragraph/pearTomatoAndFridge.svg', 56 | notes: faker.lorem.lines(1), 57 | }; 58 | 59 | httpRequest('POST', `/v1/fridges/${id}/reports`, data); 60 | } 61 | 62 | function updatePhotoFor(fridge) { 63 | fridge.photoUrl = '/feedback/happyFridge.svg'; 64 | httpRequest('PATCH', `/v1/fridges/${fridge.id}`, fridge); 65 | } 66 | 67 | const oneIn = (n) => randomInt(n) === 0; 68 | 69 | function httpRequest(method, path, dataJson) { 70 | const dataStr = JSON.stringify(dataJson); 71 | 72 | const options = { 73 | hostname: apiHost, 74 | port: apiPort, 75 | method, 76 | path, 77 | headers: { 78 | 'Content-Type': 'application/json', 79 | 'Content-Length': dataStr.length, 80 | }, 81 | }; 82 | 83 | const request = http.request(options, (res) => { 84 | const statusBad = res.statusCode < 200 || res.statusCode > 299; 85 | if (statusBad) { 86 | console.log(`statusCode: ${res.statusCode}`, res.req.path); 87 | } 88 | }); 89 | 90 | request.on('error', (error) => { 91 | console.error(error); 92 | }); 93 | 94 | request.write(dataStr); 95 | request.end(); 96 | } 97 | -------------------------------------------------------------------------------- /src/pages/demo/styles.page.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Head from 'next/head'; 3 | import { Stack, Typography } from '@mui/material'; 4 | 5 | export async function getStaticProps() { 6 | return { 7 | props: { 8 | text: `Lorem ipsum dolor sit amet consectetur adipisicing elit. Enim corrupti 9 | soluta cum, facere ut alias quae quidem autem minima ad sunt, eum 10 | tempore, nobis odit beatae? Repudiandae saepe voluptates nihil?`, 11 | listItems: [`Lorem`, `ipsum`, `dolor`], 12 | }, 13 | }; 14 | } 15 | 16 | export default function DemoStylesPage({ text, listItems }) { 17 | return ( 18 | <> 19 | 20 | Fridge Finder: Theme Styles Demo 21 | 22 | 23 | 24 | 25 | Bold 26 |
27 | {text} 28 |
29 | 30 | Italic 31 |
32 | {text} 33 |
34 | 35 | Subscript 36 |
37 | Lorem ipsum dolor sit amet consectetur adipisicing elit. 38 |
39 | 40 | Superscript 41 |
42 | Lorem ipsum dolor sit amet consectetur adipisicing elit. 43 |
44 | 45 | Blockquote 46 |
{text}
47 |
48 | 49 | ul 50 |
    51 | {listItems.map((item, i) => ( 52 |
  • {item}
  • 53 | ))} 54 |
55 |
56 | 57 | ol 58 |
    59 | {listItems.map((item, i) => ( 60 |
  1. {item}
  2. 61 | ))} 62 |
63 |
64 | 65 | Typography caption 66 |
67 | {text} 68 |
69 | 70 | Typography subtitle1 71 |
72 | {text} 73 |
74 | 75 | Typography subtitle2 76 |
77 | {text} 78 |
79 |
80 | 81 | ); 82 | } 83 | DemoStylesPage.propTypes = { 84 | text: PropTypes.string.isRequired, 85 | listItems: PropTypes.arrayOf(PropTypes.string).isRequired, 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/organisms/dialog/components/PanelNotes.jsx: -------------------------------------------------------------------------------- 1 | import { typesPanel } from './prop-types'; 2 | import { dialogReport } from 'model/view/dialog/yup'; 3 | import { useFormik } from 'formik'; 4 | 5 | import { 6 | Button, 7 | Stack, 8 | StepLabel, 9 | StepContent, 10 | TextField, 11 | } from '@mui/material'; 12 | 13 | const dialogNotesValidation = dialogReport.pick(['notes']); 14 | 15 | export default function PanelNotes({ handleNext, handleBack, getPanelValues }) { 16 | const formik = useFormik({ 17 | initialValues: { 18 | notes: '', 19 | }, 20 | validationSchema: dialogNotesValidation, 21 | onSubmit: (values) => { 22 | getPanelValues(values); 23 | handleNext(); 24 | }, 25 | }); 26 | 27 | return ( 28 | <> 29 | Notes 30 | 31 |
32 | 38 | 52 | 58 | 66 | 74 | 75 | 76 |
77 |
78 | 79 | ); 80 | } 81 | PanelNotes.propTypes = typesPanel.isRequired; 82 | -------------------------------------------------------------------------------- /.github/workflows/dev.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | pull_request: 8 | branches: [dev] 9 | 10 | jobs: 11 | Install_Dependencies: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: '22.20.0' 18 | cache: 'yarn' 19 | 20 | - name: Get yarn cache directory path 21 | id: yarn-cache-dir-path 22 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 23 | 24 | - name: Cache Yarn dependencies 25 | uses: actions/cache@v3 26 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 27 | with: 28 | path: node_modules 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | 31 | - name: Install dependencies 32 | if: steps.yarn-cache.outputs.cache-hit != 'true' 33 | run: yarn install 34 | continue-on-error: false 35 | 36 | Run_Build: 37 | needs: 38 | - Install_Dependencies 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v3 42 | - uses: actions/setup-node@v3 43 | with: 44 | node-version-file: '.nvmrc' 45 | cache: 'yarn' 46 | 47 | - name: Cache Yarn Dependencies 48 | uses: actions/cache@v3 49 | with: 50 | path: node_modules 51 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 52 | 53 | - name: Run Build 54 | run: | 55 | yarn build 56 | 57 | Run_Test: 58 | needs: 59 | - Install_Dependencies 60 | runs-on: ubuntu-latest 61 | steps: 62 | - uses: actions/checkout@v3 63 | - uses: actions/setup-node@v3 64 | with: 65 | node-version-file: '.nvmrc' 66 | cache: 'yarn' 67 | 68 | - name: Cache Yarn Dependencies 69 | uses: actions/cache@v3 70 | with: 71 | path: node_modules 72 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 73 | 74 | - name: Run Tests 75 | run: | 76 | yarn test 77 | 78 | Run_Lint: 79 | needs: 80 | - Install_Dependencies 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v3 84 | - uses: actions/setup-node@v3 85 | with: 86 | node-version-file: '.nvmrc' 87 | cache: 'yarn' 88 | 89 | - name: Cache Yarn Dependencies 90 | uses: actions/cache@v3 91 | with: 92 | path: node_modules 93 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 94 | 95 | - name: Run Lint 96 | run: | 97 | yarn lint 98 | -------------------------------------------------------------------------------- /docs/etl/address_format.md: -------------------------------------------------------------------------------- 1 | # Address Format 2 | 3 | - Argentina (AR): Street + Number, Postal Code + Locality, Province, Country (Postal code: 4 digits + 3 letters optional) 4 | - Australia (AU): Unit/Street Number + Street, Suburb, State, Postcode, Country (Postcode: 4 digits) 5 | - Belgium (BE): Street + Number, Postal Code + Municipality, Country (Postal code: 4 digits) 6 | - Brazil (BR): Street + Number, District, City, State, Postal Code, Country (Postal code: 8 digits XXXXX-XXX) 7 | - Canada (CA): Unit/Street Number + Street, City, Province, Postal Code, Country (Postal code: A1A 1A1 format) 8 | - China (CN): Province, City, District, Street, Building Number, Postal Code (Postal code: 6 digits) 9 | - Colombia (CO): Street + Number, City, Department, Postal Code, Country (Postal code: 6 digits) 10 | - Denmark (DK): Street + Number, Postal Code + City, Country (Postal code: 4 digits) 11 | - Egypt (EG): District, City, Governorate, Postal Code, Country (Postal code: 5 digits) 12 | - France (FR): Street Number + Street, Postal Code + City, Country (Postal code: 5 digits) 13 | - Germany (DE): Street + Number, Postal Code + City, Country (Postal code: 5 digits) 14 | - Iceland (IS): Street + Number, Postal Code + City, Country (Postal code: 3 digits) 15 | - India (IN): House/Street, Area, City, State, PIN Code, Country (PIN: 6 digits) 16 | - Israel (IL): Street + Number, City, Postal Code, Country (Postal code: 7 digits) 17 | - Italy (IT): Street, Postal Code, City, Province, Country (Postal code: 5 digits) 18 | - Japan (JP): Postal Code, Prefecture, City, District, Street + Number, Country (Postal code: 7 digits XXX-XXXX) 19 | - Lebanon (LB): District, City, Postal Code, Country (Postal code: 4 digits optional, 8 digits XXXX XXXX) 20 | - Lithuania (LT): Street + Number, City, Postal Code, Country (Postal code: 5 digits LT-XXXXX) 21 | - Netherlands (NL): Street + Number, Postal Code + City, Country (Postal code: 4 digits + 2 letters 1234 AB) 22 | - New Zealand (NZ): Unit/Street Number + Street, Suburb, City, Postcode, Country (Postcode: 4 digits) 23 | - Saudi Arabia (SA): District, City, Postal Code, Country (Postal code: 5 digits) 24 | - Singapore (SG): Block + Street Name, Unit Number, Postal Code, Country (Postal code: 6 digits) 25 | - Slovakia (SK): Street + Number, Postal Code + City, Country (Postal code: 5 digits XXX XX) 26 | - Switzerland (CH): Street, Postal Code, City, Canton, Country (Postal code: 4 digits) 27 | - Thailand (TH): House Number + Street, Subdistrict, District, Province, Postal Code, Country (Postal code: 5 digits) 28 | - United Kingdom (GB): Building/Street, Locality, Town/City, Postcode, Country (Postcode: various formats) 29 | - USA (US): Street + Number, City, State, ZIP Code, Country (ZIP: 5 or 9 digits) 30 | - Vietnam (VN): House Number + Street, Ward, District, City/Province, Postal Code, Country (Postal code: 6 digits) 31 | -------------------------------------------------------------------------------- /public/card/title/becomeDriver.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/atoms/ParagraphCard/ParagraphCard.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import ParagraphCard from './ParagraphCard'; 3 | 4 | describe('ParagraphCard', () => { 5 | const mockImg = { 6 | src: '/card/paragraph/pearTomatoAndFridge.svg', 7 | alt: 'Mock image', 8 | width: 125, 9 | height: 95, 10 | }; 11 | const mockTitle = 'Mock Title'; 12 | const mockText = 'Mock Text'; 13 | const mockLink = '/mock-link'; 14 | 15 | it('renders correctly with variant h2', () => { 16 | const { getByText, getByAltText, getAllByText } = render( 17 | 24 | ); 25 | // the image is rendered with the correct alt text 26 | expect(getByAltText('Mock image')).toBeInTheDocument(); 27 | 28 | // both title elements are rendered with the correct text 29 | const titleElements = getAllByText('Mock Title'); 30 | expect(titleElements.length).toBe(2); // Ensure there are two titles 31 | titleElements.forEach((element) => { 32 | expect(element).toBeInTheDocument(); 33 | expect(element.tagName.toLowerCase()).toBe('h2'); 34 | }); 35 | 36 | // the text is rendered with the correct content 37 | expect(getByText('Mock Text')).toBeInTheDocument(); 38 | 39 | // the learn more button is rendered 40 | const learnMoreButton = getByText('LEARN MORE'); 41 | expect(learnMoreButton).toBeInTheDocument(); 42 | 43 | // the learn more button has the correct link and aria-label 44 | expect(learnMoreButton).toHaveAttribute('href', '/mock-link'); 45 | expect(learnMoreButton).toHaveAttribute('aria-label', 'Mock Title'); 46 | }); 47 | 48 | it('renders correctly with variant h3', () => { 49 | const { getByText, getByAltText } = render( 50 | 57 | ); 58 | // the image is rendered with the correct alt text 59 | expect(getByAltText('Mock image')).toBeInTheDocument(); 60 | 61 | // the title is rendered with the correct text 62 | const titleElements = getByText('Mock Title'); 63 | expect(titleElements).toBeInTheDocument(); 64 | expect(titleElements.tagName.toLowerCase()).toBe('h3'); 65 | 66 | // the text is rendered with the correct content 67 | expect(getByText('Mock Text')).toBeInTheDocument(); 68 | 69 | // the learn more button is rendered 70 | const learnMoreButton = getByText('LEARN MORE'); 71 | expect(learnMoreButton).toBeInTheDocument(); 72 | 73 | // the learn more button has the correct link and aria-label 74 | expect(learnMoreButton).toHaveAttribute('href', '/mock-link'); 75 | expect(learnMoreButton).toHaveAttribute('aria-label', 'Mock Title'); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /public/card/paragraph/plumAndFridge.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/atoms/ParagraphCard/ParagraphCard.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Image from 'next/legacy/image'; 3 | import { typesNextImage } from 'model/view/component/prop-types'; 4 | import { Box, Typography, Card, CardContent, CardActions } from '@mui/material'; 5 | import { ButtonLink } from 'components/atoms'; 6 | 7 | export default function ParagraphCard({ variant, img, title, text, link }) { 8 | if (variant === 'h2') { 9 | return ( 10 | 20 | 21 | {title} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 35 | 39 | {title} 40 | 41 | {text} 42 | 49 | 50 | 51 | ); 52 | } else { 53 | return ( 54 | 61 | 62 | 63 | 64 | 65 | {title} 66 | 67 | {text} 68 | 69 | 70 | 71 | 82 | 83 | 84 | ); 85 | } 86 | } 87 | ParagraphCard.propTypes = { 88 | variant: PropTypes.oneOf(['h2', 'h3']).isRequired, 89 | img: typesNextImage.isRequired, 90 | title: PropTypes.string.isRequired, 91 | text: PropTypes.string.isRequired, 92 | link: PropTypes.string.isRequired, 93 | }; 94 | -------------------------------------------------------------------------------- /src/pages/browse.page.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Head from 'next/head'; 3 | import dynamic from 'next/dynamic'; 4 | 5 | import { 6 | CircularProgress, 7 | Box, 8 | Typography, 9 | Divider, 10 | useMediaQuery, 11 | } from '@mui/material'; 12 | import BrowseList from 'components/organisms/browse/List'; 13 | import { MapToggle } from 'components/atoms/'; 14 | 15 | import { getFridgeList } from 'model/view'; 16 | import { useWindowHeight } from 'lib/browser'; 17 | 18 | const DynamicMap = dynamic( 19 | () => { 20 | return import('../components/organisms/browse/Map'); 21 | }, 22 | { ssr: false } 23 | ); 24 | const BrowseMap = (props) => ; 25 | 26 | const ProgressIndicator = ( 27 |
35 | 36 |
37 | ); 38 | 39 | let fridgeList = null; 40 | export default function BrowsePage() { 41 | const [hasDataLoaded, setHasDataLoaded] = useState(false); 42 | const [currentView, setCurrentView] = useState(MapToggle.view.map); 43 | 44 | const availableHeight = useWindowHeight(); 45 | const isWindowDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')); 46 | 47 | useEffect(() => { 48 | const fetchData = async () => { 49 | fridgeList = await getFridgeList(); 50 | setHasDataLoaded(true); 51 | }; 52 | fetchData().catch(console.error); 53 | }, []); 54 | 55 | const Map = hasDataLoaded 56 | ? BrowseMap({ 57 | fridgeList, 58 | }) 59 | : ProgressIndicator; 60 | 61 | const List = hasDataLoaded ? ( 62 | 63 | ) : ( 64 | ProgressIndicator 65 | ); 66 | 67 | function determineView() { 68 | if (isWindowDesktop) { 69 | return ( 70 | <> 71 | 72 | 73 | FRIDGES WITHIN THIS AREA 74 | 75 | 76 | {List} 77 | 78 | 79 | {Map} 80 | 81 | ); 82 | } else { 83 | return ( 84 | <> 85 | {currentView === MapToggle.view.list ? ( 86 | {List} 87 | ) : ( 88 | {Map} 89 | )} 90 | 91 | 92 | 93 | ); 94 | } 95 | } 96 | 97 | return ( 98 | <> 99 | 100 | Fridge Finder: Geographic Map 101 | 102 | 103 | 104 | {determineView()} 105 | 106 | 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/pages/pamphlet/get-involved/donate-to-a-fridge.page.jsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { PageHero, PamphletParagraph, PageFooter } from 'components/atoms'; 3 | 4 | const pageContent = { 5 | pageHero: { 6 | img: { 7 | src: '/hero/donate_to_a_fridge.webp', 8 | alt: 'Volunteer hosting a bake sale to raise money for a community fridge', 9 | }, 10 | }, 11 | content: [ 12 | { 13 | variant: 'h1', 14 | title: 'Donate to a fridge', 15 | body: ['Give your time, food, or funds to make a big impact!'], 16 | }, 17 | { 18 | variant: 'h2', 19 | title: 'When donating time', 20 | body: [ 21 | 'Most fridges are accessible 24/7, and these locations can use your support. Every community fridge has their own volunteer process, which can be found by contacting that fridge individually. As a general principle, we encourage everyone investing time into this project to treat others with kindness and respect.', 22 | ], 23 | button: { 24 | title: 'Volunteer', 25 | to: { 26 | pathname: '/user/contact', 27 | query: { subject: 'Volunteer Interest' }, 28 | }, 29 | 'aria-label': 'Volunteer', 30 | variant: 'contained', 31 | }, 32 | }, 33 | { 34 | variant: 'h2', 35 | title: 'When donating food', 36 | body: [ 37 | 'Food should be great quality, fresh, and sealed in airtight containers. Label donated meals with ingredients and date. Do not leave items outside of the fridges. Before donating, read our best practices.', 38 | ], 39 | button: { 40 | title: 'Best Practices', 41 | to: '/pamphlet/best-practices', 42 | 'aria-label': 'Best Practices', 43 | variant: 'contained', 44 | }, 45 | }, 46 | { 47 | variant: 'h2', 48 | title: 'When donating funds', 49 | body: [ 50 | 'Fridges should be cleaned daily. If you see trash at a fridge location, take the trash with you to dispose of it properly. If the fridge is in need of a repair, you can alert our community on Fridge Finder by submitting a status report for that fridge. Do you have repair skills that can fix broken refrigerators? Can you help build fridge shelters? Contact us.', 51 | ], 52 | button: { 53 | title: 'Donate!', 54 | to: 'https://www.gofundme.com/f/hub-holiday2', 55 | 'aria-label': 'Donate funds to Fridge Finder!', 56 | variant: 'contained', 57 | }, 58 | }, 59 | ], 60 | }; 61 | 62 | export default function DonateToAFridgePage() { 63 | const { pageHero, content } = pageContent; 64 | return ( 65 | <> 66 | 67 | Fridge Finder: Donate to a fridge 68 | 69 | 70 | 71 | 72 | {content.map((paragraph, index) => ( 73 | 0} 77 | /> 78 | ))} 79 | 80 | 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/components/organisms/browse/Map.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { MapContainer, TileLayer, useMap } from 'react-leaflet'; 3 | import typesView from 'model/view/prop-types'; 4 | 5 | import { deltaInMeters } from 'lib/geo.js'; 6 | 7 | import LegendDrawer from './components/LegendDrawer'; 8 | import MapMarkerList from './components/MapMarkerList'; 9 | import markersFrom from './model/markersFrom'; 10 | 11 | const fridgePaperBoyLoveGallery = [40.697759, -73.927282]; 12 | const defaultZoom = 13.2; 13 | const defaultMapCenter = fridgePaperBoyLoveGallery; 14 | 15 | const lookupNearestFridge = (userLocation, fridgeList) => { 16 | fridgeList.map((fridge, index) => { 17 | const location = fridge.location; 18 | const { geoLat, geoLng } = location; 19 | const dist = deltaInMeters( 20 | [userLocation.lat, userLocation.lng], 21 | [geoLat, geoLng] 22 | ); 23 | fridgeList[index].distFromUser = dist; 24 | fridgeList.sort((a, b) => { 25 | return a.distFromUser - b.distFromUser; 26 | }); 27 | }); 28 | return fridgeList[0]; 29 | }; 30 | 31 | function UpdateCenter({ fridgeList }) { 32 | const pixelRadius = 1000; 33 | 34 | const maxUserToDefaultCenterMeters = 200000; 35 | const map = useMap(); 36 | map.locate().on('locationfound', (e) => { 37 | const userPosition = e.latlng; 38 | const userToDefaultCenterMeters = deltaInMeters(defaultMapCenter, [ 39 | userPosition.lat, 40 | userPosition.lng, 41 | ]); 42 | 43 | if (userToDefaultCenterMeters <= maxUserToDefaultCenterMeters) { 44 | // Zoom level adjusted by 1/2 a level for each 50 KM 45 | const zoomAdjustment = 46 | Math.ceil(userToDefaultCenterMeters / 1000 / 50) * 0.5; 47 | map.flyTo(userPosition, defaultZoom - zoomAdjustment); 48 | } else { 49 | const nearestFridge = lookupNearestFridge(userPosition, fridgeList); 50 | const { geoLat, geoLng } = nearestFridge.location; 51 | const fridgeLatLng = { 52 | lat: geoLat, 53 | lng: geoLng, 54 | }; 55 | const userFridgeBBox = L.latLngBounds(userPosition, fridgeLatLng); 56 | map.flyToBounds(userFridgeBBox); 57 | } 58 | L.circleMarker(userPosition, pixelRadius).addTo(map); 59 | }); 60 | } 61 | 62 | export default function Map({ fridgeList }) { 63 | return ( 64 | <> 65 | 72 | 73 | 79 | 80 | 81 | 82 | 83 | ); 84 | } 85 | Map.propTypes = { 86 | fridgeList: PropTypes.arrayOf(typesView.Fridge).isRequired, 87 | }; 88 | -------------------------------------------------------------------------------- /src/components/atoms/ButtonLink/ButtonLink.test.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { render, screen } from '@testing-library/react'; 3 | import ButtonLink from './ButtonLink'; 4 | 5 | describe('ButtonLink', () => { 6 | let consoleErrorSpy; 7 | 8 | beforeAll(() => { 9 | consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 10 | }); 11 | 12 | afterEach(() => { 13 | consoleErrorSpy.mockClear(); 14 | }); 15 | 16 | afterAll(() => { 17 | consoleErrorSpy.mockRestore(); 18 | }); 19 | 20 | const checkProps = (props) => 21 | PropTypes.checkPropTypes(ButtonLink.propTypes, props, 'prop', 'ButtonLink'); 22 | 23 | it('renders correctly with valid props', () => { 24 | render( 25 | 31 | ); 32 | const button = screen.getByRole('link', { name: 'View fridge status' }); 33 | expect(button).toHaveTextContent('GO TO FRIDGE'); 34 | expect(button).toHaveAttribute('href', '/fridge/test-fridge-123'); 35 | expect(button).toHaveAttribute('aria-label', 'View fridge status'); 36 | expect(button).toHaveClass('MuiButton-contained'); 37 | }); 38 | 39 | it('does not warn given all required props', () => { 40 | checkProps({ 41 | title: 'GO TO FRIDGE', 42 | to: '/about', 43 | 'aria-label': 'Go to About', 44 | variant: 'contained', 45 | }); 46 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 47 | }); 48 | 49 | it('warns if required props are missing', () => { 50 | checkProps({}); 51 | expect(consoleErrorSpy).toHaveBeenCalled(); 52 | 53 | const message = consoleErrorSpy.mock.calls.flat().join('\n'); 54 | expect(message).toMatch('The prop `title` is marked as required'); 55 | expect(message).toMatch('The prop `to` is marked as required'); 56 | expect(message).toMatch('The prop `aria-label` is marked as required'); 57 | expect(message).toMatch('The prop `variant` is marked as required'); 58 | }); 59 | 60 | it('warns if `to` prop is missing the `query` key', () => { 61 | checkProps({ 62 | title: 'GO TO FRIDGE', 63 | to: { pathname: '/only-pathname' }, // missing `query` 64 | 'aria-label': 'Broken link', 65 | variant: 'contained', 66 | }); 67 | expect(consoleErrorSpy).toHaveBeenCalled(); 68 | 69 | const message = consoleErrorSpy.mock.calls.flat().join('\n'); 70 | expect(message).toMatch('Invalid prop `to` supplied to `ButtonLink`'); 71 | }); 72 | 73 | it('warns if `variant` prop has an invalid enum value', () => { 74 | checkProps({ 75 | title: 'GO TO FRIDGE', 76 | to: '/about', 77 | 'aria-label': 'Go to About', 78 | variant: 'invalid-variant', // invalid value 79 | }); 80 | expect(consoleErrorSpy).toHaveBeenCalled(); 81 | 82 | const message = consoleErrorSpy.mock.calls.flat().join('\n'); 83 | expect(message).toMatch( 84 | 'Invalid prop `variant` of value `invalid-variant` supplied to `ButtonLink`' 85 | ); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/pages/pamphlet/get-involved.page.jsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { Grid } from '@mui/material'; 3 | import { 4 | PageFooter, 5 | PageHero, 6 | PamphletParagraph, 7 | TitleCard, 8 | } from 'components/atoms'; 9 | 10 | const pageContent = { 11 | pageHero: { 12 | img: { 13 | src: '/hero/get-involved.webp', 14 | alt: 'Volunteers in front of a community fridge', 15 | }, 16 | }, 17 | introParagraph: { 18 | title: 'Get Involved!', 19 | body: ['There are many ways to support the future of the fridges.'], 20 | variant: 'h1', 21 | }, 22 | titleCards: [ 23 | { 24 | title: 'Start A Fridge', 25 | link: '/pamphlet/get-involved/start-a-fridge', 26 | img: { 27 | src: '/card/title/startFridge.svg', 28 | alt: 'A smiling fridge', 29 | }, 30 | }, 31 | { 32 | title: 'Become A Driver', 33 | link: '/pamphlet/get-involved/become-a-driver', 34 | img: { 35 | src: '/card/title/becomeDriver.svg', 36 | alt: 'A car with a smiling face', 37 | }, 38 | }, 39 | { 40 | title: 'Donate To A Fridge', 41 | link: '/pamphlet/get-involved/donate-to-a-fridge', 42 | img: { 43 | src: '/card/title/donate.svg', 44 | alt: 'A smiling piggy bank with a coin being inserted', 45 | }, 46 | }, 47 | { 48 | title: 'Source Food', 49 | link: '/pamphlet/get-involved/source-food', 50 | img: { 51 | src: '/card/title/sourceFood.svg', 52 | alt: 'A smiling bell pepper, tomato, and broccoli', 53 | }, 54 | }, 55 | { 56 | title: 'Service Fridges', 57 | link: '/pamphlet/get-involved/service-fridges', 58 | img: { 59 | src: '/card/title/serviceFridge.svg', 60 | alt: 'A smiling wrench and screwdriver', 61 | }, 62 | }, 63 | { 64 | title: 'Join A Community Group', 65 | link: '/pamphlet/get-involved/join-a-community-group', 66 | img: { 67 | src: '/card/title/joinCommunity.svg', 68 | alt: 'Four hands coming together with a smiling heart in the center', 69 | }, 70 | }, 71 | ], 72 | }; 73 | 74 | export default function GetInvolvedPage() { 75 | const { pageHero, introParagraph, titleCards } = pageContent; 76 | return ( 77 | <> 78 | 79 | Fridge Finder: Get Involved 80 | 81 | 82 | 83 | 84 | {titleCards.map((card, index) => ( 85 | 94 | 95 | 96 | ))} 97 | 98 | 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /etl/src/3_load/create-fridges.mjs: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import http from 'http'; 3 | import https from 'https'; 4 | const { randomInt } = await import('crypto'); 5 | 6 | const localHost = '127.0.0.1'; 7 | const localPort = 3000; 8 | 9 | const awsHost = 'api-dev.communityfridgefinder.com'; 10 | const awsPort = 443; 11 | 12 | const localGetOptions = { 13 | hostname: localHost, 14 | port: localPort, 15 | path: '/v1/fridges', 16 | method: 'GET', 17 | }; 18 | 19 | const awsPostOptions = { 20 | hostname: awsHost, 21 | port: awsPort, 22 | method: 'POST', 23 | }; 24 | 25 | main(); 26 | 27 | function main() { 28 | const req = http.request(localGetOptions, (res) => { 29 | let body = ''; 30 | 31 | res.on('data', (chunk) => (body += chunk)); 32 | res.on('end', () => { 33 | try { 34 | let json = JSON.parse(body); 35 | createFridge(json[5]); 36 | // for (let fridge of json) { 37 | // createReportFor(fridge); 38 | // } 39 | } catch (error) { 40 | console.error(error); 41 | } 42 | }); 43 | }); 44 | 45 | req.on('error', (error) => { 46 | console.error(error); 47 | }); 48 | 49 | req.end(); 50 | } 51 | 52 | function createFridge(fridge) { 53 | const postReq = awsPostTo('/v1/fridges', fridge, 'name'); 54 | postReq.on('close', () => createReportFor(fridge)); 55 | postReq.end(); 56 | } 57 | 58 | function createReportFor({ id }) { 59 | // 1/10 chance of no report 60 | if (oneIn(10)) { 61 | return; 62 | } 63 | const report = { 64 | timestamp: faker.date.recent(), 65 | condition: oneIn(50) 66 | ? 'not at location' 67 | : faker.helpers.arrayElement(['good', 'dirty', 'out of order']), 68 | foodPercentage: faker.helpers.arrayElement([0, 33, 67, 100]), 69 | photoUrl: '/card/paragraph/pearTomatoAndFridge.svg', 70 | notes: faker.lorem.lines(1), 71 | }; 72 | 73 | const postReq = awsPostTo(`/v1/fridges/${id}/reports`, report); 74 | postReq.end(); 75 | } 76 | 77 | const oneIn = (n) => randomInt(n) === 0; 78 | 79 | function awsPostTo(path, json, displayKey = null) { 80 | const postData = JSON.stringify(json); 81 | awsPostOptions.path = path; 82 | awsPostOptions.headers = { 83 | 'Content-Type': 'application/json', 84 | 'Content-Length': Buffer.byteLength(postData), 85 | }; 86 | 87 | const responseClosure = (response) => { 88 | const displayText = displayKey ? ' -- ' + json[displayKey] : ''; 89 | console.log( 90 | `${response.statusCode} POST ${response.req.path}${displayText}` 91 | ); 92 | 93 | let body = ''; 94 | response.on('data', (chunk) => (body += chunk)); 95 | response.on('end', () => { 96 | if (response.statusCode != 999) { 97 | console.log(response.statusMessage, JSON.parse(body), json, '\n---'); 98 | } 99 | }); 100 | }; 101 | 102 | const req = https.request(awsPostOptions, responseClosure); 103 | req.on('error', (error) => { 104 | console.error('Request error ' + error.message); 105 | }); 106 | 107 | req.write(postData); 108 | return req; 109 | } 110 | -------------------------------------------------------------------------------- /src/components/atoms/PamphletParagraph/PamphletParagraph.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Image from 'next/image'; 3 | import { Box, Divider, Typography } from '@mui/material'; 4 | import { ButtonLink, SoftWrap } from 'components/atoms'; 5 | import { applyAlpha, designColor } from 'theme/palette'; 6 | 7 | const DividerGrey = () => ( 8 | 9 | ); 10 | 11 | function ResponsiveImage({ src, alt = '', attribution = null }) { 12 | if (!src) { 13 | return null; 14 | } 15 | return ( 16 | 17 | 25 | {alt} 32 | 33 | {attribution && ( 34 | 44 | {'Photo: ' + attribution} 45 | 46 | )} 47 | 48 | ); 49 | } 50 | const typesResponsiveImage = { 51 | src: PropTypes.string.isRequired, 52 | alt: PropTypes.string, 53 | attribution: PropTypes.string, 54 | }; 55 | ResponsiveImage.propTypes = typesResponsiveImage; 56 | 57 | export default function PamphletParagraph({ 58 | title, 59 | variant, 60 | img, 61 | body, 62 | button, 63 | hasDivider = false, 64 | sx = {}, 65 | }) { 66 | return ( 67 | 68 | {hasDivider && } 69 | 70 | {img && ResponsiveImage(img)} 71 | 72 | 73 | 74 | 75 | 76 | {body && 77 | body.map((val, index) => ( 78 | 79 | {val} 80 | 81 | ))} 82 | 83 | {button && ( 84 | 85 | 92 | 93 | )} 94 | 95 | ); 96 | } 97 | PamphletParagraph.propTypes = { 98 | title: PropTypes.string.isRequired, 99 | variant: PropTypes.oneOf(['h1', 'h2', 'h3']).isRequired, 100 | img: PropTypes.exact(typesResponsiveImage), 101 | body: PropTypes.arrayOf(PropTypes.string), 102 | button: PropTypes.shape(ButtonLink.propTypes), 103 | hasDivider: PropTypes.bool, 104 | sx: PropTypes.object, 105 | }; 106 | 107 | const sxParagraphMargin = { 108 | mb: 7, 109 | mx: { xs: 10, lg: 15, xl: 20 }, 110 | }; 111 | -------------------------------------------------------------------------------- /src/components/organisms/dialog/components/PanelMaintainer.jsx: -------------------------------------------------------------------------------- 1 | import { useFormik } from 'formik'; 2 | import { 3 | Button, 4 | Stack, 5 | StepContent, 6 | StepLabel, 7 | TextField, 8 | } from '@mui/material'; 9 | import typesValidation from './prop-types'; 10 | 11 | export default function PanelMaintainer(props) { 12 | const formik = useFormik({ 13 | initialValues: { 14 | fullName: '', 15 | organization: '', 16 | phoneNumber: '', 17 | email: '', 18 | website: '', 19 | instagram: '', 20 | }, 21 | onSubmit: (values) => { 22 | alert(JSON.stringify(values, null, 2)); 23 | }, 24 | }); 25 | return ( 26 | <> 27 | Maintainer Contact Information 28 | 29 | 30 | 37 | 44 | 51 | 58 | 65 | 72 | 78 | 86 | 94 | 95 | 96 | 97 | 98 | ); 99 | } 100 | PanelMaintainer.propTypes = typesValidation.Panel; 101 | -------------------------------------------------------------------------------- /src/pages/_document.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { renderToStaticMarkup } from 'react-dom/server'; 4 | import path from 'node:path'; 5 | 6 | // Mock Next.js Document and its subcomponents 7 | jest.mock('next/document', () => { 8 | // Stub Document class with a no-op getInitialProps 9 | class Document extends React.Component {} 10 | Document.getInitialProps = async () => ({ 11 | html: '
', 12 | head: [], 13 | styles: [], 14 | }); 15 | 16 | // Stub Html, Head, Main, NextScript to avoid internal hooks 17 | function Html({ children }) { 18 | return React.createElement('html', {}, children); 19 | } 20 | Html.propTypes = { 21 | children: PropTypes.node, 22 | }; 23 | 24 | function Head({ children }) { 25 | return React.createElement('head', {}, children); 26 | } 27 | Head.propTypes = { 28 | children: PropTypes.node, 29 | }; 30 | 31 | function Main() { 32 | return React.createElement('div', { id: '__next' }); 33 | } 34 | function NextScript() { 35 | return null; 36 | } 37 | 38 | return { 39 | __esModule: true, 40 | default: Document, 41 | Html, 42 | Head, 43 | Main, 44 | NextScript, 45 | }; 46 | }); 47 | 48 | // Mock Emotion SSR to skip real CSS extraction 49 | jest.mock('@emotion/server/create-instance', () => () => ({ 50 | extractCriticalToChunks: () => ({ styles: [] }), 51 | })); 52 | 53 | describe('Google Analytics injection into _document', () => { 54 | const ORIGINAL_ENV = Object.freeze(process.env); 55 | 56 | beforeEach(() => { 57 | jest.resetModules(); 58 | process.env = { ...ORIGINAL_ENV }; 59 | }); 60 | 61 | afterAll(() => { 62 | process.env = ORIGINAL_ENV; 63 | }); 64 | 65 | // Render _document.page.js with the stubbed getInitialProps 66 | async function renderDocument() { 67 | const MyDocument = require( 68 | path.resolve(__dirname, '../pages/_document.page.js') 69 | ).default; 70 | 71 | const initialProps = await MyDocument.getInitialProps({ 72 | renderPage: () => ({}), 73 | }); 74 | 75 | return renderToStaticMarkup(); 76 | } 77 | 78 | it('injects the production NEXT_PUBLIC_ANALYTICS_ID', async () => { 79 | process.env.NODE_ENV = 'production'; 80 | process.env.NEXT_PUBLIC_ANALYTICS_ID = 'PROD-123'; 81 | 82 | const html = await renderDocument(); 83 | expect(html).toContain( 84 | 'https://www.googletagmanager.com/gtag/js?id=PROD-123' 85 | ); 86 | expect(html).toContain(`gtag('config', 'PROD-123'`); 87 | }); 88 | 89 | it('injects the development NEXT_PUBLIC_ANALYTICS_ID', async () => { 90 | process.env.NODE_ENV = 'development'; 91 | process.env.NEXT_PUBLIC_ANALYTICS_ID = 'DEV-456'; 92 | 93 | const html = await renderDocument(); 94 | expect(html).toContain( 95 | 'https://www.googletagmanager.com/gtag/js?id=DEV-456' 96 | ); 97 | expect(html).toContain(`gtag('config', 'DEV-456'`); 98 | }); 99 | 100 | it('omits the GA script when googleAnalytics.TRACKING_ID is missing', async () => { 101 | process.env.NODE_ENV = 'production'; 102 | delete process.env.NEXT_PUBLIC_ANALYTICS_ID; 103 | 104 | const html = await renderDocument(); 105 | expect(html).not.toContain('googletagmanager.com/gtag/js?id='); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/pages/pamphlet/get-involved/join-a-community-group.page.jsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { PageHero, PamphletParagraph, PageFooter } from 'components/atoms'; 3 | 4 | const pageContent = { 5 | pageHero: { 6 | img: { 7 | src: '/hero/join_a_community_group.webp', 8 | alt: 'Volunteers resting', 9 | }, 10 | }, 11 | content: [ 12 | { 13 | title: 'Join a community group', 14 | variant: 'h1', 15 | body: [ 16 | 'The fridges near you most likely needs help cleaning and sourcing food. Anyone can participate in running community fridges. You are welcome to organize your own initiatives because operations for community fridges are decentralized and autonomous.', 17 | 'On Fridge Finder, you can find a fridge near you, connect with the location, and share status updates. To collaborate with us behind the scenes, join our engineering or outreach teams.', 18 | ], 19 | button: { 20 | title: 'Volunteer', 21 | to: { 22 | pathname: '/user/contact', 23 | query: { subject: 'Volunteer Interest' }, 24 | }, 25 | 'aria-label': 'Volunteer', 26 | variant: 'contained', 27 | }, 28 | }, 29 | { 30 | variant: 'h2', 31 | title: 'Community groups we recommend', 32 | body: [ 33 | 'There are groups across New York City that source donations and maintenance support for fridges. This is essential to keeping fridges active.', 34 | 'The following organizations have initiatives to support community fridges. To participate, contact the groups that interest you.', 35 | ], 36 | }, 37 | ], 38 | }; 39 | const organizations = [ 40 | { 41 | name: 'Artists.Athletes.Activists', 42 | url: 'https://artists-athletes-activists.org/', 43 | }, 44 | { name: 'Black Chef Movement', url: 'https://blackchefmovement.org/' }, 45 | { 46 | name: 'Black Voices Matter', 47 | url: 'https://www.blackvoicesmatterpledge.org/', 48 | }, 49 | { name: 'Bushwick Ayuda Mutua ', url: 'https://bushwickayudamutua.com/' }, 50 | { 51 | name: 'Collective Focus Resource Hub', 52 | url: 'https://collectivefocus.site/', 53 | }, 54 | { name: 'Freedge', url: 'https://freedge.org/' }, 55 | { name: 'Nuestra Mesa Brooklyn', url: 'https://www.nuestramesabk.com/' }, 56 | { 57 | name: 'One Love Community Fridge', 58 | url: 'https://www.onelovecommunityfridge.org/', 59 | }, 60 | { name: 'Stuff4Good', url: 'https://officialgoodstuff.com/stuff4good/' }, 61 | { name: 'Universe City', url: 'https://www.universecity.nyc/' }, 62 | { name: 'Woodbine Mutual Aid', url: 'https://www.woodbine.nyc/mutualaid/' }, 63 | ]; 64 | 65 | export default function JoinACommunityGroupPage() { 66 | const { pageHero, content } = pageContent; 67 | return ( 68 | <> 69 | 70 | Fridge Finder: Join a community group 71 | 72 | 73 | 74 | {content.map((paragraph, index) => ( 75 | 0} 79 | /> 80 | ))} 81 |
90 | 91 | 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

Fridge Finder

6 |

7 | 8 |

9 | 10 | 11 | 12 | GitHub contributors 13 | Build Status 14 | 15 | 16 | 17 |

18 | 19 | A community fridge is a decentralized resource where businesses and individuals can [donate perishable food](https://www.thrillist.com/lifestyle/new-york/nyc-community-fridges-how-to-support). There are dozens of fridges hosted by volunteers across the country. The Fridge Finder website is available at [fridgefinder.app](https://fridgefinder.app/) 20 | 21 | Fridge Finder is based out of Brooklyn, New York. Our goal is to make it easy for people to find fridge locations and get involved with food donation programs in their community. We are building a responsive, mobile first, web application with administrative tools for fridge maintainers. To join the project read our [contributing guidelines](./CONTRIBUTING.md) and [code of conduct](./CODE_OF_CONDUCT.md). The software architecture is documented in the [programmer reference](./architecture-reference.md) document. 22 | 23 |

Made possible by contributions from these lovely people …

24 |

25 | 26 | 27 | 28 |

29 |

❤ Thank you for all your hard work

30 | 31 | ## System Requirements 32 | 33 | 1. [Node](https://nodejs.org/en/) 34 | 35 | ## System Setup 36 | 37 | 1. Verify your system meets the requirements 38 | 39 | ```bash 40 | node --version # must be >= 22.20.0 41 | ``` 42 | 43 | 1. Install global dependencies 44 | 45 | ```bash 46 | npm install --global yarn svgo lint-staged concurrently 47 | corepack enable # for yarn 48 | ``` 49 | 50 | 1. Setup the frontend environment 51 | 52 | ```bash 53 | git clone https://github.com/FridgeFinder/CFM_Frontend frontend 54 | cd frontend 55 | git checkout dev 56 | yarn install 57 | ``` 58 | 59 | 1. Run the unit tests 60 | 61 | ```bash 62 | yarn test 63 | ``` 64 | 65 | 1. Run the application locally 66 | 67 | ```bash 68 | # to run both development database and the web server 69 | yarn dev 70 | 71 | # to run only the web server on port 3000 72 | yarn web 73 | 74 | # to run only the database server on port 3050 75 | yarn data 76 | ``` 77 | 78 | in a different terminal window 79 | 80 | ```bash 81 | start "Google Chrome" http://localhost:3000/ # Windows 82 | open -a "Google Chrome" http://localhost:3000/ # MacOS 83 | ``` 84 | -------------------------------------------------------------------------------- /public/card/paragraph/apple.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/card/title/serviceFridge.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/model/view/index.js: -------------------------------------------------------------------------------- 1 | import { ValuesFridge, ValuesReport } from 'model/data/fridge/yup/index.js'; 2 | 3 | /** 4 | * An in-memory cache array that stores the list of fridge records. 5 | * This array is used to maintain a cached collection of fridge data to avoid unnecessary API calls. 6 | * The array elements are sorted by fridge name in ascending order. 7 | * @private 8 | */ 9 | const cacheViewFridgeList = []; 10 | 11 | /** 12 | * Lookup dictionary mapping fridge id to the cached fridge record stored in cacheViewFridgeList 13 | * @private 14 | */ 15 | const viewFridgeFor = {}; 16 | 17 | export async function getFridgeList() { 18 | if (cacheViewFridgeList.length === 0) { 19 | await fetchAllData(); 20 | } 21 | return cacheViewFridgeList; 22 | } 23 | 24 | const sortByNameAsc = (a, b) => { 25 | const nameA = a.name; 26 | const nameB = b.name; 27 | if (nameA < nameB) { 28 | return -1; 29 | } 30 | if (nameA > nameB) { 31 | return 1; 32 | } 33 | return 0; 34 | }; 35 | 36 | const castOptions = Object.freeze({ stripUnknown: true }); // yup configuration 37 | 38 | function viewFridgeFromLocal(apiFridge) { 39 | const viewFridge = ValuesFridge.cast(apiFridge, castOptions); 40 | viewFridge['report'] = null; 41 | return viewFridge; 42 | } 43 | 44 | function viewFridgeFromRemote(apiFridge) { 45 | const viewFridge = ValuesFridge.cast(apiFridge, castOptions); 46 | if (apiFridge.latestFridgeReport) { 47 | viewFridge['report'] = Object.freeze( 48 | ValuesReport.cast(apiFridge.latestFridgeReport, castOptions) 49 | ); 50 | } else { 51 | viewFridge['report'] = null; 52 | } 53 | return viewFridge; 54 | } 55 | 56 | function loadIntoCache(fridges, fnConverter) { 57 | cacheViewFridgeList.length = fridges.length; 58 | 59 | for (let n = 0; n < fridges.length; n++) { 60 | const currentFridge = fridges[n]; 61 | 62 | viewFridgeFor[currentFridge.id] = cacheViewFridgeList[n] = 63 | fnConverter(currentFridge); 64 | } 65 | 66 | cacheViewFridgeList.sort(sortByNameAsc); 67 | } 68 | 69 | function mergeIntoCache(reports) { 70 | for (const currentReport of reports) { 71 | viewFridgeFor[currentReport.fridgeId].report = Object.freeze( 72 | ValuesReport.cast(currentReport, castOptions) 73 | ); 74 | } 75 | } 76 | 77 | const apiFridges = `${process.env.NEXT_PUBLIC_FF_API_URL}/v1/fridges/`; 78 | const apiReports = `${process.env.NEXT_PUBLIC_FF_API_URL}/v1/reports/`; 79 | const apiHeader = { headers: { Accept: 'application/json' } }; 80 | 81 | async function fetchAllData() { 82 | if (process.env.NEXT_PUBLIC_FLAG_useLocalDatabase) { 83 | await fetchAllLocalData(); 84 | } else { 85 | await fetchAllServerData(); 86 | } 87 | } 88 | 89 | async function fetchAllServerData() { 90 | try { 91 | const response = await fetch(apiFridges, apiHeader); 92 | const fridges = await response.json(); 93 | return loadIntoCache(fridges, viewFridgeFromRemote); 94 | } catch (error) { 95 | return console.error(error); 96 | } 97 | } 98 | 99 | async function fetchAllLocalData() { 100 | try { 101 | const fridgesResponse = await fetch(apiFridges, apiHeader); 102 | const fridges = await fridgesResponse.json(); 103 | loadIntoCache(fridges, viewFridgeFromLocal); 104 | } catch (error) { 105 | console.error('Failed to fetch fridges:', error); 106 | return; 107 | } 108 | 109 | try { 110 | const reportsResponse = await fetch(apiReports, apiHeader); 111 | const reports = await reportsResponse.json(); 112 | mergeIntoCache(reports); 113 | } catch (error) { 114 | console.error('Failed to fetch reports:', error); 115 | return; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /public/card/title/joinCommunity.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/architecture-reference.md: -------------------------------------------------------------------------------- 1 | # Architecture Reference 2 | 3 | - [Architecture decisions](./architecture-decisions.md) 4 | - [REST API Contract](../src/model/data/fridge/REST.yaml) 5 | 6 | ## Development environments 7 | 8 | - Production (deployed from main branch): https://fridgefinder.app/ 9 | - Staging (deployed from dev branch): https://dev.fridgefinder.app/ 10 | 11 | ## UI Design 12 | 13 | ### Images 14 | 15 | - [Aspect Ratio Guide](https://www.cronyxdigital.com/blog/the-ultimate-website-image-guide) 16 | 17 | #### aspect ratio (width:height) 18 | 19 | hero image 20 | : aspect ratio is 16:9, preferred size 1366x768 21 | 22 | paragraph image 23 | : aspect ratio for mobile is 3:2, preferred size 414x276 24 | 25 | fridge photo 26 | : aspect ratio is 1:1.15, exact size 300x345 27 | 28 | ##### screen size ranges (width x height) 29 | 30 | - Mobile: 360 x 640 to 414 x 896 pixels 31 | - Tablet: 601 x 962 to 1280 x 800 pixels 32 | - Desktop: 1280 x 720 to 1920 x 1080 pixels 33 | 34 | ### MUI breakpoints (width) 35 | 36 | - xs, extra-small: 0px 37 | - sm, small: 600px 38 | - md, medium: 900px 39 | - lg, large: 1200px 40 | - xl, extra-large: 1536px 41 | 42 | ## Tools 43 | 44 | ### HTML Color 45 | 46 | - [Color Hex to RGBA converter](https://bl.ocks.org/njvack/02ad8efcb0d552b0230d) 47 | 48 | ### Image Editor 49 | 50 | - [XnView](https://www.xnview.com/en/) 51 | 52 | **XnView:** 53 | 54 | 1. type in the width of 410 55 | 2. then select the aspect ratio of 3:2 56 | 3. the height should read 276 57 | 4. move the target around and crop 58 | 59 | ### REST API 60 | 61 | - [API Editor](https://editor-next.swagger.io/) 62 | 63 | ### URL Encode 64 | 65 | - [Encode SVG as URL](https://yoksel.github.io/url-encoder/) 66 | - [URL encoder/decoder](https://meyerweb.com/eric/tools/dencoder/) 67 | 68 | ### Web Analytics 69 | 70 | - [Google Analytics Debugger](https://chrome.google.com/webstore/detail/google-analytics-debugger/jnkmfdileelhofjcijamephohjechhna) 71 | 72 | ## Frontend Libraries 73 | 74 | - Application Framework: [Next.js](https://nextjs.org/docs/) 75 | - [Next.js tutorial](https://nextjs.org/learn) 76 | - [Next.js repository](https://github.com/vercel/next.js/) 77 | 78 | - Type Checking: [prop-types](https://github.com/facebook/prop-types) 79 | - [prop-types tutorial](https://blog.logrocket.com/validating-react-component-props-with-prop-types-ef14b29963fc/) 80 | 81 | - UI Components: [MUI](https://mui.com/material-ui/) 82 | 83 | - UI Dialogs: [Formik](https://formik.org/docs/overview) 84 | - [Formik tutorial](https://formik.org/docs/tutorial) 85 | 86 | - UI Dialog Validation: [Yup](https://github.com/jquense/yup) 87 | 88 | - Geographical Maps: [Leaflet](https://leafletjs.com/), [React Leaflet](https://react-leaflet.js.org/) 89 | - [Leaflet quick start tutorial](https://leafletjs.com/examples/quick-start/) 90 | - [Leaflet mobile tutorial](https://leafletjs.com/examples/mobile/) 91 | - [Leaflet custom markers tutorial](https://leafletjs.com/examples/) 92 | 93 | - REST API: [OpenAPI 3.0](https://swagger.io/docs/specification/about/) 94 | - [API Primer](https://restfulapi.net/) 95 | - [API Design Best Practices](https://docs.microsoft.com/en-us/azure/architecture/best-practices/api-design) 96 | 97 | - REST Mock Server: [json-server](https://github.com/typicode/json-server) 98 | 99 | - Testing: [Jest](https://jestjs.io/docs/api), [React Testing Library](https://testing-library.com/docs/) 100 | 101 | - State Management: [zustand](https://zustand.docs.pmnd.rs/getting-started/introduction) 102 | 103 | - Fuzzy Search: [fuze.js](https://www.fusejs.io/) 104 | 105 | ## Design philosophy 106 | 107 | - [Atomic Design](https://atomicdesign.bradfrost.com/table-of-contents/) 108 | -------------------------------------------------------------------------------- /src/pages/_document.page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 3 | import createEmotionServer from '@emotion/server/create-instance'; 4 | import createEmotionCache from 'lib/createEmotionCache'; 5 | import googleAnalytics from 'lib/analytics'; 6 | 7 | export default class MyDocument extends Document { 8 | render() { 9 | return ( 10 | 11 | 12 | {googleAnalytics.TRACKING_ID && ( 13 | <> 14 |