├── .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 |
13 | {title}
14 |
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 | :
14 | }
15 | sx={{
16 | position: 'fixed',
17 | bottom: 0,
18 | zIndex: 999,
19 | height: 60,
20 | backgroundColor: '#fff',
21 | border: 'none',
22 | borderRadius: 3,
23 | borderBottomLeftRadius: 0,
24 | borderBottomRightRadius: 0,
25 | boxShadow: '-2px 0px 4px rgb(0 0 0 / 20%)',
26 | justifyContent: 'left',
27 | padding: 5,
28 | fontWeight: 500,
29 | textTransform: 'none',
30 | ':hover': { backgroundColor: '#fff' },
31 | }}
32 | onClick={() =>
33 | setView(
34 | currentView === MapToggle.view.map
35 | ? MapToggle.view.list
36 | : MapToggle.view.map
37 | )
38 | }
39 | >
40 | {currentView === MapToggle.view.map ? 'List View' : 'Map View'}
41 |
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 |
37 | {buttonTitle}
38 |
39 |
45 | Cancel
46 |
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 | {item}
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 |
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 |
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 |
84 | Continue
85 |
86 |
92 | Back
93 |
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 |
82 | {organizations.map(({ name, url }, index) => (
83 |
84 |
85 | {name}
86 |
87 |
88 | ))}
89 |
90 |
91 |
92 | >
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Fridge Finder
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
18 |
30 | >
31 | )}
32 |
36 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 | }
49 |
50 | // Reuse a single Emotion cache across SSR to speed up performance.
51 | // Disable if you run into rendering issues.
52 | const ssrEmotionCache = createEmotionCache();
53 |
54 | MyDocument.getInitialProps = async (ctx) => {
55 | // Resolution order
56 | //
57 | // On the server:
58 | // 1. app.getInitialProps
59 | // 2. page.getInitialProps
60 | // 3. document.getInitialProps
61 | // 4. app.render
62 | // 5. page.render
63 | // 6. document.render
64 | //
65 | // On the server with error:
66 | // 1. document.getInitialProps
67 | // 2. app.render
68 | // 3. page.render
69 | // 4. document.render
70 | //
71 | // On the client
72 | // 1. app.getInitialProps
73 | // 2. page.getInitialProps
74 | // 3. app.render
75 | // 4. page.render
76 |
77 | const originalRenderPage = ctx.renderPage;
78 | const { extractCriticalToChunks } = createEmotionServer(ssrEmotionCache);
79 |
80 | ctx.renderPage = () =>
81 | originalRenderPage({
82 | enhanceApp: (App) =>
83 | function EnhanceApp(props) {
84 | return ;
85 | },
86 | });
87 |
88 | const initialProps = await Document.getInitialProps(ctx);
89 | // This is important. It prevents emotion to render invalid HTML.
90 | // See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153
91 | const emotionStyles = extractCriticalToChunks(initialProps.html);
92 | const emotionStyleTags = emotionStyles.styles.map((style) => (
93 |
98 | ));
99 |
100 | return {
101 | ...initialProps,
102 | // Styles fragment is rendered after the app and page rendering finish.
103 | styles: [
104 | ...React.Children.toArray(initialProps.styles),
105 | ...emotionStyleTags,
106 | ],
107 | };
108 | };
109 |
--------------------------------------------------------------------------------
/public/card/title/donate.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct: Fridge Finder
2 |
3 | Like the technical community as a whole, the Fridge Finder team is made up of a mixture of professionals and volunteers from all over the world, working on every aspect of the mission - including mentorship, teaching, and connecting people. Diversity is one of our huge strengths, but it can also lead to communication issues and unhappiness. To that end, we have a few ground rules that we ask people to adhere to. This code applies equally to founders, mentors and those seeking help and guidance.
4 |
5 | This isn't an exhaustive list of things that you can't do. Rather, take it in the spirit in which it's intended - a guide to make it easier to enrich all of us and the technical communities in which we participate. This code of conduct applies to all spaces managed by the Fridge Finder project. This includes the Discord Server, the Trello Board, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them.
6 |
7 | If you believe someone is violating the code of conduct, we ask that you report it by emailing .
8 |
9 | - **Be friendly and patient.**
10 |
11 | - **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, color, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability.
12 |
13 | - **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
14 |
15 | - **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It's important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the Fridge Finder community should be respectful when dealing with other members as well as with people outside the Fridge Finder community.
16 |
17 | - **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do
18 | not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
19 | - Violent threats or language directed against another person.
20 | - Discriminatory jokes and language.
21 | - Posting sexually explicit or violent material.
22 | - Posting (or threatening to post) other people's personally identifying information ("doxing").
23 | - Personal insults, especially those using racist or sexist terms.
24 | - Unwelcome sexual attention.
25 | - Advocating for, or encouraging, any of the above behavior.
26 | - Repeated harassment of others. In general, if someone asks you to stop, then stop.
27 |
28 | - **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and Fridge Finder is no exception. It is important that we resolve disagreements and differing views constructively. Remember that we're different. The strength of Fridge Finder comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn't mean that they're wrong. Don't forget that it is human to err and blaming each other doesn't get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
29 |
30 | Original text courtesy of the [Django Project](https://www.djangoproject.com/conduct/)
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.1.0",
3 | "name": "fridge-finder",
4 | "description": "A mobile friendly website that displays an interactive map of all the community fridges in and around New York City.",
5 | "keywords": [
6 | "nyc",
7 | "food bank",
8 | "community fridge",
9 | "map"
10 | ],
11 | "homepage": "https://github.com/FridgeFinder/CFM_Frontend#readme",
12 | "author": "Fridge Finder ",
13 | "contributors": [
14 | "Andrew R",
15 | "Bernard Martis (https://bernardm.github.io/)",
16 | "Briana Calderón Navarro (https://github.com/GlobalHands)",
17 | "Hamaad Chughtai (https://github.com/Hamaad102)",
18 | "Ioana Tiplea (https://github.com/ioanat94)",
19 | "Jaron Earle (https://github.com/jaronaearle)",
20 | "Linh Chi Nguyen (https://github.com/ayaderaghul)",
21 | "Luke Conley (https://github.com/grandballoon)",
22 | "Ricardo Camacho Mireles (https://github.com/rcamach7)",
23 | "Ricky Saka (https://github.com/SakaRicky)",
24 | "Sean Redmon (https://github.com/seanred360)",
25 | "Trillium Smith (https://github.com/Spiteless)",
26 | "Weston Norwood (https://github.com/wheninseattle)",
27 | "Youssef El Rhilassi (https://github.com/YELrhilassi)"
28 | ],
29 | "license": "MIT",
30 | "private": true,
31 | "repository": {
32 | "type": "git",
33 | "url": "git+https://github.com/FridgeFinder/CFM_Frontend.git"
34 | },
35 | "bugs": {
36 | "url": "https://github.com/FridgeFinder/CFM_Frontend/issues"
37 | },
38 | "directories": {
39 | "doc": "./docs"
40 | },
41 | "type": "module",
42 | "scripts": {
43 | "postinstall": "npx simple-git-hooks",
44 | "dev": "concurrently --names \"MOCK,NEXT\" --prefix-colors \"yellow,green\" --kill-others \"npx json-server mock/data.json --routes mock/server-routes.json --host 127.0.0.1 --port 3050\" \"npx next --turbopack\"",
45 | "web": "next",
46 | "data": "npx json-server mock/data.json --routes mock/server-routes.json --host 127.0.0.1 --port 3050",
47 | "build": "next build --turbopack",
48 | "start": "next start",
49 | "lint": "eslint --cache --fix ci/ etl/ src/",
50 | "style": "prettier --write --ignore-unknown . && svgo -qrf public/",
51 | "test": "jest --coverage"
52 | },
53 | "browserslist": [
54 | ">0.3%",
55 | "not ie 11",
56 | "not dead",
57 | "not op_mini all"
58 | ],
59 | "packageManager": "yarn@1.22.15",
60 | "simple-git-hooks": {
61 | "pre-commit": "lint-staged --quiet --no-stash --concurrent true",
62 | "commit-msg": "node ci/commit-msg.mjs $1"
63 | },
64 | "dependencies": {
65 | "@emotion/cache": "^11.7.1",
66 | "@emotion/react": "^11.9.0",
67 | "@emotion/server": "^11.4.0",
68 | "@emotion/styled": "^11.8.1",
69 | "@mui/icons-material": "^7.3.4",
70 | "@mui/material": "^7.3.4",
71 | "@types/react": "^19.2.2",
72 | "formik": "^2.2.9",
73 | "fuse.js": "^7.1.0",
74 | "leaflet": "^1.8.0",
75 | "next": "^16.0.10",
76 | "prop-types": "^15.8.1",
77 | "react": "19.2.0",
78 | "react-dom": "19.2.0",
79 | "react-leaflet": "^5.0.0",
80 | "yup": "^1.7.1",
81 | "zustand": "^5.0.8"
82 | },
83 | "devDependencies": {
84 | "@babel/core": "^7.28.4",
85 | "@babel/eslint-parser": "^7.28.4",
86 | "@babel/plugin-syntax-jsx": "^7.27.1",
87 | "@eslint/js": "^9.38.0",
88 | "@next/eslint-plugin-next": "^16.0.7",
89 | "@testing-library/dom": "^10.4.1",
90 | "@testing-library/jest-dom": "^6.9.1",
91 | "@testing-library/react": "^16.3.0",
92 | "eslint": "^9.38.0",
93 | "eslint-plugin-react": "^7.37.5",
94 | "eslint-plugin-react-hooks": "^7.0.0",
95 | "globals": "^16.4.0",
96 | "jest": "^30.2.0",
97 | "jest-environment-jsdom": "^30.2.0",
98 | "json-server": "0.17.4",
99 | "prettier": "^3.6.2",
100 | "simple-git-hooks": "^2.7.0"
101 | },
102 | "optionalDependencies": {
103 | "@faker-js/faker": "^7.5.0",
104 | "entities": "^4.3.1",
105 | "picocolors": "^1.0.0",
106 | "url-exist": "^3.0.1"
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/components/organisms/browse/List.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 |
3 | import { List, ListItem, Stack, Typography } from '@mui/material';
4 | import {
5 | CalendarMonthOutlined as CalendarIcon,
6 | Instagram as InstagramIcon,
7 | LocationOnOutlined as LocationOnOutlinedIcon,
8 | } from '@mui/icons-material';
9 | import { ButtonLink } from 'components/atoms';
10 | import typesView from 'model/view/prop-types';
11 | function formatDate(isoString) {
12 | const msSinceEpoch = Date.parse(isoString);
13 | return new Date(msSinceEpoch).toLocaleDateString([], {
14 | year: 'numeric',
15 | month: 'numeric',
16 | day: 'numeric',
17 | hour: '2-digit',
18 | minute: '2-digit',
19 | });
20 | }
21 |
22 | function Location({ location }) {
23 | return (
24 |
25 |
26 |
27 | {location.street}
28 |
29 | {location.city}, {location.state}
30 | {location.zip}
31 |
32 |
33 | );
34 | }
35 | Location.propTypes = {
36 | location: typesView.Location,
37 | };
38 |
39 | function Instagram({ instagramUrl }) {
40 | const instagramRegex =
41 | /(?:(?:http|https):\/\/)?(?:www.)?(?:instagram.com|instagr.am|instagr.com)\/(\w+)/gim;
42 | const handle = instagramRegex.exec(instagramUrl);
43 | return (
44 |
45 |
46 |
47 | @{handle[1]}
48 |
49 |
50 | );
51 | }
52 | Instagram.propTypes = {
53 | instagramUrl: PropTypes.string.isRequired,
54 | };
55 |
56 | function LastUpdate({ date }) {
57 | return (
58 |
59 |
60 |
61 | Last Update: {formatDate(date)}
62 |
63 |
64 | );
65 | }
66 | LastUpdate.propTypes = {
67 | date: PropTypes.object.isRequired,
68 | };
69 |
70 | export default function FridgeList({ fridges }) {
71 | return (
72 |
73 | {fridges.map((fridge, fridgeIndex) => (
74 |
79 |
80 |
81 |
82 |
83 | {fridge.name}
84 |
85 |
86 | {fridge.maintainer?.instagram ? (
87 |
88 | ) : null}
89 | {fridge.report ? (
90 |
91 | ) : null}
92 |
93 |
94 |
95 |
102 |
109 |
110 |
111 |
112 | ))}
113 |
114 | );
115 | }
116 | FridgeList.propTypes = {
117 | fridges: PropTypes.arrayOf(typesView.Fridge),
118 | };
119 |
--------------------------------------------------------------------------------
/src/components/atoms/SoftWrap/SoftWrap.test.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 | import { screen, render } from '@testing-library/react';
3 | import SoftWrap from './SoftWrap';
4 |
5 | describe('Does the function wrap each sentence in a paragraph with a span?', function () {
6 | it('Does not wrap a lone sentence with a span', function () {
7 | const text = 'Take what you need.';
8 | render(SoftWrap({ text }));
9 |
10 | expect(screen.queryByTestId('span')).not.toBeInTheDocument();
11 | });
12 |
13 | it('Does not wrap a lone, bulleted sentence with a span', function () {
14 | const text = '1. Take what you need.';
15 | render(SoftWrap({ text }));
16 |
17 | expect(screen.queryByTestId('span')).not.toBeInTheDocument();
18 | });
19 |
20 | it('Wraps Sentences ending in .!?:;。 with a span', function () {
21 | const text =
22 | 'Take what you need. Leave what you can: Toma lo que necesitas! Deja lo que puedas? 拿走你需要的食物。把不需要的食物留下。';
23 | render(SoftWrap({ text }));
24 |
25 | expect(screen.getByText('Take what you need.')).toBeInTheDocument();
26 | expect(screen.getByText('Leave what you can:')).toBeInTheDocument();
27 | expect(screen.getByText('Toma lo que necesitas!')).toBeInTheDocument();
28 | expect(screen.getByText('Deja lo que puedas?')).toBeInTheDocument();
29 | expect(screen.getByText('拿走你需要的食物。')).toBeInTheDocument();
30 | expect(screen.getByText('把不需要的食物留下。')).toBeInTheDocument();
31 | expect(
32 | screen.queryByText('Take what you need. Leave what you can.')
33 | ).not.toBeInTheDocument();
34 | expect(
35 | screen.queryByText(
36 | 'Take what you need. Leave what you can: Toma lo que necesitas! Deja lo que puedas? 拿走你需要的食物。把不需要的食物留下。'
37 | )
38 | ).not.toBeInTheDocument();
39 | });
40 |
41 | it('Wraps correctly when the string ends without punctuation', function () {
42 | const text =
43 | 'Technology Empowers Us. 科技赋予我们力量。La Tecnología Nos Da Poder';
44 | render(SoftWrap({ text }));
45 |
46 | expect(screen.getByText('Technology Empowers Us.')).toBeInTheDocument();
47 | expect(screen.getByText('科技赋予我们力量。')).toBeInTheDocument();
48 | expect(screen.getByText('La Tecnología Nos Da Poder')).toBeInTheDocument();
49 | expect(
50 | screen.queryByText('科技赋予我们力量。 La Tecnología Nos Da Poder')
51 | ).not.toBeInTheDocument();
52 | expect(
53 | screen.queryByText(
54 | 'Technology Empowers Us. 科技赋予我们力量。 La Tecnología Nos Da Poder'
55 | )
56 | ).not.toBeInTheDocument();
57 | });
58 |
59 | it('Puts numbered bullets together with the sentence that follows it', function () {
60 | const text = '1. Read Best Practices. Leer Mejores Prácticas. 参与其中。';
61 | render(SoftWrap({ text }));
62 |
63 | expect(screen.getByText('1. Read Best Practices.')).toBeInTheDocument();
64 | expect(screen.getByText('Leer Mejores Prácticas.')).toBeInTheDocument();
65 | expect(screen.getByText('参与其中。')).toBeInTheDocument();
66 | expect(screen.queryByText('1.')).not.toBeInTheDocument();
67 | expect(screen.queryByText('Read Best Practices.')).not.toBeInTheDocument();
68 | expect(
69 | screen.queryByText(
70 | '1. Read Best Practices. Leer Mejores Prácticas. 参与其中。'
71 | )
72 | ).not.toBeInTheDocument();
73 | });
74 |
75 | it('Handles bullets like this 1:', function () {
76 | const text =
77 | '1: About Community Fridges. Sobre Refrigeradores Comunitarios. 关于社区冰箱';
78 | render(SoftWrap({ text }));
79 |
80 | expect(screen.getByText('1: About Community Fridges.')).toBeInTheDocument();
81 | expect(
82 | screen.getByText('Sobre Refrigeradores Comunitarios.')
83 | ).toBeInTheDocument();
84 | expect(screen.getByText('关于社区冰箱')).toBeInTheDocument();
85 | expect(screen.queryByText('1:')).not.toBeInTheDocument();
86 | expect(
87 | screen.queryByText('About Community Fridges.')
88 | ).not.toBeInTheDocument();
89 | });
90 | });
91 |
92 | describe('Does it handle edge cases?', function () {
93 | it('Handles empty input', function () {
94 | expect(SoftWrap()).toBeNull();
95 | expect(SoftWrap({ text: '' })).toBeNull();
96 | });
97 |
98 | it('Handles a sentence under 5 characters', function () {
99 | render(SoftWrap({ text: 'word' }));
100 | expect(screen.queryByText('word')).toBeInTheDocument();
101 | });
102 | });
103 |
--------------------------------------------------------------------------------
/public/card/title/sourceFood.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/pamphlet/best-practices.page.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import PropTypes from 'prop-types';
3 | import Head from 'next/head';
4 | import { Box, Tab, Tabs, Typography } from '@mui/material';
5 | import { PageFooter } from 'components/atoms';
6 |
7 | const panelList = [
8 | {
9 | title: 'Dropping off',
10 | content: [
11 | 'Only bring good food to the community fridges. When considering what is good to donate, ask yourself if you would give the food item to your friends or family to eat? If so, your food donation is probably good for your neighbors, too.',
12 | 'Food must be fresh, stored at the proper temperature, and unexpired.',
13 | 'Portion donations into individual sized quantities that make it easy for people to take with them. Catering trays should not be stored in community fridges due to causing food contents to spill, and being inaccessible for the public to transport.',
14 | 'Food should be kept in clean, airtight containers to avoid food spills.',
15 | 'Label meals with ingredients and the date prepared.',
16 | 'If you notice a fridge needs to be cleaned, help clean it.',
17 | 'Do not bring anything else that is not food to a community fridge, unless told otherwise.',
18 | 'Take your trash with you, including cardboard boxes and food scraps.',
19 | ],
20 | },
21 | {
22 | title: 'Picking up',
23 | content: [
24 | 'Only take the amount of food that you need, and leave the rest for others. Many people depend on community fridges to get enough nutrition. Whenever possible, make sure there is enough food left for others to eat as well.',
25 | 'Only take good food from the fridges. If the food does not look good, throw it away using appropriate procedures and sanitation.',
26 | 'If you touch a food item, take it with you or throw it away.',
27 | 'Do not leave trash near community fridges.',
28 | 'If you notice a fridge needs to be cleaned, help clean it.',
29 | 'Be kind to others when interacting with community fridges.',
30 | ],
31 | },
32 | ];
33 |
34 | function TabPanel(props) {
35 | const { children, currentTab, index, ...other } = props;
36 |
37 | return (
38 |
46 |
55 | {children}
56 |
57 |
58 | );
59 | }
60 | TabPanel.propTypes = {
61 | children: PropTypes.node,
62 | currentTab: PropTypes.number.isRequired,
63 | index: PropTypes.number.isRequired,
64 | };
65 |
66 | export default function BestPracticesPage() {
67 | const [ixCurrentPanel, setCurrentPanelIndex] = useState(0);
68 |
69 | const handleChange = (event, newValue) => {
70 | setCurrentPanelIndex(newValue);
71 | };
72 |
73 | return (
74 | <>
75 |
76 | Fridge Finder: Best Practices
77 |
78 |
79 | Best Practices
80 |
81 |
89 | {panelList.map((panel, index) => (
90 |
97 | ))}
98 |
99 | {panelList.map((panel, ixPanel) => (
100 |
105 | {panel.content.map((item, ixContent) => (
106 |
107 | {item}
108 |
109 | ))}
110 |
111 | ))}
112 |
113 |
114 | >
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/public/card/paragraph/jumpingBlueberries.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/commit-convention.md:
--------------------------------------------------------------------------------
1 | # Git Commit Message Convention
2 |
3 | This convention is adapted from:
4 |
5 | - [How to Write a Git Commit Message](https://cbea.ms/git-commit/) by Chris Beams
6 | - [Semantic Commit Messages](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) by Josh Buchea
7 |
8 | ## synopsis
9 |
10 | - commit message header format: `(): `
11 | - `subject` must start with a capital letter
12 | - `subject` must not end with a period
13 | - `subject` must not exceed 70 characters
14 |
15 | commit header examples:
16 |
17 | - `feat: Add hat wobble`
18 | - `wip: Moved commit-convention to docs`
19 |
20 | ## commit message
21 |
22 | A commit message consists of a `header`, `body` and `footer`. The header has a `type`, `scope` and `subject`:
23 |
24 | ```
25 | ():
26 |
27 |
28 |
29 |
30 | ```
31 |
32 | ### header
33 |
34 | The `header` is mandatory and the `scope` of the header is optional.
35 |
36 | ```
37 | feat: Add hat wobble
38 | ^--^ ^------------^
39 | | |
40 | | +-> Summary in present tense.
41 | |
42 | +-------> Type: chore, docs, feat, fix, refactor, style, or test.
43 | ```
44 |
45 | #### type
46 |
47 | If the type is `feat`, `fix` or `perf`, it will appear in the changelog. However, if there is any [BREAKING CHANGE](#footer), the commit will always appear in the changelog.
48 |
49 | The types are as follows:
50 |
51 | - `feat`: new feature for the user, not a new feature for build script
52 | - `fix`: bug fix for the user, not a fix to a build script
53 | - `refactor`: refactoring production code, eg. renaming a variable
54 | - `test`: adding missing tests, refactoring tests; no production code change
55 | - `perf`: performance improvements to production code
56 | - `style`: formatting, missing semi colons, etc; no production code change
57 | - `asset`: changing static assets. ie: css files, images, etc
58 | - `doc`: changes to the documentation
59 | - `ci`: updating CD/CI pipeline; no local script changes
60 | - `chore`: updating grunt tasks etc; no production code change
61 | - `revert`: reverting a previously published commit
62 | - `wip`: work in progress commit
63 |
64 | ##### revert
65 |
66 | If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of the reverted commit. In the body, it should say: `This reverts commit .`, where the hash is the SHA of the commit being reverted.
67 |
68 | #### scope
69 |
70 | The scope could be anything specifying the place of the commit change. For example `dev`, `build`, `workflow`, `cli`, etc. If you don't know what the scope is, leave it blank.
71 |
72 | #### subject
73 |
74 | 1. Separate subject from body with a blank line
75 | 1. Limit the subject line to 70 characters
76 | 1. Capitalize the subject line
77 | 1. Do not end the subject line with a period
78 | 1. Use the imperative mood in the subject line
79 | 1. Wrap the body at 72 characters
80 | 1. Use the body to explain what and why vs. how
81 |
82 | ### body
83 |
84 | Just as in the `subject`, use the imperative, present tense: "change" not "changed" nor "changes".
85 | The body should include the motivation for the change and contrast this with previous behavior.
86 |
87 | ### footer
88 |
89 | The footer should contain any information about **Breaking Changes** and is also the place to
90 | reference GitHub issues that this commit **Closes**.
91 |
92 | **Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this.
93 |
94 | ### examples
95 |
96 | Appears under "Features" header, `dev` subheader:
97 |
98 | ```
99 | feat(dev): Add 'comments' option
100 | ```
101 |
102 | Appears under "Bug Fixes" header, `dev` subheader, with a link to issue #28:
103 |
104 | ```
105 | fix(dev): Fix dev error
106 |
107 | close #28
108 | ```
109 |
110 | Appears under "Performance Improvements" header, and under "Breaking Changes" with the breaking change explanation:
111 |
112 | ```
113 | perf(build): Remove 'foo' option
114 |
115 | BREAKING CHANGE: The 'foo' option has been removed.
116 | ```
117 |
118 | The following commit and commit `667ecc1` do not appear in the changelog if they are under the same release. If not, the revert commit appears under the "Reverts" header.
119 |
120 | ```
121 | revert: feat(compiler): Add 'comments' option
122 |
123 | This reverts commit 667ecc1654a317a13331b17617d973392f415f02.
124 | ```
125 |
--------------------------------------------------------------------------------
/public/brand/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/user/fridge/report/[fridgeId].test.jsx:
--------------------------------------------------------------------------------
1 | // Mock next/router with push spy
2 | const pushMock = jest.fn();
3 | jest.mock('next/router', () => ({
4 | useRouter: () => ({
5 | query: { fridgeId: 'test-fridge-123' },
6 | push: pushMock,
7 | }),
8 | }));
9 |
10 | jest.mock('components/atoms/ButtonLink', () => ({
11 | __esModule: true,
12 | default: ({ to, ...props }) => (
13 | {
17 | e.preventDefault(); // stop real navigation
18 | pushMock(to); // trigger spy
19 | }}
20 | >
21 | {props.title}
22 |
23 | ),
24 | }));
25 |
26 | import { render, screen, fireEvent, waitFor } from '@testing-library/react';
27 | import FridgeReportPage from './[fridgeId].page.jsx';
28 |
29 | describe('FridgeReportPage', () => {
30 | beforeEach(() => {
31 | global.fetch = jest.fn(() =>
32 | Promise.resolve({
33 | ok: true,
34 | status: 200,
35 | json: () => Promise.resolve({ message: 'Success' }),
36 | })
37 | );
38 | });
39 |
40 | afterEach(() => {
41 | jest.resetAllMocks();
42 | });
43 |
44 | it('renders the form with fridgeId', () => {
45 | render( );
46 | expect(screen.getByText('Fridge Status Report')).toBeInTheDocument();
47 | expect(screen.getByText('test-fridge-123')).toBeInTheDocument();
48 | expect(screen.getByLabelText('Notes')).toBeInTheDocument();
49 | expect(screen.getByLabelText('Submit status update')).toBeInTheDocument();
50 | expect(screen.getByLabelText('Return to map page')).toHaveAttribute(
51 | 'href',
52 | '/browse'
53 | );
54 | });
55 |
56 | it('allows user to fill and submit the form', async () => {
57 | render( );
58 | // Select a radio option
59 | fireEvent.click(screen.getByLabelText('Fridge needs cleaning'));
60 | // Move slider (simulate change event)
61 | fireEvent.change(screen.getByLabelText('Amount of food in the fridge'), {
62 | target: { value: 2 },
63 | });
64 | // Enter notes
65 | fireEvent.change(screen.getByLabelText('Notes'), {
66 | target: { value: 'Some notes' },
67 | });
68 | // trigger submit
69 | fireEvent.click(screen.getByLabelText('Submit status update'));
70 |
71 | // wait for the success panel rendered by FeedbackCard
72 | const successPanel = await screen.findByText(
73 | 'You have successfully submitted a status report!'
74 | );
75 | expect(successPanel).toBeInTheDocument();
76 |
77 | // Check fetch called with correct data
78 | expect(global.fetch).toHaveBeenCalledWith(
79 | expect.stringContaining('/v1/fridges/test-fridge-123/reports'),
80 | expect.objectContaining({
81 | method: 'POST',
82 | headers: { 'Content-Type': 'application/json' },
83 | body: expect.stringContaining('Some notes'),
84 | })
85 | );
86 | expect(global.fetch).toHaveBeenCalledTimes(1);
87 | });
88 |
89 | it('navigates to fridge status page on GO TO FRIDGE click', async () => {
90 | render( );
91 | // Submit to show success panel
92 | fireEvent.click(screen.getByLabelText('Submit status update'));
93 | let link;
94 | await waitFor(() => {
95 | link = screen.getByRole('link');
96 | expect(link).toHaveAttribute('href', '/fridge/test-fridge-123');
97 | });
98 | await fireEvent.click(link);
99 | await waitFor(() => {
100 | expect(pushMock).toHaveBeenCalledWith('/fridge/test-fridge-123');
101 | });
102 | });
103 |
104 | it('shows error feedback on failed submit and allows retry', async () => {
105 | global.fetch.mockResolvedValueOnce({ ok: false });
106 | render( );
107 | fireEvent.click(screen.getByLabelText('Submit status update'));
108 | await waitFor(() => {
109 | expect(screen.getByText('Error!')).toBeInTheDocument();
110 | });
111 |
112 | // Retry button brings back the form
113 | fireEvent.click(screen.getByText('TRY AGAIN'));
114 | await waitFor(() => {
115 | expect(screen.getByText('Fridge Status Report')).toBeInTheDocument();
116 | });
117 | });
118 |
119 | it('shows error feedback on fetch error', async () => {
120 | global.fetch.mockRejectedValueOnce(new Error('Network error'));
121 | render( );
122 | fireEvent.click(screen.getByLabelText('Submit status update'));
123 | await waitFor(() => {
124 | expect(screen.getByText('Error!')).toBeInTheDocument();
125 | });
126 | });
127 | });
128 |
--------------------------------------------------------------------------------