├── backend
├── tasks
│ ├── .gitignore
│ └── move_documents.js
├── src
│ ├── models
│ │ ├── enums
│ │ │ ├── trustMeterScale.ts
│ │ │ ├── ratingScale.ts
│ │ │ ├── sellerType.ts
│ │ │ ├── fulfillmentType.ts
│ │ │ ├── deviceLocationType.ts
│ │ │ └── stockLevelType.ts
│ │ ├── User.ts
│ │ ├── misc
│ │ │ └── Toggle.ts
│ │ ├── ReviewFeedback.ts
│ │ ├── SellerItem.ts
│ │ ├── Seller.ts
│ │ └── UserSettings.ts
│ ├── routes
│ │ ├── index.ts
│ │ ├── home.routes.ts
│ │ └── mapCenter.routes.ts
│ ├── config
│ │ ├── docs
│ │ │ ├── enum
│ │ │ │ ├── FulfillmentType.yml
│ │ │ │ ├── StockLevelType.yml
│ │ │ │ ├── SellerType.yml
│ │ │ │ ├── DeviceLocationType.yml
│ │ │ │ ├── TrustMeterScale.yml
│ │ │ │ └── RatingScale.yml
│ │ │ ├── MapCenterSchema.yml
│ │ │ └── TogglesSchema.yml
│ │ ├── platformAPIclient.ts
│ │ ├── dbConnection.ts
│ │ ├── sentryConnection.ts
│ │ ├── loggingConfig.ts
│ │ └── swagger.ts
│ ├── middlewares
│ │ ├── logger.ts
│ │ ├── isToggle.ts
│ │ ├── isSellerFound.ts
│ │ ├── isUserSettingsFound.ts
│ │ ├── isPioneerFound.ts
│ │ └── verifyToken.ts
│ ├── index.ts
│ ├── utils
│ │ ├── multer.ts
│ │ ├── app.ts
│ │ └── env.ts
│ ├── helpers
│ │ └── jwt.ts
│ ├── services
│ │ ├── mapCenter.service.ts
│ │ └── admin
│ │ │ └── toggle.service.ts
│ ├── controllers
│ │ ├── mapCenterController.ts
│ │ ├── userController.ts
│ │ └── reviewFeedbackController.ts
│ └── types.ts
├── .gitignore
├── docker
│ └── ecosystem.config.js
├── jest.config.js
├── test
│ ├── routes
│ │ └── home.routes.spec.ts
│ ├── middlewares
│ │ ├── verifyToken.spec.ts
│ │ └── isToggle.spec.ts
│ ├── jest.setup.ts
│ ├── controllers
│ │ ├── userController.spec.ts
│ │ └── mapCenterController.spec.ts
│ └── services
│ │ ├── mapCenter.service.spec.ts
│ │ └── userSettings.service.spec.ts
├── tsconfig.json
├── .vscode
│ └── launch.json
├── Dockerfile
├── .env.development
├── package.json
├── LICENSE
└── README.md
├── frontend
├── src
│ ├── components
│ │ ├── shared
│ │ │ ├── Forms
│ │ │ │ ├── Inputs
│ │ │ │ │ └── Inputs.module.css
│ │ │ │ └── Buttons
│ │ │ │ │ ├── Buttons.module.css
│ │ │ │ │ └── Buttons.tsx
│ │ │ ├── sidebar
│ │ │ │ └── sidebar.module.css
│ │ │ ├── navbar
│ │ │ │ └── Navbar.module.css
│ │ │ ├── SearchBar
│ │ │ │ └── SearchBar.scss
│ │ │ ├── map
│ │ │ │ └── RecenterAutomatically.tsx
│ │ │ ├── Seller
│ │ │ │ └── ToggleCollapse.tsx
│ │ │ ├── Review
│ │ │ │ └── TrustMeter.tsx
│ │ │ ├── About
│ │ │ │ ├── privacy-policy
│ │ │ │ │ └── PrivacyPolicy.module.css
│ │ │ │ ├── terms-of-service
│ │ │ │ │ ├── TermOfService.module.css
│ │ │ │ │ └── TermsOfService.tsx
│ │ │ │ └── Info
│ │ │ │ │ └── Info.module.css
│ │ │ └── confirm.tsx
│ │ └── skeleton
│ │ │ ├── skeleton.css
│ │ │ ├── skeleton.tsx
│ │ │ ├── seller
│ │ │ ├── Review.tsx
│ │ │ ├── Registration.tsx
│ │ │ └── SellerItem.tsx
│ │ │ └── Sidebar.tsx
│ ├── utils
│ │ ├── api.ts
│ │ ├── sanitize.ts
│ │ ├── auth.ts
│ │ ├── map.ts
│ │ ├── date.ts
│ │ └── geolocation.ts
│ ├── typedefs.d.ts
│ ├── app
│ │ ├── page.tsx
│ │ ├── pages
│ │ │ └── _document.js
│ │ ├── _not-found
│ │ │ └── page.tsx
│ │ ├── providers.tsx
│ │ ├── [locale]
│ │ │ ├── seller
│ │ │ │ └── reviews
│ │ │ │ │ └── util
│ │ │ │ │ └── ratingUtils.ts
│ │ │ ├── map-center
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── global-error.tsx
│ │ ├── global.css
│ │ └── layout.tsx
│ ├── navigation.ts
│ ├── middleware.ts
│ ├── config
│ │ └── client.ts
│ ├── constants
│ │ ├── placeholders.ts
│ │ ├── pi.ts
│ │ └── types.ts
│ └── services
│ │ ├── toggleApi.ts
│ │ ├── mapCenterApi.ts
│ │ ├── reviewsApi.ts
│ │ └── userSettingsApi.ts
├── public
│ ├── default.png
│ ├── favicon.ico
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── images
│ │ ├── icons
│ │ │ ├── scope.png
│ │ │ ├── crosshair.png
│ │ │ ├── map-of-pi-icon.png
│ │ │ ├── map_of_pi_logo.jpeg
│ │ │ └── map_centers_crosshair.png
│ │ ├── shared
│ │ │ ├── upload.png
│ │ │ ├── my_location.png
│ │ │ ├── upload_old.png
│ │ │ ├── social-media
│ │ │ │ ├── x-icon.png
│ │ │ │ ├── email-icon.png
│ │ │ │ ├── email-logo.png
│ │ │ │ ├── discord-icon.png
│ │ │ │ ├── facebook.icon.png
│ │ │ │ ├── youtube-icon.png
│ │ │ │ ├── instagram-icon.png
│ │ │ │ ├── E-mail_icon.svg
│ │ │ │ ├── email-icon.svg
│ │ │ │ └── tiktok-icon.svg
│ │ │ ├── review_ratings
│ │ │ │ ├── trust-o-meter_000.PNG
│ │ │ │ ├── trust-o-meter_050.PNG
│ │ │ │ ├── trust-o-meter_080.PNG
│ │ │ │ └── trust-o-meter_100.PNG
│ │ │ └── sidebar
│ │ │ │ ├── close.svg
│ │ │ │ ├── dark.svg
│ │ │ │ ├── user.svg
│ │ │ │ ├── theme.svg
│ │ │ │ ├── info.svg
│ │ │ │ ├── location.svg
│ │ │ │ ├── question.svg
│ │ │ │ ├── contact.svg
│ │ │ │ ├── language.svg
│ │ │ │ ├── light.svg
│ │ │ │ └── settings.svg
│ │ └── business
│ │ │ ├── product.png
│ │ │ ├── upload.jpg
│ │ │ └── add-item-button.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ └── validation-key.txt
├── postcss.config.mjs
├── .prettierrc.json
├── .vscode
│ └── launch.json
├── .eslintrc.json
├── .env.production
├── .env.staging
├── i18n
│ ├── i18n.ts
│ └── request.ts
├── .gitignore
├── .prettierignore
├── logger.config.mjs
├── sentry.client.config.mjs
├── .env.development
├── tsconfig.json
├── tailwind.config.ts
├── Dockerfile
├── next.config.mjs
├── LICENSE
└── package.json
├── .gitignore
├── install_docker.sh
├── reverse-proxy
├── Dockerfile
└── docker
│ ├── nginx.conf.template
│ ├── entrypoint.sh
│ └── nginx-ssl.conf.template
├── docker-compose.yml
├── .env.example
├── .github
└── workflows
│ └── build.yml
├── README.md
└── LICENSE
/backend/tasks/.gitignore:
--------------------------------------------------------------------------------
1 | *documentIds.json
2 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/Forms/Inputs/Inputs.module.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # misc
2 | .DS_Store
3 | docker-data
4 |
5 | # local env files
6 | .env
7 |
--------------------------------------------------------------------------------
/frontend/public/default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/default.png
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/favicon-16x16.png
--------------------------------------------------------------------------------
/frontend/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/favicon-32x32.png
--------------------------------------------------------------------------------
/frontend/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/frontend/public/images/icons/scope.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/icons/scope.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/upload.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/upload.png
--------------------------------------------------------------------------------
/frontend/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/frontend/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/frontend/public/images/business/product.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/business/product.png
--------------------------------------------------------------------------------
/frontend/public/images/business/upload.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/business/upload.jpg
--------------------------------------------------------------------------------
/frontend/public/images/icons/crosshair.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/icons/crosshair.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/my_location.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/my_location.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/upload_old.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/upload_old.png
--------------------------------------------------------------------------------
/backend/src/models/enums/trustMeterScale.ts:
--------------------------------------------------------------------------------
1 | export enum TrustMeterScale {
2 | ZERO = 0,
3 | FIFTY = 50,
4 | EIGHTY = 80,
5 | HUNDRED = 100
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/public/images/icons/map-of-pi-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/icons/map-of-pi-icon.png
--------------------------------------------------------------------------------
/frontend/public/images/icons/map_of_pi_logo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/icons/map_of_pi_logo.jpeg
--------------------------------------------------------------------------------
/frontend/src/utils/api.ts:
--------------------------------------------------------------------------------
1 | export const getMultipartFormDataHeaders = () => {
2 | return {
3 | 'Content-Type': 'multipart/form-data',
4 | };
5 | };
6 |
--------------------------------------------------------------------------------
/backend/src/models/enums/ratingScale.ts:
--------------------------------------------------------------------------------
1 | export enum RatingScale {
2 | DESPAIR = 0,
3 | SAD = 2,
4 | OKAY = 3,
5 | HAPPY = 4,
6 | DELIGHT = 5
7 | }
8 |
--------------------------------------------------------------------------------
/backend/src/models/enums/sellerType.ts:
--------------------------------------------------------------------------------
1 | export enum SellerType {
2 | Active = 'activeSeller',
3 | Inactive = 'inactiveSeller',
4 | Test = 'testSeller'
5 | }
--------------------------------------------------------------------------------
/frontend/public/validation-key.txt:
--------------------------------------------------------------------------------
1 | 8cc98afd1291cf58f7beb659eb012c33e9359e02b25080ade248eb8f91eb4fefdb3f765f96b37d7437f82073467a768014898e4bfabfb9521f391698f4bee6f2
--------------------------------------------------------------------------------
/frontend/public/images/business/add-item-button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/business/add-item-button.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/x-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/social-media/x-icon.png
--------------------------------------------------------------------------------
/backend/src/models/enums/fulfillmentType.ts:
--------------------------------------------------------------------------------
1 | export enum FulfillmentType {
2 | CollectionByBuyer = 'Collection by buyer',
3 | DeliveredToBuyer = 'Delivered to buyer'
4 | }
--------------------------------------------------------------------------------
/frontend/public/images/icons/map_centers_crosshair.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/icons/map_centers_crosshair.png
--------------------------------------------------------------------------------
/backend/src/models/enums/deviceLocationType.ts:
--------------------------------------------------------------------------------
1 | export enum DeviceLocationType {
2 | Automatic = 'auto',
3 | GPS = 'deviceGPS',
4 | SearchCenter = 'searchCenter'
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/email-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/social-media/email-icon.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/email-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/social-media/email-logo.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/discord-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/social-media/discord-icon.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/facebook.icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/social-media/facebook.icon.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/youtube-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/social-media/youtube-icon.png
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/instagram-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/social-media/instagram-icon.png
--------------------------------------------------------------------------------
/frontend/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/review_ratings/trust-o-meter_000.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/review_ratings/trust-o-meter_000.PNG
--------------------------------------------------------------------------------
/frontend/public/images/shared/review_ratings/trust-o-meter_050.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/review_ratings/trust-o-meter_050.PNG
--------------------------------------------------------------------------------
/frontend/public/images/shared/review_ratings/trust-o-meter_080.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/review_ratings/trust-o-meter_080.PNG
--------------------------------------------------------------------------------
/frontend/public/images/shared/review_ratings/trust-o-meter_100.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/map-of-pi/map-of-pi-docker/HEAD/frontend/public/images/shared/review_ratings/trust-o-meter_100.PNG
--------------------------------------------------------------------------------
/backend/src/routes/index.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import homeRoutes from "./home.routes";
3 |
4 | const appRouter = Router();
5 |
6 | appRouter.use("/", homeRoutes);
7 |
8 | export default appRouter;
9 |
--------------------------------------------------------------------------------
/frontend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "singleQuote": true,
5 | "semi": true,
6 | "bracketSpacing": true,
7 | "arrowParens": "always",
8 | "bracketSameLine": true,
9 | "endOfLine": "auto"
10 | }
11 |
--------------------------------------------------------------------------------
/backend/src/config/docs/enum/FulfillmentType.yml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | FulfillmentType:
4 | type: string
5 | enum:
6 | - Collection by buyer
7 | - Delivered to buyer
8 | description: The type of fulfillment method.
--------------------------------------------------------------------------------
/backend/src/config/docs/enum/StockLevelType.yml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | FulfillmentType:
4 | type: string
5 | enum:
6 | - Collection by buyer
7 | - Delivered to buyer
8 | description: The type of fulfillment method.
--------------------------------------------------------------------------------
/backend/src/config/docs/enum/SellerType.yml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | SellerType:
4 | type: string
5 | enum:
6 | - activeSeller
7 | - inactiveSeller
8 | - testSeller
9 | description: The type of seller.
10 |
--------------------------------------------------------------------------------
/frontend/src/typedefs.d.ts:
--------------------------------------------------------------------------------
1 | declare interface Window {
2 | Pi: any;
3 | }
4 |
5 | // Declaration for leaflet-control-geocoder
6 | declare module 'leaflet-control-geocoder/dist/Control.Geocoder.js' {
7 | const Geocoder: any;
8 | export default Geocoder;
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Next.js: debug client-side",
6 | "type": "chrome",
7 | "request": "launch",
8 | "url": "http://localhost:4200"
9 | },
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export const dynamic = 'force-dynamic';
4 |
5 | // This page only renders when the app is built statically (output: 'export')
6 | export default function RootPage() {
7 | redirect('/en');
8 | }
9 |
--------------------------------------------------------------------------------
/backend/src/config/docs/enum/DeviceLocationType.yml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | DeviceLocationType:
4 | type: string
5 | enum:
6 | - auto
7 | - deviceGPS
8 | - searchCenter
9 | description: The type of FindMe device location.
10 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignorePatterns": ["node_modules/", ".next/"],
3 | "extends": "next",
4 | "rules": {
5 | "react/no-unescaped-entities": "off",
6 | "@next/next/no-page-custom-font": "off",
7 | "react-hooks/exhaustive-deps": "off"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/backend/src/config/docs/enum/TrustMeterScale.yml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | TrustMeterScale:
4 | type: number
5 | enum:
6 | - 0
7 | - 50
8 | - 80
9 | - 100
10 | description: The trust meter scale measured in increments for the seller.
11 |
--------------------------------------------------------------------------------
/backend/src/config/platformAPIclient.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { env } from "../utils/env";
3 |
4 | export const platformAPIClient = axios.create({
5 | baseURL: env.PLATFORM_API_URL,
6 | timeout: 20000,
7 | headers: {
8 | Authorization: `Key ${env.PI_API_KEY}`,
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/backend/src/models/enums/stockLevelType.ts:
--------------------------------------------------------------------------------
1 | export enum StockLevelType {
2 | AVAILABLE_1 = '1 available',
3 | AVAILABLE_2 = '2 available',
4 | AVAILABLE_3 = '3 available',
5 | MANY_AVAILABLE = 'Many available',
6 | MADE_TO_ORDER = 'Made to order',
7 | ONGOING_SERVICE = 'Ongoing service',
8 | SOLD = 'Sold'
9 | }
--------------------------------------------------------------------------------
/backend/src/config/docs/enum/RatingScale.yml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | RatingScale:
4 | type: number
5 | enum:
6 | - 0
7 | - 2
8 | - 3
9 | - 4
10 | - 5
11 | description: The rating scale where 0 is DESPAIR, 2 is SAD, 3 is OKAY, 4 is HAPPY, 5 is DELIGHT.
12 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # compiled output
4 | /dist
5 | /build
6 |
7 | # dependencies
8 | /node_modules
9 |
10 | # testing
11 | /coverage
12 |
13 | # local env files
14 | .env
15 | .vercel
16 |
17 | # local misc
18 | /uploads
19 | /scripts
20 |
--------------------------------------------------------------------------------
/frontend/src/utils/sanitize.ts:
--------------------------------------------------------------------------------
1 | // Helper function to remove all manner of URLs and links from text
2 | export default function removeUrls(text: string): string {
3 | text = text.trim()
4 | const urlPattern = /((https?:\/\/)?(www\.)?([a-zA-Z0-9-]+\.[a-zA-Z]{2,})(\/[^\s]*)?)/g;
5 | return text.replace(urlPattern, "[URL removed]");
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/src/components/skeleton/skeleton.css:
--------------------------------------------------------------------------------
1 | .skeleton {
2 | animation: skeleton 1s ease infinite alternate;
3 | }
4 |
5 | @keyframes skeleton {
6 | to {
7 | opacity: 0.5;
8 | }
9 | }
10 |
11 | .skeleton div {
12 | background-color: #c6c6c6;
13 | }
14 |
15 | .my-bg-trans {
16 | background-color: transparent !important;
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/navigation.ts:
--------------------------------------------------------------------------------
1 | import { createNavigation } from 'next-intl/navigation';
2 | import { locales, defaultLocale } from '../i18n/i18n';
3 |
4 | export const localePrefix = 'always';
5 |
6 | export const { Link, redirect, usePathname, useRouter } =
7 | createNavigation({
8 | locales,
9 | localePrefix,
10 | defaultLocale,
11 | });
--------------------------------------------------------------------------------
/backend/docker/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | // PM2 config file to run the app in production mode.
2 | module.exports = {
3 | apps: [{
4 | name: "map-of-pi-backend",
5 | script: "/usr/src/app/build/src/index.js",
6 | exec_mode: "cluster",
7 | instances: 4,
8 | out_file: './log/out.log',
9 | error_file: './log/error.log',
10 | }]
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/.env.production:
--------------------------------------------------------------------------------
1 | NODE_ENV=production
2 | IMAGE_BUCKET_HOST=mapofpi-production.sfo3.digitaloceanspaces.com
3 | NEXT_PUBLIC_BACKEND_URL=https://backend.mapofpi-nfycqr6ip35cena9.piappengine.com
4 | NEXT_PUBLIC_SENTRY_DSN=https://d4d5689596b6a9c4891d6f8ca24220fe@o4508101727223808.ingest.us.sentry.io/4508221232971776
5 | NEXT_PUBLIC_PI_SDK_URL=https://sdk.minepi.com/pi-sdk.js
6 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/close.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/.env.staging:
--------------------------------------------------------------------------------
1 | NODE_ENV=production
2 | IMAGE_BUCKET_HOST=mapofpi-staging.sfo3.digitaloceanspaces.com
3 | NEXT_PUBLIC_BACKEND_URL=https://backend.mapofpi-heuwx64a8iqc29xo.staging.piappengine.com
4 | NEXT_PUBLIC_SENTRY_DSN=https://fc6294b567223b594378540b51be91cd@o4508209577263104.ingest.us.sentry.io/4508221130211328
5 | NEXT_PUBLIC_PI_SDK_URL=https://staging-sdk.socialchainapp.com/pi-sdk.js
6 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/sidebar/sidebar.module.css:
--------------------------------------------------------------------------------
1 | .slide_content, .slide_contentx {
2 | display: flex;
3 | align-items: center;
4 | padding: 0.5rem 0.5rem;
5 | cursor: pointer;
6 | border-radius: 0.75rem;
7 | }
8 |
9 | .slide_content:hover img, .slide_content:hover svg {
10 | filter: invert(100%);
11 | }
12 |
13 | .slide_content:hover {
14 | color: white;
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/i18n/i18n.ts:
--------------------------------------------------------------------------------
1 | // Can be imported from a shared config
2 | export const locales = [
3 | 'ak-TW',
4 | 'ar',
5 | 'de',
6 | 'en',
7 | 'en-GB',
8 | 'es',
9 | 'ewe-BJ',
10 | 'fon-BJ',
11 | 'fr',
12 | 'hau-NG',
13 | 'yor-NG',
14 | 'hi',
15 | 'ja',
16 | 'ko',
17 | 'vi',
18 | 'zh-CN',
19 | 'zh-TW'
20 | ] as const;
21 |
22 | export const defaultLocale = 'en';
--------------------------------------------------------------------------------
/frontend/src/app/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from 'next/document';
2 |
3 | class MyDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 | }
16 |
17 | export default MyDocument;
18 |
--------------------------------------------------------------------------------
/backend/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | testEnvironment: "node",
5 | testMatch: ["**/**/*.spec.ts"],
6 | verbose: true,
7 | forceExit: true,
8 | resetMocks: true,
9 | restoreMocks: true,
10 | clearMocks: true,
11 | setupFilesAfterEnv: ['/test/jest.setup.ts']
12 | };
13 |
--------------------------------------------------------------------------------
/frontend/src/app/_not-found/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useTranslations } from 'next-intl';
4 |
5 | export const dynamic = 'force-dynamic';
6 |
7 | export default function NotFound() {
8 | const t = useTranslations();
9 |
10 | return (
11 |
12 |
404 | {t('ERROR.PAGE_NOT_FOUND_HEADER')}
13 |
{t('ERROR.PAGE_NOT_FOUND_MESSAGE')}
14 |
15 | );
16 | }
--------------------------------------------------------------------------------
/backend/test/routes/home.routes.spec.ts:
--------------------------------------------------------------------------------
1 | import request from "supertest";
2 | import app from "../../src/utils/app";
3 |
4 | describe("Successful request", () => {
5 | it('should return a 200 status and a message', async () => {
6 | const response = await request(app).get('/');
7 | expect(response.status).toBe(200);
8 | expect(response.body).toEqual({ message: 'Server is running' });
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/frontend/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import createMiddleware from 'next-intl/middleware';
2 | import { locales, defaultLocale } from '../i18n/i18n';
3 | import { localePrefix } from './navigation';
4 |
5 | export default createMiddleware({
6 | locales,
7 | defaultLocale,
8 | localePrefix
9 | });
10 |
11 | export const config = {
12 | matcher: [
13 | '/((?!api|_next/static|_next/image|_vercel|.*\\..*).*)'
14 | ]
15 | };
--------------------------------------------------------------------------------
/frontend/src/components/shared/navbar/Navbar.module.css:
--------------------------------------------------------------------------------
1 | .nav_item {
2 | width: 35px;
3 | height: 35px;
4 | border: none;
5 | background-color: transparent;
6 | cursor: pointer;
7 | display: flex;
8 | justify-content: center;
9 | align-items: center;
10 | }
11 |
12 | .nav_item:active {
13 | width: 35px;
14 | height: 35px;
15 | border: none;
16 | border-radius: 50%;
17 | background-color: #d0d0d086;
18 | }
19 |
--------------------------------------------------------------------------------
/backend/src/middlewares/logger.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from "express";
2 |
3 | import logger from '../config/loggingConfig';
4 |
5 | const requestLogger = (
6 | req: Request,
7 | res: Response,
8 | next: NextFunction
9 | ): void => {
10 | logger.info(`Endpoint: ${req.method} ${req.originalUrl}`);
11 | logger.debug("Request Body:", req.body);
12 | return next();
13 | };
14 |
15 | export default requestLogger;
16 |
--------------------------------------------------------------------------------
/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2016",
4 | "module": "commonjs",
5 | "rootDir": "./",
6 | "outDir": "./build",
7 | "esModuleInterop": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "strict": true,
10 | "skipLibCheck": true,
11 | "resolveJsonModule": true,
12 | "types": ["jest", "node"]
13 | },
14 | "include": ["src/**/*", "test/**/*"],
15 | "exclude": ["node_modules"]
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/app/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ThemeProvider } from 'next-themes';
4 |
5 | import AppContextProvider from '../../context/AppContextProvider';
6 |
7 | export function Providers({ children }: { children: React.ReactNode }) {
8 | return (
9 |
10 |
11 | {children}
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/E-mail_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/SearchBar/SearchBar.scss:
--------------------------------------------------------------------------------
1 | button {
2 | outline: none;
3 | }
4 |
5 | .search-toggle-element {
6 | height: auto;
7 | font-size: 25px !important;
8 | color: #1d724b;
9 | }
10 |
11 | /* Styles for the TextField label */
12 | .custom-label {
13 | transition: all 0.2s ease-in-out;
14 | }
15 |
16 | .custom-label.Mui-focused,
17 | .custom-label.MuiInputLabel-shrink {
18 | background-color: white;
19 | padding: 0 8px;
20 | border-radius: 8px;
21 | transform: translate(10px, -6px) scale(0.75);
22 | }
--------------------------------------------------------------------------------
/frontend/src/components/shared/map/RecenterAutomatically.tsx:
--------------------------------------------------------------------------------
1 | 'use client;'
2 |
3 | import { useEffect } from 'react'
4 | import { useMap } from 'react-leaflet';
5 |
6 | interface RecenterAutomaticallyProps {
7 | position: {
8 | lat: number;
9 | lng: number;
10 | },
11 | }
12 |
13 | function RecenterAutomatically({ position}: RecenterAutomaticallyProps) {
14 | const map = useMap();
15 |
16 | useEffect(() => {
17 | map.setView(position);
18 | }, []);
19 | return null;
20 | }
21 |
22 | export default RecenterAutomatically
23 |
--------------------------------------------------------------------------------
/frontend/src/config/client.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const backendURL = `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/v1`;
4 | const axiosClient = axios.create({
5 | baseURL: backendURL,
6 | timeout: 20000,
7 | withCredentials: true
8 | });
9 |
10 | export const setAuthToken = (token: string) => {
11 | if (token) {
12 | return (axiosClient.defaults.headers.common['Authorization'] = `Bearer ${token}`);
13 | } else {
14 | return delete axiosClient.defaults.headers.common['Authorization'];
15 | }
16 | };
17 |
18 | export default axiosClient;
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # next.js
10 | /.next/
11 | /out/
12 |
13 | # production
14 | /build
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 |
20 | # debug
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # local env files
26 | .env
27 | .env.local
28 |
29 | # vercel
30 | .vercel
31 |
32 | # typescript
33 | *.tsbuildinfo
34 | next-env.d.ts
35 |
36 | # Local Netlify folder
37 | .netlify
38 |
--------------------------------------------------------------------------------
/frontend/.prettierignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # Compiled output
4 | /build
5 | /.next
6 |
7 | # Node
8 | /node_modules
9 |
10 | # IDEs and editors
11 | .idea/
12 | .project
13 | .classpath
14 | .c9/
15 | *.launch
16 | .settings/
17 | *.sublime-workspace
18 |
19 | # Visual Studio Code
20 | .vscode/*
21 | !.vscode/settings.json
22 | !.vscode/tasks.json
23 | !.vscode/launch.json
24 | !.vscode/extensions.json
25 | .history/*
26 |
27 | # Miscellaneous
28 | /coverage
29 |
30 | # System files
31 | .DS_Store
32 | Thumbs.db
33 |
--------------------------------------------------------------------------------
/frontend/src/utils/auth.ts:
--------------------------------------------------------------------------------
1 | import { OnIncompletePaymentFoundType } from '@/constants/pi';
2 | import { IUser } from '@/constants/types';
3 | import logger from '../../logger.config.mjs';
4 |
5 | export const checkAndAutoLoginUser = (currentUser:IUser | null, authenticateUser:()=>void) => {
6 | if(!currentUser) {
7 | logger.info("User not logged in, attempting to auto-login..");
8 | authenticateUser();
9 | }
10 | }
11 |
12 | export const onIncompletePaymentFound : OnIncompletePaymentFoundType = payment => {
13 | logger.info('onIncompletePaymentFound:', { payment });
14 | }
--------------------------------------------------------------------------------
/backend/src/models/User.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from "mongoose";
2 |
3 | import { IUser } from "../types";
4 |
5 | const userSchema = new Schema(
6 | {
7 | pi_uid: {
8 | type: String,
9 | required: true,
10 | unique: true,
11 | },
12 | pi_username: {
13 | type: String,
14 | required: true,
15 | },
16 | user_name: {
17 | type: String,
18 | required: true,
19 | }
20 | }
21 | );
22 |
23 | userSchema.index({ pi_username: "text" });
24 |
25 | const User = mongoose.model("User", userSchema);
26 |
27 | export default User;
28 |
--------------------------------------------------------------------------------
/frontend/src/constants/placeholders.ts:
--------------------------------------------------------------------------------
1 | export const reviewPrompt = {
2 | comment: 'Type in your review comments',
3 | image: '/android-chrome-192x192.png', // change to map of pi logo image
4 | };
5 |
6 | export const sellerPrompt = {
7 | name: 'Type in your seller name',
8 | type: 'Pioneer',
9 | sale_items: 'Description of seller & items for sale with pi price, etc.',
10 | address: 'Help your Buyers find you by describing your address or whereabouts',
11 | description: 'I sell items via Pay with Pi.',
12 | image: '/android-chrome-192x192.png' // change to map of pi logo image
13 | }
--------------------------------------------------------------------------------
/install_docker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | apt-get update && \
4 | apt-get install -y zip apt-transport-https ca-certificates curl gnupg-agent software-properties-common && \
5 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - && \
6 | add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" && \
7 | apt-get update && \
8 | apt-get install -y docker-ce docker-ce-cli containerd.io && \
9 | curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && \
10 | chmod +x /usr/local/bin/docker-compose
11 |
--------------------------------------------------------------------------------
/frontend/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import { getRequestConfig } from 'next-intl/server';
2 | import { locales, defaultLocale } from './i18n';
3 |
4 | export default getRequestConfig(async ({ locale }) => {
5 | // locale may be undefined according to types — handle that
6 | // If locale is not a valid string or is not among supported locales, fallback:
7 | const finalLocale =
8 | typeof locale === 'string' && locales.includes(locale as typeof locales[number])
9 | ? locale
10 | : defaultLocale;
11 |
12 | return {
13 | locale: finalLocale,
14 | messages: (await import(`../messages/${finalLocale}.json`)).default
15 | };
16 | });
--------------------------------------------------------------------------------
/frontend/src/services/toggleApi.ts:
--------------------------------------------------------------------------------
1 | import axiosClient from "@/config/client";
2 | import logger from '../../logger.config.mjs';
3 |
4 | // Fetch a single toggle
5 | export const fetchToggle = async (toggleName: string) => {
6 | try {
7 | logger.info(`Fetching toggle with ID: ${toggleName}`);
8 | const { data } = await axiosClient.get(`/toggles/${toggleName}`);
9 | logger.info(`Fetch toggle successful for ${toggleName}`, { data });
10 | return data;
11 | } catch (error) {
12 | logger.error(`Fetch toggle for ${ toggleName } encountered an error:`, error);
13 | throw new Error('Failed to fetch toggle. Please try again later.');
14 | }
15 | };
--------------------------------------------------------------------------------
/frontend/src/utils/map.ts:
--------------------------------------------------------------------------------
1 | import { LatLngExpression } from "leaflet";
2 |
3 | export const toLatLngLiteral = (origin: LatLngExpression): { lat: number; lng: number } => {
4 | if (Array.isArray(origin)) {
5 | // origin is a LatLngTuple (e.g., [lat, lng])
6 | return { lat: origin[0], lng: origin[1] };
7 | } else if ('lat' in origin && 'lng' in origin) {
8 | // origin is a LatLngLiteral (e.g., { lat: number, lng: number })
9 | return origin as { lat: number; lng: number };
10 | } else {
11 | // origin is a Leaflet LatLng object
12 | const latLng = origin as L.LatLng;
13 | return { lat: latLng.lat, lng: latLng.lng };
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/frontend/logger.config.mjs:
--------------------------------------------------------------------------------
1 | import log from 'loglevel';
2 | import { logToSentry } from './sentry.client.config.mjs';
3 |
4 | export const configureLogger = () => {
5 | if (process.env.NODE_ENV === 'production') {
6 | // In production, we want to log only to Sentry
7 | log.setLevel('silent');
8 |
9 | log.error = (...args) => {
10 | logToSentry(args.join(' '));
11 | };
12 |
13 | } else if (process.env.NODE_ENV === 'development') {
14 | // In development, we want to log only to the console
15 | log.setLevel('info');
16 | }
17 | };
18 |
19 | // Initialize the logger configuration
20 | configureLogger();
21 |
22 | export default log;
23 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/sentry.client.config.mjs:
--------------------------------------------------------------------------------
1 | import * as Sentry from "@sentry/nextjs";
2 | import { replayIntegration } from '@sentry/browser';
3 |
4 | // initialize Sentry only in production environment
5 | if (process.env.NODE_ENV === 'production') {
6 | try {
7 | Sentry.init({
8 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
9 | tracesSampleRate: 0.1,
10 | replaysSessionSampleRate: 0.1,
11 | replaysOnErrorSampleRate: 1.0
12 | });
13 | } catch (error) {
14 | throw new Error(`Failed connection to Sentry: ${error.message}`);
15 | }
16 | }
17 |
18 | export const logToSentry = (message) => {
19 | Sentry.captureException(new Error(message));
20 | };
21 |
--------------------------------------------------------------------------------
/frontend/.env.development:
--------------------------------------------------------------------------------
1 | # This .env file is a template for environment variables.
2 | # Only add new variables or update existing ones here, but DO NOT include actual values.
3 | # Use placeholders in the form of "ADD YOUR $" where $ represents the purpose of the variable.
4 | # Your environment-specific values should be set in your .env.local file, which is not tracked in version control.
5 |
6 | NODE_ENV=development
7 | IMAGE_BUCKET_HOST=ADD-YOUR-IMAGE-HOST-HERE.com
8 | NEXT_PUBLIC_BACKEND_URL=http://localhost:8001
9 | NEXT_PUBLIC_SENTRY_DSN=https://e7a6a9d1d2ac4ab65091a8cb55535688@o4507779280732160.ingest.us.sentry.io/4507779383361536
10 | NEXT_PUBLIC_PI_SDK_URL=https://sdk.minepi.com/pi-sdk.js
11 |
--------------------------------------------------------------------------------
/frontend/src/app/[locale]/seller/reviews/util/ratingUtils.ts:
--------------------------------------------------------------------------------
1 | export const resolveRating = (rating: number | null)=>{
2 | switch(rating) {
3 | case 0:
4 | return {
5 | reaction: 'Despair',
6 | unicode: '😠'
7 | };
8 | case 2:
9 | return {
10 | reaction: 'Sad',
11 | unicode: '🙁'
12 | };
13 | case 3:
14 | return {
15 | reaction: 'Okay',
16 | unicode: '🙂'
17 | };
18 | case 4:
19 | return {
20 | reaction: 'Happy',
21 | unicode: '😃'
22 | };
23 | case 5:
24 | return {
25 | reaction: 'Delight',
26 | unicode: '😍'
27 | }
28 | }
29 | };
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/user.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/backend/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Debug Jest Tests",
6 | "type": "node",
7 | "request": "launch",
8 | "program": "${workspaceFolder}/node_modules/jest/bin/jest.js",
9 | "args": [
10 | "--runInBand",
11 | "--config",
12 | "${workspaceFolder}/jest.config.js",
13 | "--verbose"
14 | ],
15 | "console": "integratedTerminal",
16 | "internalConsoleOptions": "neverOpen",
17 | "env": {
18 | "NODE_ENV": "test"
19 | }
20 | },
21 | {
22 | "name": "Attach by Process ID",
23 | "processId": "${command:PickProcess}",
24 | "request": "attach",
25 | "type": "node"
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/components/skeleton/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './skeleton.css';
3 | import { SkeletonSellerRegistration } from './seller/Registration';
4 | import { SkeletonSellerReview } from './seller/Review';
5 | import { SkeletonSellerItem } from './seller/SellerItem';
6 | import { SkeletonSidebar } from './Sidebar';
7 |
8 | function Skeleton(props : any) {
9 | if (props.type === "seller_registration") return ;
10 | if (props.type === "seller_review") {
11 | return Array(8).fill(null).map((_, index) => (
12 |
13 | ));
14 | }
15 | if (props.type === "seller_item") return ;
16 | if (props.type === "sidebar") return ;
17 | }
18 |
19 | export default Skeleton;
20 |
--------------------------------------------------------------------------------
/backend/src/middlewares/isToggle.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 | import Toggle from "../models/misc/Toggle";
3 | import logger from '../config/loggingConfig';
4 |
5 | export const isToggle = (toggleName: string) => async (
6 | req: Request,
7 | res: Response,
8 | next: NextFunction
9 | ) => {
10 | try {
11 | const toggle = await Toggle.findOne({ name: toggleName });
12 |
13 | if (!toggle || !toggle.enabled) {
14 | return res.status(403).json({
15 | message: "Feature is currently disabled",
16 | });
17 | }
18 |
19 | return next();
20 | } catch (error) {
21 | logger.error(`Failed to fetch toggle ${toggleName}:`, error);
22 | return res.status(500).json({
23 | message: 'Failed to determine feature state; please try again later'
24 | });
25 | }
26 | };
--------------------------------------------------------------------------------
/backend/src/models/misc/Toggle.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from "mongoose";
2 |
3 | import { IToggle } from "../../types";
4 |
5 | const toggleSchema = new Schema(
6 | {
7 | name: {
8 | type: String,
9 | required: true,
10 | unique: true,
11 | },
12 | enabled: {
13 | type: Boolean,
14 | required: true,
15 | default: false,
16 | },
17 | description: {
18 | type: String,
19 | required: false,
20 | },
21 | },
22 | {
23 | timestamps: true, // Automatically creates createdAt and updatedAt
24 | toJSON: {
25 | transform: function (doc, ret) {
26 | delete ret._id;
27 | delete ret.__v;
28 | return ret;
29 | }
30 | }
31 | }
32 | );
33 |
34 | const Toggle = mongoose.model("Toggle", toggleSchema);
35 |
36 | export default Toggle;
--------------------------------------------------------------------------------
/backend/src/routes/home.routes.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 |
3 | const homeRoutes = Router()
4 |
5 | /**
6 | * @swagger
7 | * /:
8 | * get:
9 | * summary: Get server status
10 | * tags:
11 | * - Home
12 | * responses:
13 | * 200:
14 | * description: Successful response | Server is running
15 | * content:
16 | * application/json:
17 | * schema:
18 | * type: object
19 | * properties:
20 | * message:
21 | * type: string
22 | * example: Server is running
23 | * 500:
24 | * description: Internal server error
25 | */
26 | homeRoutes.get("/", (req, res) => {
27 | res.status(200).json({
28 | message:"Server is running"
29 | })
30 | })
31 |
32 | export default homeRoutes
33 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "dom",
5 | "dom.iterable",
6 | "esnext"
7 | ],
8 | "allowJs": true,
9 | "skipLibCheck": true,
10 | "strict": true,
11 | "noEmit": true,
12 | "esModuleInterop": true,
13 | "module": "esnext",
14 | "moduleResolution": "bundler",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve",
18 | "incremental": true,
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ],
24 | "paths": {
25 | "@/*": [
26 | "./src/*"
27 | ]
28 | }
29 | },
30 | "include": [
31 | "next-env.d.ts",
32 | "**/*.ts",
33 | "**/*.tsx",
34 | ".next/types/**/*.ts",
35 | "build/map-of-pi/types/**/*.ts"
36 | ],
37 | "exclude": [
38 | "node_modules"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/Seller/ToggleCollapse.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { FaChevronDown } from 'react-icons/fa6';
3 |
4 | interface ToggleCollapseProps {
5 | children: React.ReactNode;
6 | header: string;
7 | open?: boolean;
8 | }
9 |
10 | function ToggleCollapse({ children, header, open = false }: ToggleCollapseProps) {
11 | const [toggle, setToggle] = useState(open);
12 |
13 | return (
14 |
15 |
setToggle(!toggle)}>
16 |
{header}
17 |
21 |
22 | {toggle && children}
23 |
24 | );
25 | }
26 |
27 | export default ToggleCollapse;
--------------------------------------------------------------------------------
/frontend/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | primary: 'var(--default-primary-color)',
13 | secondary: 'var(--default-secondary-color)',
14 | tertiary: 'var(--default-tertiary-color)',
15 | background: 'var(--default-bg-color)'
16 | },
17 | height: {
18 | 100: '25rem',
19 | },
20 | width: {
21 | 88: '22rem',
22 | },
23 | spacing: {
24 | 17: '4.25rem',
25 | },
26 | zIndex: {
27 | 500: '500',
28 | },
29 | },
30 | },
31 | plugins: [],
32 | darkMode: 'class',
33 | };
34 | export default config;
35 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18
2 |
3 | # Create app directory
4 | WORKDIR /usr/src/app
5 |
6 | # Install app dependencies
7 | COPY package.json ./
8 | COPY yarn.lock ./
9 | RUN yarn install --frozen-lockfile
10 | # RUN npm ci --only=production
11 |
12 | # Bundle app source
13 | COPY ./src ./src
14 | COPY ./tsconfig.json ./tsconfig.json
15 |
16 | # Build the app
17 | RUN yarn build
18 | # Copy swagger docs yml files into build folder for serving
19 | RUN cp -r ./src/config/docs ./build/src/config/docs
20 |
21 | # Install PM2
22 | RUN yarn global add pm2
23 |
24 | # Copy PM2 configuration
25 | COPY ./docker/ecosystem.config.js ./ecosystem.config.js
26 |
27 | # Copy Node scripts
28 | COPY ./tasks ./tasks
29 |
30 | # Create log directory
31 | RUN mkdir -p log && touch log/.keep
32 |
33 | # Expose the application port
34 | EXPOSE 80
35 |
36 | # Start the application using PM2
37 | CMD [ "pm2-runtime", "ecosystem.config.js" ]
38 |
--------------------------------------------------------------------------------
/frontend/src/app/global-error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import NextError from "next/error";
4 |
5 | import { useEffect } from "react";
6 |
7 | import * as Sentry from "@sentry/nextjs";
8 | import logger from '../../logger.config.mjs';
9 |
10 | export default function GlobalError({
11 | error,
12 | }: {
13 | error: Error & { digest?: string };
14 | }) {
15 | useEffect(() => {
16 | Sentry.captureException(error);
17 | logger.error('Global error captured:', error);
18 | }, [error]);
19 |
20 | return (
21 |
22 |
23 | {/* `NextError` is the default Next.js error page component. Its type
24 | definition requires a `statusCode` prop. However, since the App Router
25 | does not expose status codes for errors, we simply pass 0 to render a
26 | generic error message. */}
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/Forms/Buttons/Buttons.module.css:
--------------------------------------------------------------------------------
1 | .add_btn {
2 | display: inline-flex;
3 | position: relative;
4 | align-items: center;
5 | justify-content: center;
6 | box-sizing: border-box;
7 | width: 56px;
8 | height: 56px;
9 | border-radius: 50%;
10 | padding: 0;
11 | border: none;
12 | fill: currentColor;
13 | text-decoration: none;
14 | cursor: pointer;
15 | overflow: visible;
16 | background-color: #0a4223;
17 | transition: box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1), opacity 15ms linear 30ms, transform 270ms 0ms cubic-bezier(0, 0, 0.2, 1);
18 | color: #ffffff;
19 | font-size: 25px;
20 | box-shadow: 0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12);
21 | }
22 |
23 | .add_btn:hover {
24 | box-shadow: 0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12);
25 | }
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18-alpine
2 |
3 | EXPOSE 80
4 |
5 | ARG ENVIRONMENT=$ENVIRONMENT
6 |
7 | RUN mkdir /app
8 |
9 | COPY ./package.json /app/package.json
10 | COPY ./package-lock.json /app/package-lock.json
11 |
12 | WORKDIR /app
13 |
14 | RUN npm ci
15 |
16 | # Copy the resources needed to build the app
17 | COPY ./src /app/src
18 | COPY ./i18n /app/i18n
19 | COPY ./messages /app/messages
20 | COPY ./public /app/public
21 | COPY ./tsconfig.json /app/tsconfig.json
22 | COPY ./next.config.mjs /app/next.config.mjs
23 | COPY ./postcss.config.mjs /app/postcss.config.mjs
24 | COPY ./tailwind.config.ts /app/tailwind.config.ts
25 | COPY ./sentry.client.config.mjs /app/sentry.client.config.mjs
26 | COPY ./logger.config.mjs /app/logger.config.mjs
27 | COPY ./context /app/context
28 | COPY ./.env.$ENVIRONMENT /app/.env
29 |
30 | RUN NODE_PATH=./src npm run build
31 |
32 | # Remove JS source maps for production
33 | RUN rm -rf ./build/static/js/*.map
34 |
35 | CMD ["npm", "run", "start"]
36 |
--------------------------------------------------------------------------------
/backend/src/index.ts:
--------------------------------------------------------------------------------
1 | import dotenv from "dotenv";
2 |
3 | import "./config/sentryConnection";
4 | import logger from "./config/loggingConfig";
5 | import { connectDB } from "./config/dbConnection";
6 | import app from "./utils/app";
7 | import { env } from "./utils/env";
8 |
9 | dotenv.config();
10 |
11 | const startServer = async () => {
12 | logger.info("Initiating server setup...");
13 | try {
14 | // Establish connection to MongoDB
15 | await connectDB();
16 |
17 | // Start the server
18 | await new Promise((resolve) => {
19 | // Start listening on the specified port
20 | app.listen(env.PORT, () => {
21 | logger.info(`Server is running on port ${env.PORT}`);
22 | resolve();
23 | });
24 | });
25 |
26 | logger.info("Server setup initiated.");
27 | } catch (error) {
28 | logger.error('Server failed to initialize:', error);
29 | }
30 | };
31 |
32 | // Start the server setup process
33 | startServer();
34 |
35 | export default app;
--------------------------------------------------------------------------------
/frontend/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | import { parseISO, format } from 'date-fns';
2 | import { toZonedTime } from 'date-fns-tz';
3 |
4 | // Function to format the date
5 | export const resolveDate = (dateString: string | undefined): { date: string; time: string } => {
6 | if (!dateString) return { date: '', time: '' };
7 |
8 | const date = parseISO(dateString);
9 | const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
10 | const localDate = toZonedTime(date, timeZone);
11 |
12 | const formattedDate = format(localDate, 'dd MMM. yyyy');
13 | const formattedTime = format(localDate, 'HH:mma').toLowerCase();
14 |
15 | return { date: formattedDate, time: formattedTime };
16 | };
17 |
18 | // Example usage
19 | // const gmtDateString = '2024-07-06T12:34:56.78Z';
20 | // const localDateTime = resolveDate(gmtDateString);
21 | // console.log(`Date: ${localDateTime.date}`); // Outputs the formatted local date
22 | // console.log(`Time: ${localDateTime.time}`); // Outputs the formatted local time
23 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/theme.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/frontend/src/components/skeleton/seller/Review.tsx:
--------------------------------------------------------------------------------
1 | import '../skeleton.css';
2 |
3 | export const SkeletonSellerReview = () => {
4 | return (
5 | <>
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
22 |
23 |
24 | >
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/info.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/src/components/skeleton/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | export const SkeletonSidebar = () => {
2 | return (
3 | <>
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
25 |
26 | >
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/reverse-proxy/Dockerfile:
--------------------------------------------------------------------------------
1 | ##
2 | ## REVERSE PROXY IMAGE
3 | ##
4 |
5 | FROM nginx:1.23.1
6 |
7 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y certbot python3-certbot-nginx
8 |
9 | COPY ./docker/nginx.conf.template /nginx.conf.template
10 | COPY ./docker/nginx-ssl.conf.template /nginx-ssl.conf.template
11 | COPY ./docker/entrypoint.sh /var/entrypoint.sh
12 |
13 | RUN chmod +x /var/entrypoint.sh
14 |
15 | # Default nginx configuration has only one worker process running. "Auto" is a better setting for scalability.
16 | # Commenting out any existing setting, and adding the desired one is more robust against new docker image versions.
17 | RUN sed -i "s/worker_processes/#worker_processes/" /etc/nginx/nginx.conf && \
18 | echo "worker_processes auto;" >> /etc/nginx/nginx.conf && \
19 | echo "worker_rlimit_nofile 16384;" >> /etc/nginx/nginx.conf
20 |
21 | # Override the default command of the base image:
22 | # See: https://github.com/nginxinc/docker-nginx/blob/1.15.7/mainline/stretch/Dockerfile#L99
23 | CMD ["/var/entrypoint.sh"]
24 |
--------------------------------------------------------------------------------
/backend/src/config/dbConnection.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 |
3 | import logger from "./loggingConfig";
4 | import { env } from "../utils/env";
5 |
6 | export const connectDB = async () => {
7 | // Set up MongoDB connection string
8 | // Don't include credentials formatting if username/password not present
9 | const mongodbCredentials = env.MONGODB_APP_USER ? `${env.MONGODB_APP_USER}:${env.MONGODB_APP_PASSWORD}@` : ''
10 | const mongodbUrl = `${env.MONGODB_URI_PREFIX}://${mongodbCredentials}${env.MONGODB_HOST}/${env.MONGODB_APP_DATABASE_NAME}?${env.MONGODB_OPTION_PARAMS}`
11 |
12 | try {
13 | // Only log the MongoDB URL in non-production environments
14 | if (env.NODE_ENV !== 'production') {
15 | logger.info(`Connecting to MongoDB with URL: ${mongodbUrl}`);
16 | }
17 | await mongoose.connect(mongodbUrl, {
18 | minPoolSize: env.MONGODB_MIN_POOL_SIZE,
19 | maxPoolSize: env.MONGODB_MAX_POOL_SIZE
20 | });
21 | logger.info("Successful connection to MongoDB.");
22 | } catch (error) {
23 | logger.error('Failed connection to MongoDB:', error);
24 | }
25 | };
--------------------------------------------------------------------------------
/backend/.env.development:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 | JWT_SECRET=MAP_OF_PI
3 | PLATFORM_API_URL=https://api.minepi.com
4 | PI_API_KEY="ADD YOUR PI API KEY"
5 | ADMIN_API_USERNAME="ADD YOUR ADMIN API USERNAME"
6 | ADMIN_API_PASSWORD="ADD YOUR ADMIN API PASSWORD"
7 |
8 | MONGODB_URL=mongodb://mapofpi_mongo:27017/demoDB
9 | MONGODB_URI_PREFIX=mongodb
10 | MONGODB_APP_USER=""
11 | MONGODB_APP_PASSWORD=""
12 | MONGODB_HOST="mapofpi_mongo:27017"
13 | MONGODB_APP_DATABASE_NAME=demoDB
14 | MONGODB_OPTION_PARAMS=""
15 |
16 | MONGODB_MIN_POOL_SIZE=1
17 | MONGODB_MAX_POOL_SIZE=5
18 |
19 | SENTRY_DSN="ADD YOUR SENTRY DSN"
20 |
21 | PORT=8001
22 | # TODO: Remove this when Map of Pi is no longer using Vercel/Cloudinary
23 | # image hosting solution
24 | UPLOAD_PATH=./../tmp/uploads
25 |
26 | DIGITAL_OCEAN_BUCKET_ACCESS_KEY=
27 | DIGITAL_OCEAN_BUCKET_SECRET_KEY=
28 | DIGITAL_OCEAN_BUCKET_NAME=
29 | DIGITAL_OCEAN_BUCKET_ORIGIN_ENDPOINT=
30 | DIGITAL_OCEAN_BUCKET_CDN_ENDPOINT=
31 |
32 | DEVELOPMENT_URL=http://localhost:8001
33 | PRODUCTION_URL=https://map-of-pi-backend-react.vercel.app/
34 | CORS_ORIGIN_URL=http://localhost:4200
35 |
--------------------------------------------------------------------------------
/backend/src/models/ReviewFeedback.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from "mongoose";
2 |
3 | import { IReviewFeedback } from "../types";
4 |
5 | import { RatingScale } from "./enums/ratingScale";
6 |
7 | const reviewFeedbackSchema = new Schema(
8 | {
9 | review_receiver_id: {
10 | type: String,
11 | required: true
12 | },
13 | review_giver_id: {
14 | type: String,
15 | required: true
16 | },
17 | reply_to_review_id: {
18 | type: String,
19 | required: false,
20 | default: null
21 | },
22 | rating: {
23 | type: Number,
24 | enum: Object.values(RatingScale).filter(value => typeof value === 'number'),
25 | required: true
26 | },
27 | comment: {
28 | type: String,
29 | required: false
30 | },
31 | image: {
32 | type: String,
33 | required: false
34 | },
35 | review_date: {
36 | type: Date,
37 | required: true
38 | }
39 | },
40 | );
41 |
42 | const ReviewFeedback = mongoose.model("Review-Feedback", reviewFeedbackSchema);
43 |
44 | export default ReviewFeedback;
45 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/location.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/backend/src/middlewares/isSellerFound.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 |
3 | import Seller from "../models/Seller";
4 | import { ISeller } from "../types";
5 |
6 | import logger from '../config/loggingConfig';
7 |
8 | declare module 'express-serve-static-core' {
9 | interface Request {
10 | currentSeller: ISeller;
11 | }
12 | }
13 |
14 | export const isSellerFound = async (
15 | req: Request,
16 | res: Response,
17 | next: NextFunction
18 | ) => {
19 | const seller_id = req.currentUser?.pi_uid;
20 |
21 | try {
22 | logger.info(`Checking if seller exists for user ID: ${seller_id}`);
23 | const currentSeller: ISeller | null = await Seller.findOne({seller_id});
24 |
25 | if (currentSeller) {
26 | req.currentSeller = currentSeller;
27 | logger.info(`Seller found: ${currentSeller._id}`);
28 | return next();
29 | } else {
30 | logger.warn(`Seller not found for user ID: ${seller_id}`);
31 | return res.status(404).json({message: "Seller not found"});
32 | }
33 | } catch (error) {
34 | logger.error('Failed to identify seller:', error);
35 | res.status(500).json({ message: 'Failed to identify | seller not found; please try again later'});
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | reverse-proxy:
4 | build: ./reverse-proxy
5 | environment:
6 | HTTPS: ${HTTPS}
7 | FRONTEND_DOMAIN_NAME: ${FRONTEND_DOMAIN_NAME}
8 | BACKEND_DOMAIN_NAME: ${BACKEND_DOMAIN_NAME}
9 | DOMAIN_VALIDATION_KEY: ${DOMAIN_VALIDATION_KEY}
10 | ports:
11 | - "80:80"
12 | - "443:443"
13 | healthcheck:
14 | test: ["CMD", "curl", "-f", "http://localhost:80"]
15 | interval: 30s
16 | timeout: 10s
17 | retries: 5
18 | volumes:
19 | - ${DATA_DIRECTORY}/reverse-proxy/etc-letsencrypt:/etc/letsencrypt/
20 |
21 | frontend:
22 | build:
23 | context: ./frontend
24 | args:
25 | ENVIRONMENT: development
26 | environment:
27 | NODE_ENV: ${NODE_ENV}
28 | BACKEND_URL: ${BACKEND_URL}
29 | NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN}
30 |
31 | backend:
32 | build: ./backend
33 | env_file: ./backend/.env
34 |
35 | mapofpi_mongo:
36 | image: mongo:6.0
37 | environment:
38 | MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME}
39 | MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
40 | ports:
41 | - 27027:27017
42 | volumes:
43 | - ${DATA_DIRECTORY}/mongo/data:/data/db
44 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/email-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/question.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/src/app/[locale]/map-center/page.tsx:
--------------------------------------------------------------------------------
1 | import dynamic from 'next/dynamic';
2 | import { Suspense } from 'react';
3 |
4 | interface MapCenterPageProps {
5 | searchParams: { entryType?: string };
6 | params: { locale: string };
7 | }
8 |
9 | type EntryType = 'search' | 'sell';
10 |
11 | interface MapCenterProps {
12 | entryType: EntryType;
13 | locale: string;
14 | }
15 |
16 | const MapCenter = (props: MapCenterProps) => {
17 | const { entryType, locale } = props
18 |
19 | // Dynamically import the MapCenter component
20 | const DynamicMapCenter = dynamic(() => import('@/components/shared/map/MapCenter'), {
21 | ssr: false,
22 | });
23 |
24 | return (
25 | /* Pass entryType as a prop with locale */
26 | );
27 | };
28 |
29 | // Must wrap in a Suspense boundary to avoid 500 error on page load
30 | const MapCenterPage = ({ searchParams, params }: MapCenterPageProps) => {
31 | const { entryType = 'search' } = searchParams;
32 | const { locale } = params;
33 |
34 | return (
35 |
36 | {/* Pass entryType as a prop with locale */}
37 |
38 | );
39 | };
40 |
41 | export default MapCenterPage;
42 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Frontend app URL and bare domain name:
2 | FRONTEND_URL=http://localhost:4200
3 | FRONTEND_DOMAIN_NAME=localhost
4 |
5 | # Backend app URL and bare domain name:
6 | BACKEND_URL=http://localhost:8001
7 | BACKEND_DOMAIN_NAME=localhost
8 |
9 | # Obtain the following 2 values on the Pi Developer Portal (open develop.pi in the Pi Browser).
10 |
11 | # Domain validation key:
12 | DOMAIN_VALIDATION_KEY=
13 | # Pi Platform API Key:
14 | PI_API_KEY=
15 |
16 | # Generate a random string, or roll your face on the keyboard to fill this value
17 | SESSION_SECRET=
18 |
19 | # MongoDB database connection details:
20 | MONGODB_DATABASE_NAME=
21 | MONGODB_USERNAME=
22 | MONGODB_PASSWORD=
23 |
24 | # This will be prepended to all container names.
25 | # Changing this will make docker-compose lose track of all your containers.
26 | # Run `docker-compose down` before changing it.
27 | COMPOSE_PROJECT_NAME=map-of-pi
28 |
29 | # Set this to either "development" or "production" (XXX "staging"?):
30 | # ENVIRONMENT=production
31 | ENVIRONMENT=development
32 |
33 | # This directory will be used to store all persistent data needed by Docker (using volumes):
34 | DATA_DIRECTORY=./docker-data
35 |
36 | # URL of the Pi Platform API - you should not need to change this.
37 | PLATFORM_API_URL=https://api.minepi.com
38 |
--------------------------------------------------------------------------------
/frontend/next.config.mjs:
--------------------------------------------------------------------------------
1 | import createNextIntlPlugin from 'next-intl/plugin';
2 | import { withSentryConfig } from "@sentry/nextjs";
3 |
4 | const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
5 |
6 | /** @type {import('next').NextConfig} */
7 | const nextConfig = {
8 | eslint: {
9 | ignoreDuringBuilds: true,
10 | },
11 | images: {
12 | remotePatterns: [
13 | {
14 | protocol: 'http',
15 | hostname: 'localhost',
16 | port: '8001',
17 | pathname: '/**',
18 | },
19 | {
20 | protocol: 'https',
21 | hostname: process.env.IMAGE_BUCKET_HOST,
22 | port: '',
23 | pathname: '/**',
24 | },
25 | ],
26 | },
27 | async rewrites() {
28 | return [
29 | {
30 | source: '/api/v1/:path*',
31 | destination: 'http://localhost:8001/api/v1/:path*',
32 | },
33 | ];
34 | }
35 | };
36 |
37 | const sentryWebpackPluginOptions = {
38 | silent: true, // suppress Sentry errors during the build process
39 | sourcemaps: {
40 | deleteSourcemapsAfterUpload: true,
41 | }
42 | };
43 |
44 | // wrap existing configuration with Sentry
45 | const configWithSentry = withSentryConfig(nextConfig, sentryWebpackPluginOptions);
46 |
47 | export default withNextIntl(configWithSentry);
48 |
--------------------------------------------------------------------------------
/frontend/src/app/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @import '~react-phone-number-input/style.css';
6 |
7 | :root {
8 | --default-primary-color: #386F4F;
9 | --default-secondary-color: #F6C367;
10 | --default-tertiary-color: #808080;
11 | --default-bg-color: #EBF1ED;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | margin: 0;
17 | padding: 0;
18 | }
19 |
20 | /* remove leaflet attribution control */
21 | .leaflet-control-attribution {
22 | display: none !important;
23 | }
24 |
25 | .seller_item_container{
26 | background: #E0E0E066;
27 | border: 1px solid #BDBDBD4D;
28 | width: Fill (335px)px;
29 | height: Hug (94px)px;
30 | padding: 8px 16px 8px 16px;
31 | gap: 12px;
32 | border-radius: 10px 10px 10px 10px;
33 | }
34 |
35 | .subheader_text{
36 | font-family: Lato;
37 | font-size: 16px;
38 | font-weight: 600;
39 | line-height: 18px;
40 | }
41 |
42 | .normal_btn{
43 | border: 1px solid var(--default-primary-color);
44 | width: Fixed (122px)px;
45 | height: Fixed (38px)px;
46 | padding: var(--space-0) var(--space-8) var(--space-0) var(--space-8);
47 | gap: var(--space-3);
48 | border-radius: 6px;
49 | border: 1px;
50 | }
51 |
52 | .disabled {
53 | pointer-events: none;
54 | /* opacity: 0.5; */
55 | color: grey
56 | }
57 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/contact.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/Review/TrustMeter.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import React, {useEffect, useState} from 'react';
3 |
4 | type TrustMeterProps = {
5 | ratings: number;
6 | hideLabel?: boolean;
7 | };
8 |
9 | const TrustMeter: React.FC = ({ ratings, hideLabel }) => {
10 | const [ratingImage, setRatingImage] = useState('')
11 |
12 | useEffect(() => {
13 | if (ratings === 0) {
14 | setRatingImage(`/images/shared/review_ratings/trust-o-meter_0${ratings}0.PNG`);
15 | } else if (ratings === 50 || ratings === 80) {
16 | setRatingImage(`/images/shared/review_ratings/trust-o-meter_0${ratings}.PNG`);
17 | } else {
18 | setRatingImage(`/images/shared/review_ratings/trust-o-meter_${ratings}.PNG`);
19 | }
20 | }, [ratings]);
21 |
22 | return (
23 |
37 | );
38 | };
39 |
40 | export default TrustMeter;
41 |
--------------------------------------------------------------------------------
/backend/src/config/sentryConnection.ts:
--------------------------------------------------------------------------------
1 | import { env } from "../utils/env";
2 | import * as Sentry from "@sentry/node";
3 | import { nodeProfilingIntegration } from "@sentry/profiling-node";
4 | import { transports } from "winston";
5 |
6 | // initialize Sentry only in production environment
7 | if (env.NODE_ENV === 'production') {
8 | try {
9 | // initialize Sentry
10 | Sentry.init({
11 | dsn: env.SENTRY_DSN,
12 | integrations: [
13 | nodeProfilingIntegration(),
14 | ],
15 | tracesSampleRate: 0.1, // adjust this based on your need for performance monitoring
16 | profilesSampleRate: 1.0
17 | });
18 |
19 | } catch (error: any) {
20 | throw new Error(`Failed connection to Sentry: ${error}`);
21 | }
22 | }
23 |
24 | // create a custom Sentry transport for Winston in production
25 | class SentryTransport extends transports.Stream {
26 | log(info: any, callback: () => void) {
27 | setImmediate(() => this.emit('logged', info));
28 |
29 | if (info.level === 'error') {
30 | if (info.error instanceof Error) {
31 | Sentry.captureException(info.error);
32 | } else {
33 | // fallback to message if error is not passed properly
34 | Sentry.captureMessage(info.message || JSON.stringify(info), 'error');
35 | }
36 | callback();
37 | return true;
38 | }
39 | }
40 | }
41 |
42 | export { SentryTransport };
43 |
--------------------------------------------------------------------------------
/backend/src/middlewares/isUserSettingsFound.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 |
3 | import UserSettings from "../models/UserSettings";
4 | import { IUserSettings } from "../types";
5 |
6 | import logger from '../config/loggingConfig'
7 |
8 | declare module 'express-serve-static-core' {
9 | interface Request {
10 | currentUserSettings: IUserSettings;
11 | }
12 | }
13 |
14 | export const isUserSettingsFound = async (
15 | req: Request,
16 | res: Response,
17 | next: NextFunction
18 | ) => {
19 | const userSettingsId = req.currentUser?.pi_uid;
20 |
21 | try {
22 | logger.info(`Checking if user settings exist for user ID: ${userSettingsId}`);
23 | const currentUserSettings: IUserSettings | null = await UserSettings.findOne({user_settings_id: userSettingsId});
24 |
25 | if (currentUserSettings) {
26 | req.currentUserSettings = currentUserSettings;
27 | logger.info(`User settings found for user ID: ${userSettingsId}`);
28 | return next();
29 | } else {
30 | logger.warn(`User settings not found for user ID: ${userSettingsId}`);
31 | return res.status(404).json({message: "User Settings not found"});
32 | }
33 | } catch (error) {
34 | logger.error('Failed to identify user settings:', error);
35 | res.status(500).json({ message: 'Failed to identify | user settings not found; please try again later'});
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/backend/src/models/SellerItem.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema, Types } from "mongoose";
2 |
3 | import { ISellerItem } from "../types";
4 | import { StockLevelType } from "./enums/stockLevelType";
5 |
6 | const sellerItemSchema = new Schema(
7 | {
8 | seller_id: {
9 | type: String,
10 | required: true,
11 | },
12 | name: {
13 | type: String,
14 | required: true,
15 | },
16 | description: {
17 | type: String,
18 | default: null
19 | },
20 | price: {
21 | type: Types.Decimal128,
22 | required: true,
23 | default: 0.01
24 | },
25 | stock_level: {
26 | type: String,
27 | enum: Object.values(StockLevelType).filter(value => typeof value === 'string')
28 | },
29 | image: {
30 | type: String,
31 | required: false,
32 | default: null
33 | },
34 | duration: {
35 | type: Number,
36 | default: 1,
37 | min: 1
38 | },
39 | expired_by: {
40 | type: Date,
41 | required: true,
42 | }
43 | },
44 | {
45 | timestamps: true, // Enables createdAt and updatedAt
46 | }
47 | );
48 |
49 | sellerItemSchema.index({ seller_id: 1 });
50 | sellerItemSchema.index({ name: 'text', description: 'text' });
51 |
52 | // Creating the Seller model from the schema
53 | const SellerItem = mongoose.model("Seller-Item", sellerItemSchema);
54 |
55 | export default SellerItem;
--------------------------------------------------------------------------------
/backend/src/middlewares/isPioneerFound.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 |
3 | import { platformAPIClient } from "../config/platformAPIclient";
4 | import logger from '../config/loggingConfig';
5 |
6 | export const isPioneerFound = async (
7 | req: Request,
8 | res: Response,
9 | next: NextFunction
10 | ) => {
11 | const authHeader = req.headers.authorization;
12 | const tokenFromHeader = authHeader && authHeader.split(" ")[1];
13 |
14 | try {
15 | logger.info("Verifying user's access token with the /me endpoint.");
16 | // Verify the user's access token with the /me endpoint:
17 | const me = await platformAPIClient.get(`/v2/me`, {
18 | headers: { 'Authorization': `Bearer ${ tokenFromHeader }` }
19 | });
20 |
21 | if (me && me.data) {
22 | const user = {
23 | pi_uid: me.data.uid,
24 | pi_username: me.data.username,
25 | user_name: me.data.username
26 | }
27 | req.body.user = user;
28 | logger.info(`Pioneer found: ${user.pi_uid} - ${user.pi_username}`);
29 | return next();
30 | } else {
31 | logger.warn("Pioneer not found.");
32 | return res.status(404).json({message: "Pioneer not found"});
33 | }
34 | } catch (error) {
35 | logger.error('Failed to identify pioneer:', error);
36 | res.status(500).json({ message: 'Failed to identify | pioneer not found; please try again later'});
37 | }
38 | };
39 |
--------------------------------------------------------------------------------
/frontend/src/app/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { NextIntlClientProvider } from 'next-intl';
2 | import { setRequestLocale, getMessages } from 'next-intl/server';
3 | import { Lato } from 'next/font/google';
4 | import { ToastContainer } from 'react-toastify';
5 | import { locales } from '../../../i18n/i18n';
6 | import { Providers } from '../providers';
7 | import Navbar from '@/components/shared/navbar/Navbar';
8 | import logger from '../../../logger.config.mjs';
9 |
10 | const lato = Lato({ weight: '400', subsets: ['latin'], display: 'swap' });
11 |
12 | export const dynamic = 'force-dynamic';
13 |
14 | export function generateStaticParams() {
15 | return locales.map((locale) => ({ locale }));
16 | }
17 |
18 | export default async function LocaleLayout({
19 | children,
20 | params: { locale }
21 | }: {
22 | children: React.ReactNode;
23 | params: { locale: string };
24 | }) {
25 | // Ensure next-intl sees the selected locale
26 | setRequestLocale(locale);
27 |
28 | // Load messages on the server
29 | const messages = await getMessages({ locale });
30 |
31 | // log the locale and messages loading
32 | logger.info(`Rendering LocaleLayout for locale: ${locale}`);
33 | logger.info('Messages loaded successfully.');
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | {children}
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
--------------------------------------------------------------------------------
/backend/src/utils/multer.ts:
--------------------------------------------------------------------------------
1 | import multer from "multer";
2 | import multerS3 from "multer-s3";
3 | import { S3 } from "@aws-sdk/client-s3";
4 |
5 | import path from "path";
6 | import crypto from "crypto";
7 |
8 | import { env } from "./env";
9 |
10 | // Set S3 endpoint to DigitalOcean Spaces
11 | const s3 = new S3({
12 | forcePathStyle: false, // Configures to use subdomain/virtual calling format.
13 | endpoint: env.DIGITAL_OCEAN_BUCKET_ORIGIN_ENDPOINT,
14 | region: "us-east-1",
15 | credentials: {
16 | accessKeyId: env.DIGITAL_OCEAN_BUCKET_ACCESS_KEY,
17 | secretAccessKey: env.DIGITAL_OCEAN_BUCKET_SECRET_KEY
18 | }
19 | });
20 |
21 | const getExtension = (fileName: string) => path.extname(fileName).toLowerCase();
22 |
23 | const storage = multerS3({
24 | s3,
25 | bucket: env.DIGITAL_OCEAN_BUCKET_NAME,
26 | acl: 'public-read',
27 | contentType: multerS3.AUTO_CONTENT_TYPE,
28 | key: function (request: any, file: any, callback: any) {
29 | const extension = getExtension(file.originalname);
30 | callback(null, `${crypto.randomUUID()}${extension}`);
31 | }
32 | });
33 |
34 | const fileFilter = (
35 | req: Express.Request,
36 | file: Express.Multer.File,
37 | cb: multer.FileFilterCallback
38 | ): void => {
39 | const extension = getExtension(file.originalname);
40 | if (!(extension === ".jpg" || extension === ".jpeg" || extension === ".png")) {
41 | const error: any = {
42 | code: "INVALID_FILE_TYPE",
43 | message: "Wrong format for file",
44 | };
45 | cb(new Error(error));
46 | return;
47 | }
48 | cb(null, true);
49 | };
50 |
51 | const upload = multer({
52 | storage,
53 | fileFilter
54 | });
55 |
56 | export default upload;
--------------------------------------------------------------------------------
/backend/src/utils/app.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import cookieParser from 'cookie-parser';
3 | import cors from "cors"
4 | import dotenv from "dotenv";
5 | import path from "path";
6 |
7 | import docRouter from "../config/swagger";
8 | import requestLogger from "../middlewares/logger";
9 |
10 | import appRouter from "../routes";
11 | import homeRoutes from "../routes/home.routes";
12 | import userRoutes from "../routes/user.routes";
13 | import userPreferencesRoutes from "../routes/userPreferences.routes";
14 | import sellerRoutes from "../routes/seller.routes";
15 | import reviewFeedbackRoutes from "../routes/reviewFeedback.routes";
16 | import mapCenterRoutes from "../routes/mapCenter.routes";
17 | import toggleRoutes from "../routes/toggle.routes";
18 |
19 | dotenv.config();
20 |
21 | const app = express();
22 |
23 | app.use(express.urlencoded({ extended: true }));
24 | app.use(express.json());
25 | app.use(requestLogger);
26 |
27 | app.use(cors({
28 | origin: process.env.CORS_ORIGIN_URL,
29 | credentials: true
30 | }));
31 | app.use(cookieParser());
32 |
33 | // serve static files for Swagger documentation
34 | app.use('/api/docs', express.static(path.join(__dirname, '../config/docs')));
35 |
36 | // Swagger OpenAPI documentation
37 | app.use("/api/docs", docRouter);
38 |
39 | app.use("/api/v1", appRouter);
40 | app.use("/api/v1/users", userRoutes);
41 | app.use("/api/v1/user-preferences", userPreferencesRoutes);
42 | app.use("/api/v1/sellers", sellerRoutes);
43 | app.use("/api/v1/review-feedback", reviewFeedbackRoutes);
44 | app.use("/api/v1/map-center", mapCenterRoutes);
45 | app.use("/api/v1/toggles", toggleRoutes);
46 |
47 | app.use("/", homeRoutes);
48 |
49 | export default app;
--------------------------------------------------------------------------------
/backend/src/config/loggingConfig.ts:
--------------------------------------------------------------------------------
1 | import { createLogger, format, transports, Logger } from "winston";
2 |
3 | import { env } from "../utils/env";
4 | import { SentryTransport } from "./sentryConnection";
5 |
6 | // define the logging configuration logic
7 | export const getLoggerConfig = (): { level: string; format: any; transports: any[] } => {
8 | let defaultLogLevel: string = '';
9 | let logFormat: any;
10 | const loggerTransports: any[] = [];
11 |
12 | const consoleLogFormat = format.combine(format.colorize(), format.simple());
13 | const consoleLogTransport = new transports.Console({ format: consoleLogFormat });
14 | if (env.NODE_ENV === 'development' || env.NODE_ENV === 'sandbox') {
15 | defaultLogLevel = 'info';
16 | logFormat = consoleLogFormat;
17 | loggerTransports.push(consoleLogTransport);
18 | } else if (env.NODE_ENV === 'production') {
19 | defaultLogLevel = 'error';
20 | logFormat = format.combine(
21 | format.errors({ stack: true }),
22 | format.timestamp(),
23 | format.json()
24 | );
25 | loggerTransports.push(new SentryTransport({ stream: process.stdout })); // Log to Sentry
26 | loggerTransports.push(consoleLogTransport); // Log to pod as well
27 | }
28 |
29 | const logLevel = env.LOG_LEVEL || defaultLogLevel;
30 | return { level: logLevel, format: logFormat, transports: loggerTransports };
31 | };
32 |
33 | // Create the logger using the configuration
34 | const loggerConfig = getLoggerConfig();
35 |
36 | // set up Winston logger accordingly
37 | const logger: Logger = createLogger({
38 | level: loggerConfig.level,
39 | format: loggerConfig.format,
40 | transports: loggerConfig.transports
41 | });
42 |
43 | export default logger;
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/language.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/social-media/tiktok-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 |
10 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/About/privacy-policy/PrivacyPolicy.module.css:
--------------------------------------------------------------------------------
1 | .model_container {
2 | position: fixed;
3 | top: 0;
4 | left: 0;
5 | width: 100%;
6 | height: 100%;
7 | background-color: #00000080;
8 | z-index: 550;
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | }
13 |
14 | .model_body {
15 | position: relative;
16 | width: 500px;
17 | max-width: 500px;
18 | height: 90vh;
19 | background-color: #fff;
20 | border-radius: 10px;
21 | box-shadow: 0 0 10px #0003;
22 | z-index: 600;
23 | cursor: crosshair;
24 | overflow-y: auto;
25 | }
26 |
27 | .model_event {
28 | width: 100%;
29 | height: 100%;
30 | position: absolute;
31 | background-color: transparent;
32 | }
33 |
34 | .privacy_policy_content {
35 | text-align: left;
36 | padding: 50px;
37 | }
38 |
39 | .privacy_policy_content h1,
40 | .privacy_policy_content h2 {
41 | color: #1d724b;
42 | font-size: 20px;
43 | }
44 |
45 | .privacy_policy_content h2 {
46 | border-bottom: 1px solid #ccc;
47 | padding-bottom: 5px;
48 | margin-bottom: 16px;
49 | font-weight: 500;
50 | }
51 |
52 | .privacy_policy_content h4 {
53 | font-size: 14px;
54 | font-weight: bold;
55 | }
56 |
57 | .privacy_policy_content p {
58 | margin-bottom: 20px;
59 | }
60 |
61 | .privacy_policy_content ul {
62 | list-style-type: none;
63 | margin-bottom: 20px;
64 | font-size: 14px;
65 | text-align: justify;
66 | }
67 |
68 | .privacy_policy_content li {
69 | margin-bottom: 10px;
70 | }
71 |
72 | @media screen and (max-width: 500px) {
73 | .model_body {
74 | min-width: 80%;
75 | width: 90%;
76 | height: 70vh;
77 | }
78 |
79 | .privacy_policy_content {
80 | padding: 20px;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/backend/src/helpers/jwt.ts:
--------------------------------------------------------------------------------
1 | import jwt from "jsonwebtoken";
2 |
3 | import { IUser } from "../types";
4 | import User from "../models/User";
5 | import { env } from "../utils/env";
6 |
7 | import logger from '../config/loggingConfig';
8 |
9 | export const generateUserToken = (user: IUser) => {
10 | try {
11 | logger.info(`Generating token for user: ${user.pi_uid}`);
12 | const token = jwt.sign({ userId: user.pi_uid, _id: user._id }, env.JWT_SECRET, {
13 | expiresIn: "1d", // 1 day
14 | });
15 | logger.info(`Successfully generated token for user: ${user.pi_uid}`);
16 | return token;
17 | } catch (error) {
18 | logger.error(`Failed to generate user token for piUID ${ user.pi_uid }:`, error);
19 | throw new Error('Failed to generate user token; please try again');
20 | }
21 | };
22 |
23 | export const decodeUserToken = async (token: string) => {
24 | try {
25 | logger.info(`Decoding token.`);
26 | const decoded = jwt.verify(token, env.JWT_SECRET) as { userId: string };
27 | if (!decoded.userId) {
28 | logger.warn(`Invalid token: Missing userID.`);
29 | throw new Error("Invalid token: Missing userID.");
30 | }
31 | logger.info(`Finding user associated with token: ${decoded.userId}`);
32 | const associatedUser = await User.findOne({pi_uid: decoded.userId});
33 | if (!associatedUser) {
34 | logger.warn(`User not found for token: ${decoded.userId}`);
35 | throw new Error("User not found.");
36 | }
37 | logger.info(`Successfully decoded token and found user: ${associatedUser.pi_uid}`);
38 | return associatedUser;
39 | } catch (error) {
40 | logger.error('Failed to decode user token:', error);
41 | throw new Error('Failed to decode user token; please try again');
42 | }
43 | };
44 |
--------------------------------------------------------------------------------
/backend/test/middlewares/verifyToken.spec.ts:
--------------------------------------------------------------------------------
1 | import { verifyAdminToken } from "../../src/middlewares/verifyToken";
2 |
3 | describe("verifyAdminToken function", () => {
4 | let req: any;
5 | let res: any;
6 | let mockNext: jest.Mock;
7 |
8 | process.env.ADMIN_API_USERNAME = "validUsername";
9 | process.env.ADMIN_API_PASSWORD = "validPassword";
10 |
11 | beforeEach(() => {
12 | req = {
13 | headers: {},
14 | };
15 |
16 | res = {
17 | status: jest.fn().mockReturnThis(),
18 | json: jest.fn(),
19 | };
20 |
21 | mockNext = jest.fn();
22 | });
23 |
24 | it("should return 401 if no admin credentials are provided", () => {
25 | verifyAdminToken(req, res, mockNext);
26 |
27 | expect(res.status).toHaveBeenCalledWith(401);
28 | expect(res.json).toHaveBeenCalledWith({
29 | message: "Unauthorized",
30 | });
31 | expect(mockNext).not.toHaveBeenCalled();
32 | });
33 |
34 | it("should return 401 if incorrect admin credentials are provided", () => {
35 | req.headers.authorization = `Basic ${Buffer.from("invalidUsername:invalidPassword").toString("base64")}`;
36 |
37 | verifyAdminToken(req, res, mockNext);
38 |
39 | expect(res.status).toHaveBeenCalledWith(401);
40 | expect(res.json).toHaveBeenCalledWith({
41 | message: "Unauthorized",
42 | });
43 | expect(mockNext).not.toHaveBeenCalled();
44 | });
45 |
46 | it("should pass middleware if admin credentials are valid", () => {
47 | req.headers.authorization = `Basic ${Buffer.from("validUsername:validPassword").toString("base64")}`;
48 |
49 | verifyAdminToken(req, res, mockNext);
50 |
51 | expect(mockNext).toHaveBeenCalled();
52 | expect(res.status).not.toHaveBeenCalled();
53 | expect(res.json).not.toHaveBeenCalled();
54 | });
55 | });
--------------------------------------------------------------------------------
/reverse-proxy/docker/nginx.conf.template:
--------------------------------------------------------------------------------
1 | #
2 | # Frontend config:
3 | #
4 | server {
5 | # Use Docker's built-in DNS resolver to enable resolving container hostnames used in the proxy_pass
6 | # directives below.
7 | # https://stackoverflow.com/questions/35744650/docker-network-nginx-resolver
8 | resolver 127.0.0.11 ipv6=off;
9 |
10 | # Enable nginx to start even when upstream hosts are unreachable (i.e containers not started yet)
11 | # https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/
12 | set $frontend_upstream http://frontend;
13 |
14 | server_name ${FRONTEND_DOMAIN_NAME};
15 | listen 80;
16 |
17 | location /.well-known/acme-challenge/ {
18 | root /var/www/certbot;
19 | }
20 |
21 | location / {
22 | proxy_pass $frontend_upstream;
23 | }
24 | }
25 |
26 | #
27 | # Backend config:
28 | #
29 | server {
30 | # Use Docker's built-in DNS resolver to enable resolving container hostnames used in the proxy_pass
31 | # directives below.
32 | # https://stackoverflow.com/questions/35744650/docker-network-nginx-resolver
33 | resolver 127.0.0.11 ipv6=off;
34 |
35 | # Enable nginx to start even when upstream hosts are unreachable (i.e containers not started yet)
36 | # https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/
37 | set $backend_upstream http://backend:8000;
38 |
39 | server_name ${BACKEND_DOMAIN_NAME};
40 | listen 80;
41 |
42 | # Enable nginx to start even when upstream hosts are unreachable (i.e containers not started yet)
43 | # https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/
44 |
45 | location /.well-known/acme-challenge/ {
46 | root /var/www/certbot;
47 | }
48 |
49 | location / {
50 | proxy_pass $backend_upstream;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/src/components/shared/About/terms-of-service/TermOfService.module.css:
--------------------------------------------------------------------------------
1 | .model_container {
2 | position: fixed;
3 | top: 0;
4 | left: 0;
5 | width: 100%;
6 | height: 100%;
7 | background-color: #00000080;
8 | z-index: 550;
9 | display: flex;
10 | align-items: center;
11 | justify-content: center;
12 | }
13 |
14 | .model_body {
15 | position: relative;
16 | width: 500px;
17 | max-width: 500px;
18 | height: 90vh;
19 | background-color: #fff;
20 | border-radius: 10px;
21 | box-shadow: 0 0 10px #0003;
22 | z-index: 600;
23 | cursor: crosshair;
24 | overflow-y: auto;
25 | }
26 |
27 | .model_event {
28 | width: 100%;
29 | height: 100%;
30 | position: absolute;
31 | background-color: transparent;
32 | }
33 |
34 | .terms_of_service_content {
35 | text-align: left;
36 | padding: 50px;
37 | }
38 |
39 | .terms_of_service_content h1,
40 | .terms_of_service_content h2 {
41 | color: #1d724b;
42 | font-size: 20px;
43 | }
44 |
45 | .terms_of_service_content h2 {
46 | border-bottom: 1px solid #ccc;
47 | padding-bottom: 5px;
48 | margin-bottom: 16px;
49 | font-weight: 500;
50 | }
51 |
52 | .terms_of_service_content h4 {
53 | font-size: 14px;
54 | font-weight: bold;
55 | }
56 |
57 | .terms_of_service_content p {
58 | margin-bottom: 20px;
59 | }
60 |
61 | .terms_of_service_content ul {
62 | list-style-type: none;
63 | margin-bottom: 20px;
64 | font-size: 14px;
65 | text-align: justify;
66 | }
67 |
68 | .terms_of_service_content li {
69 | margin-bottom: 10px;
70 | }
71 |
72 | .terms_of_service_content a {
73 | font-weight: bold;
74 | }
75 |
76 |
77 | @media screen and (max-width: 500px) {
78 | .model_body {
79 | min-width: 80%;
80 | width: 90%;
81 | height: 70vh;
82 | }
83 |
84 | .terms_of_service_content {
85 | padding: 20px;
86 | }
87 | }
--------------------------------------------------------------------------------
/reverse-proxy/docker/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | echo; echo
4 | echo "reverse-proxy: Initializing.."
5 | echo " - HTTPS: ${HTTPS}"
6 | echo " - FRONTEND_DOMAIN_NAME: ${FRONTEND_DOMAIN_NAME}"
7 | echo " - BACKEND_DOMAIN_NAME: ${BACKEND_DOMAIN_NAME}"
8 |
9 | set -e
10 |
11 | # Directory used by certbot to serve certificate requests challenges:
12 | mkdir -p /var/www/certbot
13 |
14 | if [ $HTTPS = "true" ]; then
15 | echo "Starting in SSL mode"
16 |
17 | rm /etc/nginx/conf.d/default.conf
18 |
19 | echo
20 | echo "Obtaining SSL certificate for frontend domain name: ${FRONTEND_DOMAIN_NAME}"
21 | certbot certonly --noninteractive --agree-tos --register-unsafely-without-email --nginx -d ${FRONTEND_DOMAIN_NAME}
22 |
23 | echo
24 | echo "Obtaining SSL certificate for backend domain name: ${BACKEND_DOMAIN_NAME}"
25 | certbot certonly --noninteractive --agree-tos --register-unsafely-without-email --nginx -d ${BACKEND_DOMAIN_NAME}
26 |
27 | # The above certbot command will start the nginx service in the background as a service.
28 | # However, we need the `nginx -g "daemon off;"` to be the main nginx process running on the container, and
29 | # we need it to be able to start listening on ports 80/443. If we don't stop the nginx process here, we'll
30 | # encounter the following error: `nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address already in use)`.
31 | service nginx stop
32 |
33 | envsubst '$FRONTEND_DOMAIN_NAME $BACKEND_DOMAIN_NAME $DOMAIN_VALIDATION_KEY' < /nginx-ssl.conf.template > /etc/nginx/conf.d/default.conf
34 | else
35 | echo "Starting in http mode"
36 | envsubst '$FRONTEND_DOMAIN_NAME $BACKEND_DOMAIN_NAME $DOMAIN_VALIDATION_KEY' < /nginx.conf.template > /etc/nginx/conf.d/default.conf
37 | fi
38 |
39 | # Call the command that the base image was initally supposed to run
40 | # See: XXX
41 | nginx -g "daemon off;"
42 |
--------------------------------------------------------------------------------
/backend/test/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 | import { MongoMemoryServer } from 'mongodb-memory-server';
3 |
4 | import * as mockData from './mockData.json';
5 | import User from '../src/models/User';
6 | import UserSettings from '../src/models/UserSettings';
7 | import Seller from '../src/models/Seller';
8 | import SellerItem from '../src/models/SellerItem';
9 | import ReviewFeedback from '../src/models/ReviewFeedback';
10 | import Toggle from '../src/models/misc/Toggle';
11 |
12 | // mock the Winston logger
13 | jest.mock('../src/config/loggingConfig', () => ({
14 | debug: jest.fn(),
15 | info: jest.fn(),
16 | warn: jest.fn(),
17 | error: jest.fn(),
18 | }));
19 |
20 | // allow ample time to start running tests
21 | jest.setTimeout(100000);
22 |
23 | // MongoDB memory server setup
24 | let mongoServer: MongoMemoryServer;
25 |
26 | beforeAll(async () => {
27 | try {
28 | mongoServer = await MongoMemoryServer.create();
29 | const uri = mongoServer.getUri();
30 | await mongoose.connect(uri, { dbName: 'mapofpi-test-db' });
31 |
32 | // Load the mock data into Map of PI DB collections
33 | await User.insertMany(mockData.users);
34 | await UserSettings.createIndexes();
35 | await UserSettings.insertMany(mockData.userSettings);
36 | // Ensure indexes are created for the schema models before running tests
37 | await Seller.createIndexes();
38 | await Seller.insertMany(mockData.sellers);
39 | await SellerItem.createIndexes();
40 | await SellerItem.insertMany(mockData.sellerItems);
41 | await ReviewFeedback.insertMany(mockData.reviews);
42 | await Toggle.insertMany(mockData.toggle);
43 | } catch (error) {
44 | console.error('Failed to start MongoMemoryServer', error);
45 | throw error;
46 | }
47 | });
48 |
49 | afterAll(async () => {
50 | await mongoose.disconnect();
51 | await mongoServer.stop();
52 | });
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "map-of-pi-backend",
3 | "version": "1.0.0",
4 | "author": "Map of Pi Team",
5 | "description": "Map of Pi Backend",
6 | "license": "PiOS",
7 | "main": "index.js",
8 | "private": true,
9 | "scripts": {
10 | "build": "tsc",
11 | "dev": "nodemon src/index.ts",
12 | "start": "node build/index.js",
13 | "test": "jest --detectOpenHandles --coverage"
14 | },
15 | "dependencies": {
16 | "@aws-sdk/client-s3": "^3.651.1",
17 | "@sentry/integrations": "^7.114.0",
18 | "@sentry/node": "^8.49.0",
19 | "@sentry/profiling-node": "^8.49.0",
20 | "axios": "^1.8.2",
21 | "body-parser": "^1.20.2",
22 | "bottleneck": "^2.19.5",
23 | "cookie-parser": "^1.4.6",
24 | "cors": "^2.8.5",
25 | "dotenv": "^16.4.5",
26 | "express": "^4.19.2",
27 | "jsonwebtoken": "^9.0.2",
28 | "mongoose": "^8.9.5",
29 | "multer": "^1.4.5-lts.1",
30 | "multer-s3": "^3.0.1",
31 | "swagger-jsdoc": "^6.2.8",
32 | "swagger-ui-dist": "^5.17.14",
33 | "swagger-ui-express": "^5.0.0",
34 | "winston": "^3.14.2"
35 | },
36 | "devDependencies": {
37 | "@types/cookie-parser": "^1.4.7",
38 | "@types/cors": "^2.8.17",
39 | "@types/express": "^4.17.21",
40 | "@types/jest": "^29.5.13",
41 | "@types/jsonwebtoken": "^9.0.6",
42 | "@types/mongodb-memory-server": "^2.3.0",
43 | "@types/multer": "^1.4.11",
44 | "@types/multer-s3": "^3.0.3",
45 | "@types/node": "^20.12.12",
46 | "@types/nodemailer": "^6.4.15",
47 | "@types/supertest": "^6.0.2",
48 | "@types/swagger-jsdoc": "^6.0.4",
49 | "@types/swagger-ui-dist": "^3.30.5",
50 | "@types/swagger-ui-express": "^4.1.6",
51 | "jest": "^29.7.0",
52 | "mongodb-memory-server": "^10.0.0",
53 | "nodemon": "^3.1.0",
54 | "supertest": "^7.0.0",
55 | "ts-jest": "^29.2.5",
56 | "ts-node": "^10.9.2",
57 | "typescript": "^5.4.5"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/backend/src/utils/env.ts:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 |
3 | // Load environment variables from .env file
4 | dotenv.config();
5 |
6 | export const env = {
7 | PORT: process.env.PORT || 8001,
8 | NODE_ENV: process.env.NODE_ENV || 'development',
9 | JWT_SECRET: process.env.JWT_SECRET || 'default_secret',
10 | DIGITAL_OCEAN_BUCKET_ORIGIN_ENDPOINT: process.env.DIGITAL_OCEAN_BUCKET_ORIGIN_ENDPOINT || '',
11 | DIGITAL_OCEAN_BUCKET_CDN_ENDPOINT: process.env.DIGITAL_OCEAN_BUCKET_CDN_ENDPOINT || '',
12 | DIGITAL_OCEAN_BUCKET_ACCESS_KEY: process.env.DIGITAL_OCEAN_BUCKET_ACCESS_KEY || '',
13 | DIGITAL_OCEAN_BUCKET_SECRET_KEY: process.env.DIGITAL_OCEAN_BUCKET_SECRET_KEY || '',
14 | DIGITAL_OCEAN_BUCKET_NAME: process.env.DIGITAL_OCEAN_BUCKET_NAME || '',
15 | LOG_LEVEL: process.env.LOG_LEVEL || '',
16 | PI_API_KEY: process.env.PI_API_KEY || '',
17 | PLATFORM_API_URL: process.env.PLATFORM_API_URL || '',
18 | ADMIN_API_USERNAME: process.env.ADMIN_API_USERNAME || '',
19 | ADMIN_API_PASSWORD: process.env.ADMIN_API_PASSWORD || '',
20 | UPLOAD_PATH: process.env.UPLOAD_PATH || '', // TODO: Remove when Map of Pi is no longer using Vercel/Cloudinary image hosting solution
21 | MONGODB_URI_PREFIX: process.env.MONGODB_URI_PREFIX || '',
22 | MONGODB_APP_USER: process.env.MONGODB_APP_USER || '',
23 | MONGODB_APP_PASSWORD: process.env.MONGODB_APP_PASSWORD || '',
24 | MONGODB_HOST: process.env.MONGODB_HOST || '',
25 | MONGODB_APP_DATABASE_NAME: process.env.MONGODB_APP_DATABASE_NAME || '',
26 | MONGODB_OPTION_PARAMS: process.env.MONGODB_OPTION_PARAMS || '',
27 | MONGODB_MIN_POOL_SIZE: Number(process.env.MONGODB_MIN_POOL_SIZE) || 1,
28 | MONGODB_MAX_POOL_SIZE: Number(process.env.MONGODB_MAX_POOL_SIZE) || 5,
29 | SENTRY_DSN: process.env.SENTRY_DSN || '',
30 | DEVELOPMENT_URL: process.env.DEVELOPMENT_URL || '',
31 | PRODUCTION_URL: process.env.PRODUCTION_URL || '',
32 | CORS_ORIGIN_URL: process.env.CORS_ORIGIN_URL || ''
33 | };
--------------------------------------------------------------------------------
/backend/test/controllers/userController.spec.ts:
--------------------------------------------------------------------------------
1 | import { deleteUser } from '../../src/controllers/userController';
2 | import * as userService from '../../src/services/user.service';
3 |
4 | jest.mock('../../src/services/user.service', () => ({
5 | deleteUser: jest.fn(),
6 | }));
7 |
8 | describe('UserController', () => {
9 | let req: any;
10 | let res: any;
11 |
12 | beforeEach(() => {
13 | req = {
14 | currentUser: {
15 | pi_uid: '0a0a0a-0a0a-0a0a',
16 | },
17 | };
18 |
19 | res = {
20 | status: jest.fn().mockReturnThis(),
21 | json: jest.fn(),
22 | };
23 | });
24 |
25 | describe('deleteUser function', () => {
26 | it('should delete user and return successful message', async () => {
27 | const expectedDeletedData = {
28 | user: { pi_uid: '0a0a0a-0a0a-0a0a' },
29 | sellers: [{ seller_id: '0a0a0a-0a0a-0a0a' }],
30 | userSetting: { user_settings_id: '0a0a0a-0a0a-0a0a' },
31 | };
32 |
33 | (userService.deleteUser as jest.Mock).mockResolvedValue(expectedDeletedData);
34 |
35 | await deleteUser(req, res);
36 |
37 | expect(userService.deleteUser).toHaveBeenCalledWith('0a0a0a-0a0a-0a0a');
38 | expect(res.status).toHaveBeenCalledWith(200);
39 | expect(res.json).toHaveBeenCalledWith({
40 | message: 'User deleted successfully',
41 | deletedData: expectedDeletedData,
42 | });
43 | });
44 |
45 | it('should return appropriate [500] if delete user fails', async () => {
46 | const mockError = new Error('An error occurred while deleting user; please try again later');
47 |
48 | (userService.deleteUser as jest.Mock).mockRejectedValue(mockError);
49 |
50 | await deleteUser(req, res);
51 |
52 | expect(userService.deleteUser).toHaveBeenCalledWith('0a0a0a-0a0a-0a0a');
53 | expect(res.status).toHaveBeenCalledWith(500);
54 | expect(res.json).toHaveBeenCalledWith({ message: mockError.message });
55 | });
56 | });
57 | });
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Map of Pi Docker Build
2 |
3 | # Controls when the workflow will execute
4 | on:
5 | # Triggers the workflow on push or pull request events for the "main" and "dev" branch
6 | push:
7 | branches:
8 | - main
9 | - dev
10 | pull_request:
11 | branches:
12 | - main
13 | - dev
14 |
15 | # Allows you to run this workflow manually from the Actions tab
16 | workflow_dispatch:
17 |
18 | jobs:
19 | build:
20 | name: 🏗️ Build Frontend & Backend
21 | runs-on: ubuntu-latest
22 | # The sequence of tasks that will be executed as part of the job
23 | steps:
24 | - name: Checkout code
25 | uses: actions/checkout@v3
26 |
27 | - name: Install Node
28 | uses: actions/setup-node@v3
29 | with:
30 | node-version: 18.x
31 |
32 | # Backend CI Process
33 | - name: Install Backend Dependencies
34 | run: yarn install --frozen-lockfile
35 | working-directory: backend
36 |
37 | - name: Build Backend
38 | run: yarn build
39 | working-directory: backend
40 |
41 | # Frontend CI Process
42 | - name: Install Frontend Dependencies
43 | run: |
44 | cd frontend
45 | npm ci
46 |
47 | - name: Build Frontend
48 | run: |
49 | cd frontend
50 | npm run build
51 |
52 | test:
53 | name: ✅ Run Backend Unit Tests
54 | runs-on: ubuntu-latest
55 | needs: build
56 | # The sequence of tasks that will be executed as part of the job
57 | steps:
58 | - name: Checkout code
59 | uses: actions/checkout@v3
60 |
61 | - name: Install Node
62 | uses: actions/setup-node@v3
63 | with:
64 | node-version: 18.x
65 |
66 | - name: Install Backend Dependencies
67 | run: yarn install --frozen-lockfile
68 | working-directory: backend
69 |
70 | - name: Execute Backend Tests
71 | run: yarn test -- --passWithNoTests
72 | working-directory: backend
--------------------------------------------------------------------------------
/backend/src/config/docs/MapCenterSchema.yml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | GetMapCenterRs:
4 | type: object
5 | properties:
6 | origin:
7 | type: object
8 | properties:
9 | map_center_id:
10 | type: string
11 | example: 0d367ba3-a2e8-4380-86c3-ab7c0b7890c0
12 | search_map_center:
13 | type: object
14 | properties:
15 | latitude:
16 | type: number
17 | example: 40.7128
18 | longitude:
19 | type: number
20 | example: -74.0060
21 | sell_map_center:
22 | type: object
23 | properties:
24 | latitude:
25 | type: number
26 | example: 34.0522
27 | longitude:
28 | type: number
29 | example: -118.2437
30 |
31 | SaveMapCenterRq:
32 | type: object
33 | properties:
34 | latitude:
35 | type: number
36 | example: 40.7128
37 | longitude:
38 | type: number
39 | example: -74.0060
40 | type:
41 | type: string
42 | enum:
43 | - search
44 | - sell
45 | example: search
46 |
47 | SaveMapCenterRs:
48 | type: object
49 | properties:
50 | map_center_id:
51 | type: string
52 | example: 0d367ba3-a2e8-4380-86c3-ab7c0b7890c0
53 | search_map_center:
54 | type: object
55 | properties:
56 | latitude:
57 | type: number
58 | example: 40.7128
59 | longitude:
60 | type: number
61 | example: -74.0060
62 | sell_map_center:
63 | type: object
64 | properties:
65 | latitude:
66 | type: number
67 | example: 34.0522
68 | longitude:
69 | type: number
70 | example: -118.2437
--------------------------------------------------------------------------------
/backend/src/config/swagger.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import { serve, setup } from "swagger-ui-express";
3 | import swaggerJsDoc from "swagger-jsdoc";
4 | import swaggerUI from "swagger-ui-dist";
5 | import path from "path";
6 |
7 | import { env } from "../utils/env";
8 |
9 | const docRouter = Router();
10 |
11 | const options = {
12 | definition: {
13 | openapi: "3.1.0",
14 | info: {
15 | title: "Map of Pi API Documentation",
16 | version: "1.0.0",
17 | description: "Map of Pi is a mobile application developed to help Pi community members easily locate local businesses that accept Pi as payment. This Swagger documentation provides comprehensive details on the Map of Pi API, including endpoints + request and response structures.",
18 | contact: {
19 | name: "Map of Pi Team",
20 | email: "philip@mapofpi.com"
21 | },
22 | },
23 | servers: [
24 | {
25 | url: "http://localhost:8001/",
26 | description: "Development server",
27 | },
28 | {
29 | url: env.PRODUCTION_URL,
30 | description: "Production server",
31 | },
32 | ],
33 | components: {
34 | securitySchemes: {
35 | bearerAuth: {
36 | type: "http",
37 | scheme: "bearer",
38 | bearerFormat: "JWT",
39 | }
40 | }
41 | },
42 | security: [
43 | {
44 | bearerAuth: []
45 | }
46 | ]
47 | },
48 | apis: [
49 | path.join(__dirname, '../routes/*.{ts,js}'),
50 | path.join(__dirname, '../config/docs/*.yml'),
51 | ]
52 | };
53 |
54 | const specs = swaggerJsDoc(options);
55 |
56 | docRouter.use("/", serve, setup(specs, {
57 | customCss: '.swagger-ui .opblock .opblock-summary-path-description-wrapper { align-items: center; display: flex; flex-wrap: wrap; gap: 0 10px; padding: 0 10px; width: 100%; }',
58 | customCssUrl: 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui.min.css',
59 | swaggerUrl: path.join(__dirname, swaggerUI.getAbsoluteFSPath())
60 | }));
61 |
62 | export default docRouter;
63 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/src/components/skeleton/seller/Registration.tsx:
--------------------------------------------------------------------------------
1 | import '../skeleton.css';
2 |
3 | export const SkeletonSellerRegistration = () => {
4 | return (
5 | <>
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | >
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/backend/src/services/mapCenter.service.ts:
--------------------------------------------------------------------------------
1 | import { IMapCenter } from "../types";
2 |
3 | import Seller from "../models/Seller";
4 | import UserSettings from "../models/UserSettings";
5 | import logger from "../config/loggingConfig";
6 |
7 | export const getMapCenterById = async (map_center_id: string, type: string): Promise => {
8 | try {
9 | if (type === 'sell') {
10 | let seller = await Seller.findOne({ seller_id: map_center_id }).exec();
11 | return seller? seller.sell_map_center as IMapCenter : null;
12 | } else if (type === 'search') {
13 | let userSettings = await UserSettings.findOne({ user_settings_id: map_center_id }).exec();
14 | return userSettings? userSettings.search_map_center as IMapCenter : null;
15 | } else {
16 | return null;
17 | }
18 | } catch (error: any) {
19 | logger.error(`Failed to retrieve Map Center for mapCenterID ${ map_center_id }: ${ error }`);
20 | throw error;
21 | }
22 | };
23 |
24 | export const createOrUpdateMapCenter = async (
25 | map_center_id: string,
26 | latitude: number,
27 | longitude: number,
28 | type: 'search' | 'sell'
29 | ): Promise => {
30 | try {
31 | const setCenter: IMapCenter = {
32 | type: 'Point',
33 | coordinates: [longitude, latitude],
34 | }
35 | if (type === 'search') {
36 | await UserSettings.findOneAndUpdate(
37 | { user_settings_id: map_center_id },
38 | { search_map_center: setCenter },
39 | { new: true }
40 | ).exec();
41 |
42 | } else if (type === 'sell') {
43 | const existingSeller = await Seller.findOneAndUpdate(
44 | { seller_id: map_center_id },
45 | { sell_map_center: setCenter },
46 | { new: true }
47 | ).exec();
48 | if (!existingSeller) {
49 | await Seller.create({
50 | seller_id: map_center_id,
51 | sell_map_center: setCenter,
52 | })
53 | }
54 | }
55 | return setCenter;
56 | } catch (error: any) {
57 | logger.error(`Failed to create or update Map Center for ${ type }: ${ error }`);
58 | throw error;
59 | }
60 | };
--------------------------------------------------------------------------------
/frontend/src/components/shared/About/Info/Info.module.css:
--------------------------------------------------------------------------------
1 | .model_container {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | position: fixed;
6 | top: 0;
7 | left: 0;
8 | width: 100%;
9 | height: 100%;
10 | background-color: #00000080;
11 | z-index: 550;
12 | }
13 |
14 | .model_body {
15 | position: relative;
16 | width: 300px;
17 | max-width: 300px;
18 | height: auto;
19 | background-color: #fff;
20 | border-radius: 10px;
21 | box-shadow: 0 0 10px #0003;
22 | padding-top: 20px;
23 | padding-bottom: 20px;
24 | z-index: 600;
25 | cursor: crosshair;
26 | }
27 |
28 | .model_event {
29 | width: 100%;
30 | height: 100%;
31 | position: absolute;
32 | background-color: transparent;
33 | }
34 |
35 | .logo {
36 | position: relative;
37 | width: 150px;
38 | height: 150px;
39 | }
40 |
41 | .title {
42 | font-size: 24px;
43 | font-weight: bold;
44 | background-color: #1d724b;
45 | padding: 5px 10px;
46 | border-bottom: 3px solid #ffc153;
47 | color: #ffc153;
48 | text-align: center;
49 | }
50 |
51 | .version {
52 | font-size: 18px;
53 | font-weight: bold;
54 | text-align: center;
55 | padding-top: 10px;
56 | }
57 |
58 | .social_media {
59 | position: relative;
60 | width: 100%;
61 | margin-top: 10px;
62 | padding: 10px;
63 | background: linear-gradient(to right, var(--default-bg-color), #eaeaea);
64 | text-align: center;
65 | font-family: var(--font-family-main);
66 | border-top: 1px solid #dcdcdc;
67 | box-shadow: 0 -1px 5px rgba(0, 0, 0, 0.1);
68 | }
69 |
70 | .social_media__links {
71 | margin-bottom: 0.5rem;
72 | display: flex;
73 | justify-content: space-between;
74 |
75 | }
76 |
77 | .social_media__link,
78 | .social_media__icon {
79 | padding: 2px; /* Increases the touchable area */
80 | margin: 0 4px; /* Adjusts spacing around links */
81 | }
82 |
83 | .social_media__link {
84 | position: relative;
85 | width: 25px;
86 | height: 25px;
87 | }
88 |
89 | .defects_contact {
90 | padding-top: 25px;
91 | text-align: center;
92 | }
93 |
94 | .defects_contact span, .defects_contact a {
95 | font-size: 13px;
96 | color: #ff0000;
97 | }
98 |
--------------------------------------------------------------------------------
/backend/test/controllers/mapCenterController.spec.ts:
--------------------------------------------------------------------------------
1 | import { saveMapCenter } from '../../src/controllers/mapCenterController';
2 | import * as mapCenterService from '../../src/services/mapCenter.service';
3 |
4 | jest.mock('../../src/services/mapCenter.service', () => ({
5 | createOrUpdateMapCenter: jest.fn(),
6 | getMapCenterById: jest.fn(),
7 | }));
8 |
9 | describe('MapCenterController', () => {
10 | let req: any;
11 | let res: any;
12 |
13 | beforeEach(() => {
14 | req = {
15 | currentUser: {
16 | pi_uid: '0a0a0a-0a0a-0a0a'
17 | },
18 | body: {
19 | longitude: 45.123,
20 | latitude: 23.456,
21 | type: 'search'
22 | },
23 | params: {
24 | type: 'search'
25 | }
26 | };
27 |
28 | res = {
29 | status: jest.fn().mockReturnThis(),
30 | json: jest.fn(),
31 | };
32 | });
33 |
34 | describe('saveMapCenter', () => {
35 | it('should save map center successfully', async () => {
36 | const mockMapCenter = { map_center_id: '0a0a0a-0a0a-0a0a', latitude: 23.456, longitude: 45.123 };
37 | (mapCenterService.createOrUpdateMapCenter as jest.Mock).mockResolvedValue(mockMapCenter);
38 |
39 | await saveMapCenter(req, res);
40 |
41 | expect(mapCenterService.createOrUpdateMapCenter).toHaveBeenCalledWith('0a0a0a-0a0a-0a0a', 23.456, 45.123, 'search');
42 | expect(res.status).toHaveBeenCalledWith(200);
43 | expect(res.json).toHaveBeenCalledWith({ uid: '0a0a0a-0a0a-0a0a', map_center: mockMapCenter });
44 | });
45 |
46 | it('should return appropriate [500] if saving map center fails', async () => {
47 | const mockError = new Error('An error occurred while saving the Map Center; please try again later');
48 |
49 | (mapCenterService.createOrUpdateMapCenter as jest.Mock).mockRejectedValue(mockError);
50 |
51 | await saveMapCenter(req, res);
52 |
53 | expect(mapCenterService.createOrUpdateMapCenter).toHaveBeenCalledWith('0a0a0a-0a0a-0a0a', 23.456, 45.123, 'search');
54 | expect(res.status).toHaveBeenCalledWith(500);
55 | expect(res.json).toHaveBeenCalledWith({ message: mockError.message });
56 | });
57 | });
58 | });
--------------------------------------------------------------------------------
/frontend/src/components/shared/Forms/Buttons/Buttons.tsx:
--------------------------------------------------------------------------------
1 | import styles from './Buttons.module.css';
2 |
3 | import { IoMdClose } from 'react-icons/io';
4 | import { RiAddFill } from 'react-icons/ri';
5 | import { MdArrowForward } from 'react-icons/md';
6 |
7 | export const AddButton = (props: any) => {
8 | return (
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export const Button = (props: any) => {
16 | const { styles, icon, label, disabled, onClick } = props;
17 | return (
18 |
23 | {icon && icon}
24 | {label && label}
25 |
26 | );
27 | };
28 |
29 | export const OutlineBtn = (props: any) => {
30 | const { styles, icon, label, disabled, onClick } = props;
31 | return (
32 |
37 | {icon && icon}
38 | {label && label}
39 |
40 | );
41 | };
42 |
43 | export const YellowBtn = (props: any) => {
44 | return (
45 |
46 |
50 | {props.icon && props.icon}
51 | {props.text && props.text}
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export const CloseButton = (props: any) => {
59 | return (
60 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Map of Pi
2 |
3 |
4 |
5 | [](https://github.com/pi-apps/PiOS/blob/main/pi-commerce.md)
6 | 
7 | 
8 |
9 |
10 |
11 |
12 |
Map of Pi is a mobile application developed to help Pi community members easily locate local businesses that accept Pi as payment. This project was initiated as part of the Pi Commerce Hackathon with the goal of facilitating Pi transactions and connecting businesses with the Pi community.
13 |
14 |
15 | ## Table of Contents
16 |
17 | - [Brand Design](#brand-design)
18 | - [Tech Stack](#tech-stack)
19 | - [Team](#team)
20 | - [Contributions](#contributions)
21 |
22 | ## Brand Design
23 |
24 | | App Logo | App Icon |
25 | | ------------- |:-------------:|
26 | | |
27 |
28 | ## Tech Stack 📊
29 |
30 | - **Frontend**: NextJS/ React, TypeScript, HTML, SCSS, CSS
31 | - **Backend**: Express/ NodeJS, REST API
32 | - **Database**: MongoDB
33 | - **DevOps**: Docker, GitHub Actions
34 |
35 | ## Team 🧑👩🦱🧔👨🏾🦱👨🏾
36 |
37 | ### Project Manager
38 | - Philip Jennings
39 |
40 | ### Marketing
41 | - Bonnie Ford
42 | - Joseph Ciccone
43 |
44 | ### Solution Design / UX
45 | - Femma Ashraf
46 | - Oluwabukola Adesina
47 | - Folorunsho Omotunde
48 | - Henry Fasakin
49 |
50 | ### Technical Lead/ DevOps
51 | - Danny Lee
52 |
53 | ### Technical Advisor
54 | - Zoltan Magyar
55 |
56 | ### Application Developers
57 | - Darin Hajou
58 | - Rokundo Soleil
59 | - Ayomikun Omotosho
60 | - Yusuf Adisa
61 | - Francis Mwaura
62 | - Samuel Oluyomi
63 |
64 | ## Contributions
65 |
66 |
67 |
We welcome contributions from the community to improve the Map of Pi project.
68 |
69 |
--------------------------------------------------------------------------------
/backend/src/models/Seller.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema, Types } from "mongoose";
2 |
3 | import { ISeller } from "../types";
4 | import { SellerType } from "./enums/sellerType";
5 | import { FulfillmentType } from "./enums/fulfillmentType";
6 |
7 | const sellerSchema = new Schema(
8 | {
9 | seller_id: {
10 | type: String,
11 | required: true,
12 | unique: true,
13 | },
14 | name: {
15 | type: String,
16 | required: true,
17 | },
18 | seller_type: {
19 | type: String,
20 | enum: Object.values(SellerType).filter(value => typeof value === 'string'),
21 | required: true,
22 | default: SellerType.Test,
23 | },
24 | description: {
25 | type: String,
26 | required: false,
27 | },
28 | image: {
29 | type: String,
30 | required: false,
31 | },
32 | address: {
33 | type: String,
34 | required: false,
35 | },
36 | average_rating: {
37 | type: Types.Decimal128,
38 | required: true,
39 | default: 5.0,
40 | },
41 | sell_map_center: {
42 | type: {
43 | type: String,
44 | enum: ['Point'],
45 | required: true,
46 | default: 'Point',
47 | },
48 | coordinates: {
49 | type: [Number],
50 | required: true,
51 | default: [0, 0]
52 | },
53 | },
54 | order_online_enabled_pref: {
55 | type: Boolean,
56 | required: false,
57 | },
58 | fulfillment_method: {
59 | type: String,
60 | enum: Object.values(FulfillmentType).filter(value => typeof value === 'string'),
61 | default: FulfillmentType.CollectionByBuyer
62 | },
63 | fulfillment_description: {
64 | type: String,
65 | default: null,
66 | required: false
67 | }
68 | },
69 | { timestamps: true } // Adds timestamps to track creation and update times
70 | );
71 |
72 | sellerSchema.index({ name: 'text', description: 'text', address: 'text' });
73 |
74 | // Creating a 2dsphere index for the sell_map_center field
75 | sellerSchema.index({ 'sell_map_center.coordinates': '2dsphere' });
76 | sellerSchema.index({ 'sell_map_center': '2dsphere', 'updatedAt': -1 });
77 |
78 | // Creating the Seller model from the schema
79 | const Seller = mongoose.model("Seller", sellerSchema);
80 |
81 | export default Seller;
--------------------------------------------------------------------------------
/backend/src/controllers/mapCenterController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 |
3 | import * as mapCenterService from '../services/mapCenter.service';
4 | import { IMapCenter } from '../types';
5 |
6 | import logger from '../config/loggingConfig';
7 |
8 | export const saveMapCenter = async (req: Request, res: Response) => {
9 | try {
10 | const authUser = req.currentUser;
11 | // early authentication check
12 | if (!authUser) {
13 | logger.warn('User not found; Map Center failed to save');
14 | return res.status(404).json({ message: 'User not found: Map Center failed to save' });
15 | }
16 |
17 | const map_center_id = authUser.pi_uid;
18 | const { latitude, longitude, type } = req.body;
19 | const mapCenter = await mapCenterService.createOrUpdateMapCenter(map_center_id, latitude, longitude, type);
20 | logger.info(`${type === 'search' ? 'Search' : 'Sell'} Center saved successfully for user ${map_center_id} with Longitude: ${longitude}, Latitude: ${latitude} `);
21 |
22 | return res.status(200).json({uid: map_center_id, map_center: mapCenter});
23 | } catch (error) {
24 | logger.error('Failed to save Map Center:', error);
25 | return res.status(500).json({ message: 'An error occurred while saving the Map Center; please try again later' });
26 | }
27 | };
28 |
29 | export const getMapCenter = async (req: Request, res: Response) => {
30 | try {
31 | const map_center_id = req.currentUser?.pi_uid;
32 | const {type} = req.params;
33 | if (map_center_id) {
34 | const mapCenter: IMapCenter | null = await mapCenterService.getMapCenterById(map_center_id, type);
35 | if (!mapCenter) {
36 | logger.warn(`Map Center not found for user ${map_center_id}`);
37 | return res.status(404).json({ message: "Map Center not found" });
38 | }
39 | logger.info(`Map Center retrieved successfully for user ${map_center_id}`);
40 | return res.status(200).json(mapCenter);
41 | } else {
42 | logger.warn('No user found; cannot retrieve Map Center.');
43 | return res.status(404).json({ message: "User not found" });
44 | }
45 | } catch (error) {
46 | logger.error('Failed to retrieve Map Center:', error);
47 | return res.status(500).json({ message: 'An error occurred while getting the Map Center; please try again later' });
48 | }
49 | };
--------------------------------------------------------------------------------
/frontend/src/constants/pi.ts:
--------------------------------------------------------------------------------
1 | // Pi SDK type definitions
2 | // Based on SDK reference at https://github.com/pi-apps/pi-platform-docs/blob/master/SDK_reference.md
3 |
4 | interface PaymentDTO {
5 | amount: number,
6 | user_uid: string,
7 | created_at: string,
8 | identifier: string,
9 | metadata: Object,
10 | memo: string,
11 | status: {
12 | developer_approved: boolean,
13 | transaction_verified: boolean,
14 | developer_completed: boolean,
15 | cancelled: boolean,
16 | user_cancelled: boolean,
17 | },
18 | to_address: string,
19 | transaction: null | {
20 | txid: string,
21 | verified: boolean,
22 | _link: string,
23 | },
24 | };
25 |
26 | type PiScope = "payments" | "username" | "roles" | "wallet_address";
27 |
28 | // TODO: Add more adequate typing if payments are introduced
29 | export type OnIncompletePaymentFoundType = (payment: PaymentDTO) => void;
30 |
31 | export interface AuthResult {
32 | accessToken: string;
33 | user: {
34 | uid: string;
35 | username: string;
36 | };
37 | };
38 |
39 | type AuthenticateType = (
40 | scopes: PiScope[],
41 | onIncompletePaymentFound: OnIncompletePaymentFoundType
42 | ) => Promise;
43 |
44 | interface InitParams {
45 | version: string,
46 | sandbox?: boolean
47 | }
48 |
49 | interface PiType {
50 | authenticate: AuthenticateType;
51 | init: (config: InitParams) => void;
52 | initialized: boolean;
53 | nativeFeaturesList(): Promise<("inline_media" | "request_permission" | "ad_network")[]>;
54 | Ads: {
55 | showAd: (adType: AdType) => Promise
56 | isAdReady: (adType: AdType) => Promise
57 | requestAd: (adType: AdType) => Promise
58 | }
59 | };
60 |
61 | type AdType = "interstitial" | "rewarded";
62 |
63 | type ShowAdResponse =
64 | | {
65 | type: "interstitial";
66 | result: "AD_CLOSED" | "AD_DISPLAY_ERROR" | "AD_NETWORK_ERROR" | "AD_NOT_AVAILABLE";
67 | }
68 | | {
69 | type: "rewarded";
70 | result: "AD_REWARDED" | "AD_CLOSED" | "AD_DISPLAY_ERROR" | "AD_NETWORK_ERROR" | "AD_NOT_AVAILABLE" | "ADS_NOT_SUPPORTED" | "USER_UNAUTHENTICATED";
71 | adId?: string;
72 | };
73 |
74 | type IsAdReadyResponse = {
75 | type: AdType;
76 | ready: boolean;
77 | };
78 |
79 | type RequestAdResponse = {
80 | type: AdType;
81 | result: "AD_LOADED" | "AD_FAILED_TO_LOAD" | "AD_NOT_AVAILABLE";
82 | };
83 |
84 | declare global {
85 | const Pi: PiType;
86 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | PiOS License
2 |
3 | Copyright (C) 2024 Map Of Pi Team
4 |
5 | Permission is hereby granted by the application software developer (“Software Developer”), free
6 | of charge, to any person obtaining a copy of this application, software and associated
7 | documentation files (the “Software”), which was developed by the Software Developer for use on
8 | Pi Network, whereby the purpose of this license is to permit the development of derivative works
9 | based on the Software, including the right to use, copy, modify, merge, publish, distribute,
10 | sub-license, and/or sell copies of such derivative works and any Software components incorporated
11 | therein, and to permit persons to whom such derivative works are furnished to do so, in each case,
12 | solely to develop, use and market applications for the official Pi Network. For purposes of this
13 | license, Pi Network shall mean any application, software, or other present or future platform
14 | developed, owned or managed by Pi Community Company, and its parents, affiliates or subsidiaries,
15 | for which the Software was developed, or on which the Software continues to operate. However,
16 | you are prohibited from using any portion of the Software or any derivative works thereof in any
17 | manner (a) which infringes on any Pi Network intellectual property rights, (b) to hack any of Pi
18 | Network’s systems or processes or (c) to develop any product or service which is competitive with
19 | the Pi Network.
20 |
21 | The above copyright notice and this permission notice shall be included in all copies or
22 | substantial portions of the Software.
23 |
24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
25 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
26 | AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS, PUBLISHERS, OR COPYRIGHT HOLDERS OF THIS
27 | SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY OR CONSEQUENTIAL
28 | DAMAGES (INCLUDING, BUT NOT LIMITED TO BUSINESS INTERRUPTION, LOSS OF USE, DATA OR PROFITS)
29 | HOWEVER CAUSED AND UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
30 | TORT (INCLUDING NEGLIGENCE) ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
31 | OR OTHER DEALINGS IN THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
32 |
33 | Pi, Pi Network and the Pi logo are trademarks of the Pi Community Company.
34 |
--------------------------------------------------------------------------------
/backend/LICENSE:
--------------------------------------------------------------------------------
1 | PiOS License
2 |
3 | Copyright (C) 2024 Map Of Pi Team
4 |
5 | Permission is hereby granted by the application software developer (“Software Developer”), free
6 | of charge, to any person obtaining a copy of this application, software and associated
7 | documentation files (the “Software”), which was developed by the Software Developer for use on
8 | Pi Network, whereby the purpose of this license is to permit the development of derivative works
9 | based on the Software, including the right to use, copy, modify, merge, publish, distribute,
10 | sub-license, and/or sell copies of such derivative works and any Software components incorporated
11 | therein, and to permit persons to whom such derivative works are furnished to do so, in each case,
12 | solely to develop, use and market applications for the official Pi Network. For purposes of this
13 | license, Pi Network shall mean any application, software, or other present or future platform
14 | developed, owned or managed by Pi Community Company, and its parents, affiliates or subsidiaries,
15 | for which the Software was developed, or on which the Software continues to operate. However,
16 | you are prohibited from using any portion of the Software or any derivative works thereof in any
17 | manner (a) which infringes on any Pi Network intellectual property rights, (b) to hack any of Pi
18 | Network’s systems or processes or (c) to develop any product or service which is competitive with
19 | the Pi Network.
20 |
21 | The above copyright notice and this permission notice shall be included in all copies or
22 | substantial portions of the Software.
23 |
24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
25 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
26 | AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS, PUBLISHERS, OR COPYRIGHT HOLDERS OF THIS
27 | SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY OR CONSEQUENTIAL
28 | DAMAGES (INCLUDING, BUT NOT LIMITED TO BUSINESS INTERRUPTION, LOSS OF USE, DATA OR PROFITS)
29 | HOWEVER CAUSED AND UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
30 | TORT (INCLUDING NEGLIGENCE) ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
31 | OR OTHER DEALINGS IN THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
32 |
33 | Pi, Pi Network and the Pi logo are trademarks of the Pi Community Company.
34 |
--------------------------------------------------------------------------------
/frontend/LICENSE:
--------------------------------------------------------------------------------
1 | PiOS License
2 |
3 | Copyright (C) 2024 Map Of Pi Team
4 |
5 | Permission is hereby granted by the application software developer (“Software Developer”), free
6 | of charge, to any person obtaining a copy of this application, software and associated
7 | documentation files (the “Software”), which was developed by the Software Developer for use on
8 | Pi Network, whereby the purpose of this license is to permit the development of derivative works
9 | based on the Software, including the right to use, copy, modify, merge, publish, distribute,
10 | sub-license, and/or sell copies of such derivative works and any Software components incorporated
11 | therein, and to permit persons to whom such derivative works are furnished to do so, in each case,
12 | solely to develop, use and market applications for the official Pi Network. For purposes of this
13 | license, Pi Network shall mean any application, software, or other present or future platform
14 | developed, owned or managed by Pi Community Company, and its parents, affiliates or subsidiaries,
15 | for which the Software was developed, or on which the Software continues to operate. However,
16 | you are prohibited from using any portion of the Software or any derivative works thereof in any
17 | manner (a) which infringes on any Pi Network intellectual property rights, (b) to hack any of Pi
18 | Network’s systems or processes or (c) to develop any product or service which is competitive with
19 | the Pi Network.
20 |
21 | The above copyright notice and this permission notice shall be included in all copies or
22 | substantial portions of the Software.
23 |
24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
25 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
26 | AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS, PUBLISHERS, OR COPYRIGHT HOLDERS OF THIS
27 | SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY OR CONSEQUENTIAL
28 | DAMAGES (INCLUDING, BUT NOT LIMITED TO BUSINESS INTERRUPTION, LOSS OF USE, DATA OR PROFITS)
29 | HOWEVER CAUSED AND UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
30 | TORT (INCLUDING NEGLIGENCE) ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
31 | OR OTHER DEALINGS IN THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
32 |
33 | Pi, Pi Network and the Pi logo are trademarks of the Pi Community Company.
34 |
--------------------------------------------------------------------------------
/backend/test/middlewares/isToggle.spec.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 | import { isToggle } from "../../src/middlewares/isToggle";
3 | import Toggle from "../../src/models/misc/Toggle";
4 |
5 | describe("isToggle function", () => {
6 | let req: Partial;
7 | let res: Partial;
8 | let next: NextFunction;
9 |
10 | beforeEach(() => {
11 | req = {};
12 | res = {
13 | status: jest.fn().mockReturnThis(),
14 | json: jest.fn(),
15 | };
16 | next = jest.fn();
17 | });
18 |
19 | it("should pass middleware if expected toggle is enabled", async () => {
20 | const middleware = isToggle("testToggle_1");
21 | await middleware(req as Request, res as Response, next);
22 |
23 | expect(next).toHaveBeenCalled();
24 | expect(res.status).not.toHaveBeenCalled();
25 | });
26 |
27 | it("should return 403 if expected toggle is disabled", async () => {
28 | const middleware = isToggle("testToggle");
29 | await middleware(req as Request, res as Response, next);
30 |
31 | expect(res.status).toHaveBeenCalledWith(403);
32 | expect(res.json).toHaveBeenCalledWith({
33 | message: "Feature is currently disabled",
34 | });
35 | expect(next).not.toHaveBeenCalled();
36 | });
37 |
38 | it("should return 403 if expected toggle is not found", async () => {
39 | const middleware = isToggle("testToggle_nonExisting");
40 | await middleware(req as Request, res as Response, next);
41 |
42 | expect(res.status).toHaveBeenCalledWith(403);
43 | expect(res.json).toHaveBeenCalledWith({
44 | message: "Feature is currently disabled",
45 | });
46 | expect(next).not.toHaveBeenCalled();
47 | });
48 |
49 | it("should return 500 if an exception occurs", async () => {
50 | const toggleName = "testToggle_nonExisting";
51 | const mockError = new Error(`Failed to fetch toggle ${ toggleName }; please try again later`);
52 |
53 | const findOneSpy = jest.spyOn(Toggle, 'findOne').mockRejectedValue(mockError);
54 |
55 | const middleware = isToggle(toggleName);
56 | await middleware(req as Request, res as Response, next);
57 |
58 | expect(res.status).toHaveBeenCalledWith(500);
59 | expect(res.json).toHaveBeenCalledWith({
60 | message: 'Failed to determine feature state; please try again later',
61 | });
62 | expect(next).not.toHaveBeenCalled();
63 |
64 | // Restore original method to avoid affecting other tests
65 | findOneSpy.mockRestore();
66 | });
67 | });
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "map-of-pi-frontend",
3 | "version": "1.0.0",
4 | "author": "Map of Pi Team",
5 | "description": "Map of Pi Frontend",
6 | "license": "PiOS",
7 | "private": true,
8 | "scripts": {
9 | "dev": "next dev -p 4200",
10 | "build": "next build",
11 | "start": "next start -p 80",
12 | "lint": "next lint",
13 | "test": "jest"
14 | },
15 | "dependencies": {
16 | "@emotion/core": "^10.1.1",
17 | "@emotion/react": "^11.11.4",
18 | "@emotion/styled": "^11.11.5",
19 | "@mui/icons-material": "^5.15.16",
20 | "@mui/material": "^5.15.16",
21 | "@sentry/browser": "^8.33.1",
22 | "@sentry/nextjs": "^8.52.0",
23 | "@sentry/node": "^8.52.0",
24 | "axios": "^1.7.5",
25 | "clsx": "^2.1.1",
26 | "cookies-next": "^4.1.1",
27 | "date-fns": "^3.6.0",
28 | "date-fns-tz": "^3.1.3",
29 | "i18next": "^23.11.3",
30 | "js-cookie": "^3.0.5",
31 | "leaflet": "^1.9.4",
32 | "leaflet-control-geocoder": "^2.4.0",
33 | "leaflet-geosearch": "^3.11.1",
34 | "loglevel": "^1.9.1",
35 | "next": "^14.2.28",
36 | "next-intl": "^4.5.8",
37 | "next-logger": "^4.0.0",
38 | "next-themes": "^0.3.0",
39 | "ngx-logger": "^5.0.12",
40 | "react": "^18",
41 | "react-dom": "^18",
42 | "react-i18next": "^14.1.1",
43 | "react-icons": "^5.1.0",
44 | "react-intl": "^6.6.5",
45 | "react-leaflet": "^4.2.1",
46 | "react-logger": "^1.1.0",
47 | "react-phone-number-input": "^3.4.3",
48 | "react-switch": "^7.0.0",
49 | "react-toastify": "^10.0.5",
50 | "sass": "^1.77.0",
51 | "sharp": "^0.33.3",
52 | "zod": "^3.23.8"
53 | },
54 | "devDependencies": {
55 | "@testing-library/jest-dom": "^6.4.2",
56 | "@testing-library/react": "^15.0.2",
57 | "@types/js-cookie": "^3.0.6",
58 | "@types/leaflet": "^1.9.12",
59 | "@types/lodash": "^4.17.5",
60 | "@types/node": "^20",
61 | "@types/react": "^18",
62 | "@types/react-dom": "^18",
63 | "@types/react-leaflet": "^3.0.0",
64 | "@typescript-eslint/eslint-plugin": "^7.7.1",
65 | "@typescript-eslint/parser": "^7.7.1",
66 | "eslint": "^8",
67 | "eslint-config-next": "14.2.2",
68 | "eslint-config-prettier": "^9.1.0",
69 | "eslint-plugin-prettier": "^5.1.3",
70 | "jest": "^29.7.0",
71 | "jest-environment-jsdom": "^29.7.0",
72 | "postcss": "^8",
73 | "prettier": "^3.2.5",
74 | "tailwindcss": "^3.4.1",
75 | "ts-jest": "^29.1.2",
76 | "ts-node": "^10.9.2",
77 | "typescript": "^5"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/backend/src/models/UserSettings.ts:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from "mongoose";
2 |
3 | import { IUserSettings } from "../types";
4 | import { DeviceLocationType } from "./enums/deviceLocationType";
5 | import { TrustMeterScale } from "./enums/trustMeterScale";
6 |
7 | const userSettingsSchema = new Schema(
8 | {
9 | user_settings_id: {
10 | type: String,
11 | required: true,
12 | unique: true
13 | },
14 | user_name: {
15 | type: String,
16 | required: true,
17 | },
18 | email: {
19 | type: String,
20 | required: false,
21 | default: null,
22 | },
23 | phone_number: {
24 | type: String,
25 | required: false,
26 | default: null,
27 | },
28 | image: {
29 | type: String,
30 | required: false,
31 | default: ''
32 | },
33 | findme: {
34 | type: String,
35 | enum: Object.values(DeviceLocationType).filter(value => typeof value === 'string'),
36 | required: true,
37 | default: DeviceLocationType.SearchCenter
38 | },
39 | trust_meter_rating: {
40 | type: Number,
41 | enum: Object.values(TrustMeterScale).filter(value => typeof value === 'number'),
42 | required: true,
43 | default: TrustMeterScale.HUNDRED
44 | },
45 | search_map_center: {
46 | type: {
47 | type: String,
48 | enum: ['Point'],
49 | required: false,
50 | default: 'Point',
51 | },
52 | coordinates: {
53 | type: [Number],
54 | required: false,
55 | default: [0, 0]
56 | },
57 | },
58 | search_filters: {
59 | type: {
60 | include_active_sellers: { type: Boolean, default: true },
61 | include_inactive_sellers: { type: Boolean, default: false },
62 | include_test_sellers: { type: Boolean, default: false },
63 | include_trust_level_100: { type: Boolean, default: true },
64 | include_trust_level_80: { type: Boolean, default: true },
65 | include_trust_level_50: { type: Boolean, default: true },
66 | include_trust_level_0: { type: Boolean, default: false },
67 | },
68 | required: true,
69 | default: {},
70 | },
71 | }
72 | );
73 |
74 | userSettingsSchema.index({ user_name: "text", email: "text" });
75 |
76 | // use GeoJSON format to store geographical data i.e., points using '2dsphere' index.
77 | userSettingsSchema.index({ search_map_center: '2dsphere' });
78 |
79 | const UserSettings = mongoose.model("User-Settings", userSettingsSchema);
80 |
81 | export default UserSettings;
82 |
--------------------------------------------------------------------------------
/frontend/src/components/skeleton/seller/SellerItem.tsx:
--------------------------------------------------------------------------------
1 | export const SkeletonSellerItem = () => {
2 | return (
3 | <>
4 |
5 |
6 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
36 |
37 |
38 |
39 |
40 |
44 |
45 |
46 | >
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/backend/src/middlewares/verifyToken.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 | import { decodeUserToken } from "../helpers/jwt";
3 | import { IUser } from "../types";
4 |
5 | import logger from "../config/loggingConfig";
6 |
7 | declare module 'express-serve-static-core' {
8 | interface Request {
9 | currentUser?: IUser;
10 | token?: string;
11 | }
12 | }
13 |
14 | export const verifyToken = async (
15 | req: Request,
16 | res: Response,
17 | next: NextFunction
18 | ) => {
19 | // First, checking if the token exists in the cookies
20 | const tokenFromCookie = req.cookies.token;
21 |
22 | // Fallback to the authorization header if token is not in the cookie
23 | const authHeader = req.headers.authorization;
24 | const tokenFromHeader = authHeader && authHeader.split(" ")[1];
25 |
26 | // Prioritize token from cookies, then from header
27 | const token = tokenFromCookie || tokenFromHeader;
28 |
29 | if (!token) {
30 | logger.warn("Authentication token is missing.");
31 | return res.status(401).json({ message: "Unauthorized" });
32 | }
33 |
34 | try {
35 | // Decode the token to get the user information
36 | const currentUser = await decodeUserToken(token);
37 |
38 | if (!currentUser) {
39 | logger.warn("Authentication token is invalid or expired.");
40 | return res.status(401).json({ message: "Unauthorized" });
41 | }
42 |
43 | // Attach currentUser to the request object
44 | req.currentUser = currentUser;
45 | req.token = token;
46 | next();
47 | } catch (error) {
48 | logger.error('Failed to verify token:', error);
49 | return res.status(500).json({ message: 'Failed to verify token; please try again later' });
50 | }
51 | };
52 |
53 | export const verifyAdminToken = (
54 | req: Request,
55 | res: Response,
56 | next: NextFunction
57 | ) => {
58 | const { ADMIN_API_USERNAME, ADMIN_API_PASSWORD } = process.env;
59 |
60 | const authHeader = req.headers.authorization;
61 | const base64Credentials = authHeader && authHeader.split(" ")[1];
62 | if (!base64Credentials) {
63 | logger.warn("Admin credentials are missing.");
64 | return res.status(401).json({ message: "Unauthorized" });
65 | }
66 |
67 | const credentials = Buffer.from(base64Credentials, "base64").toString("ascii");
68 | const [username, password] = credentials.split(":");
69 |
70 | if (username !== ADMIN_API_USERNAME || password !== ADMIN_API_PASSWORD) {
71 | logger.warn("Admin credentials are invalid.");
72 | return res.status(401).json({ message: "Unauthorized" });
73 | }
74 |
75 | logger.info("Admin credentials verified successfully.");
76 | next();
77 | };
--------------------------------------------------------------------------------
/frontend/src/services/mapCenterApi.ts:
--------------------------------------------------------------------------------
1 | import axiosClient from "@/config/client";
2 |
3 | import logger from '../../logger.config.mjs';
4 |
5 | // Function to Fetch Map Center
6 | export const fetchMapCenter = async (type: 'search' | 'sell') => {
7 | try {
8 | logger.info('Fetching map center...');
9 | const response = await axiosClient.get(`/map-center/${type}`);
10 |
11 | if (response.status === 200) {
12 | logger.info(`Fetch map center successful with Status ${response.status}`, {
13 | data: response.data,
14 | });
15 |
16 | // Ensure proper access to nested data if present
17 | const mapCenter = response.data;
18 |
19 | // Log mapCenter to inspect its structure
20 | logger.info('Fetched map center details:', mapCenter);
21 |
22 | // Access coordinates based on the actual response structure
23 | const longitude = mapCenter?.coordinates[0];
24 | const latitude = mapCenter?.coordinates[1];
25 | const type = mapCenter?.type;
26 |
27 | // Verify extracted values
28 | logger.info('Extracted coordinates:', { longitude, latitude, type });
29 |
30 | if (latitude !== undefined && longitude !== undefined) {
31 | return { longitude, latitude, type };
32 | } else {
33 | logger.warn('Fetched map center has undefined coordinates, returning default.');
34 | return null;
35 | }
36 | } else {
37 | logger.error(`Fetch map center failed with Status ${response.status}`);
38 | return null;
39 | }
40 | } catch (error) {
41 | logger.error('Fetch map center encountered an error:', error);
42 | throw new Error('Failed to fetch map center. Please try again later.');
43 | }
44 | };
45 |
46 | // Function to Save Map Center
47 | export const saveMapCenter = async (latitude: number, longitude: number, type: 'search' | 'sell') => {
48 | try {
49 | logger.info(`Sending map center with coordinates: longitude ${longitude}, latitude ${latitude}, type: ${type}`);
50 |
51 | const response = await axiosClient.put('/map-center/save', {
52 | longitude,
53 | latitude,
54 | type
55 | });
56 |
57 | if (response.status === 200) {
58 | logger.info(`Save map center successful with Status ${response.status}`, {
59 | data: response.data
60 | });
61 | return response.data;
62 | } else {
63 | logger.error(`Save map center failed with Status ${response.status}`);
64 | return null;
65 | }
66 | } catch (error) {
67 | logger.error('Save map center encountered an error:', error);
68 | throw new Error('Failed to save map center. Please try again later.');
69 | }
70 | };
--------------------------------------------------------------------------------
/reverse-proxy/docker/nginx-ssl.conf.template:
--------------------------------------------------------------------------------
1 | #
2 | # Frontend config:
3 | #
4 | server {
5 | # Use Docker's built-in DNS resolver to enable resolving container hostnames used in the proxy_pass
6 | # directives below.
7 | # https://stackoverflow.com/questions/35744650/docker-network-nginx-resolver
8 | resolver 127.0.0.11 ipv6=off;
9 |
10 | # Enable nginx to start even when upstream hosts are unreachable (i.e containers not started yet)
11 | # https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/
12 | set $frontend_upstream http://frontend;
13 |
14 | server_name ${FRONTEND_DOMAIN_NAME};
15 | listen 443 ssl;
16 |
17 | ssl_certificate /etc/letsencrypt/live/${FRONTEND_DOMAIN_NAME}/fullchain.pem;
18 | ssl_certificate_key /etc/letsencrypt/live/${FRONTEND_DOMAIN_NAME}/privkey.pem;
19 | include /etc/letsencrypt/options-ssl-nginx.conf;
20 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
21 |
22 | location /.well-known/acme-challenge/ {
23 | root /var/www/certbot;
24 | }
25 |
26 | location /validation-key.txt {
27 | return 200 '${DOMAIN_VALIDATION_KEY}';
28 | }
29 |
30 | location / {
31 | proxy_pass $frontend_upstream;
32 | }
33 | }
34 |
35 | server {
36 | listen 80;
37 | server_name ${FRONTEND_DOMAIN_NAME};
38 |
39 | if ($host = ${FRONTEND_DOMAIN_NAME}) {
40 | return 302 https://$host$request_uri;
41 | }
42 |
43 | return 404;
44 | }
45 |
46 |
47 | #
48 | # Backend config:
49 | #
50 | server {
51 | # Use Docker's built-in DNS resolver to enable resolving container hostnames used in the proxy_pass
52 | # directives below.
53 | # https://stackoverflow.com/questions/35744650/docker-network-nginx-resolver
54 | resolver 127.0.0.11 ipv6=off;
55 |
56 | # Enable nginx to start even when upstream hosts are unreachable (i.e containers not started yet)
57 | # https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/
58 | set $backend_upstream http://backend:8000;
59 |
60 | server_name ${BACKEND_DOMAIN_NAME};
61 | listen 443 ssl;
62 |
63 | ssl_certificate /etc/letsencrypt/live/${BACKEND_DOMAIN_NAME}/fullchain.pem;
64 | ssl_certificate_key /etc/letsencrypt/live/${BACKEND_DOMAIN_NAME}/privkey.pem;
65 | include /etc/letsencrypt/options-ssl-nginx.conf;
66 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
67 |
68 | location /.well-known/acme-challenge/ {
69 | root /var/www/certbot;
70 | }
71 |
72 | location / {
73 | proxy_pass $backend_upstream;
74 | }
75 | }
76 |
77 | server {
78 | listen 80;
79 | server_name ${BACKEND_DOMAIN_NAME};
80 |
81 | if ($host = ${BACKEND_DOMAIN_NAME}) {
82 | return 302 https://$host$request_uri;
83 | }
84 |
85 | return 404;
86 | }
87 |
--------------------------------------------------------------------------------
/frontend/src/services/reviewsApi.ts:
--------------------------------------------------------------------------------
1 | import axiosClient from "@/config/client";
2 | import { getMultipartFormDataHeaders } from "@/utils/api";
3 |
4 | import logger from '../../logger.config.mjs';
5 |
6 | // Fetch a single review for a seller
7 | export const fetchSingleReview = async (reviewID: string) => {
8 | try {
9 | logger.info(`Fetching single review with ID: ${reviewID}`);
10 | const response = await axiosClient.get(`/review-feedback/single/${reviewID}`);
11 | if (response.status === 200) {
12 | logger.info(`Fetch single review successful with Status ${response.status}`, {
13 | data: response.data
14 | });
15 | return response.data;
16 | } else {
17 | logger.error(`Fetch single review failed with Status ${response.status}`);
18 | return null;
19 | }
20 | } catch (error) {
21 | logger.error(`Fetch single review for ${ reviewID } encountered an error:`, error);
22 | throw new Error('Failed to fetch single review. Please try again later.');
23 | }
24 | };
25 |
26 | // Fetch reviews for a seller
27 | export const fetchReviews = async (userId:string, searchQuery:string='') => {
28 | try {
29 | logger.info(`Fetching reviews for seller with UID: ${userId}`);
30 | const response = await axiosClient.get(`/review-feedback/${userId}`, {
31 | params: { searchQuery },
32 | });
33 | if (response.status === 200) {
34 | logger.info(`Fetch reviews successful with Status ${response.status}`, {
35 | data: response.data
36 | });
37 | return response.data;
38 | } else {
39 | logger.error(`Fetch reviews failed with Status ${response.status}`);
40 | return null;
41 | }
42 | } catch (error) {
43 | logger.error(`Fetch reviews for ${ userId } encountered an error:`, error);
44 | throw new Error('Failed to fetch reviews. Please try again later.');
45 | }
46 | };
47 |
48 | // Create a new review
49 | export const createReview = async (formData: FormData) => {
50 | try {
51 | logger.info('Creating a new review with formData..');
52 | const headers = getMultipartFormDataHeaders();
53 |
54 | const response = await axiosClient.post('/review-feedback/add', formData, { headers });
55 |
56 | if (response.status === 200) {
57 | logger.info(`Create review successful with Status ${response.status}`, {
58 | data: response.data
59 | });
60 | return response.data;
61 | } else {
62 | logger.error(`Create review failed with Status ${response.status}`);
63 | return null;
64 | }
65 | } catch (error) {
66 | logger.error('Create review encountered an error:', error);
67 | throw new Error('Failed to create review. Please try again later.');
68 | }
69 | };
70 |
--------------------------------------------------------------------------------
/frontend/public/images/shared/sidebar/settings.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/backend/src/config/docs/TogglesSchema.yml:
--------------------------------------------------------------------------------
1 | components:
2 | schemas:
3 | AddToggleRq:
4 | type: object
5 | properties:
6 | name:
7 | type: string
8 | example: testToggle
9 | enabled:
10 | type: boolean
11 | example: false
12 | description:
13 | type: string
14 | example: This is a toggle description.
15 | required:
16 | - name
17 | - enabled
18 | - description
19 |
20 | UpdateToggleRq:
21 | type: object
22 | properties:
23 | name:
24 | type: string
25 | example: testToggle
26 | enabled:
27 | type: boolean
28 | example: false
29 | description:
30 | type: string
31 | example: This is a toggle description.
32 |
33 | GetAllTogglesRs:
34 | type: array
35 | items:
36 | type: object
37 | properties:
38 | name:
39 | type: string
40 | example: testToggle
41 | enabled:
42 | type: boolean
43 | example: false
44 | description:
45 | type: string
46 | example: This is a toggle description.
47 |
48 | GetSingleToggleRs:
49 | type: object
50 | properties:
51 | name:
52 | type: string
53 | example: testToggle
54 | enabled:
55 | type: boolean
56 | example: false
57 | description:
58 | type: string
59 | example: This is a toggle description.
60 |
61 | AddToggleRs:
62 | type: object
63 | properties:
64 | addedToggle:
65 | name:
66 | type: string
67 | example: testToggle
68 | enabled:
69 | type: boolean
70 | example: false
71 | description:
72 | type: string
73 | example: This is a toggle description.
74 |
75 | UpdateToggleRs:
76 | type: object
77 | properties:
78 | updatedToggle:
79 | name:
80 | type: string
81 | example: testToggle
82 | enabled:
83 | type: boolean
84 | example: false
85 | description:
86 | type: string
87 | example: This is a toggle description.
88 |
89 | DeleteToggleRs:
90 | type: object
91 | properties:
92 | deletedToggle:
93 | name:
94 | type: string
95 | example: testToggle
96 | enabled:
97 | type: boolean
98 | example: false
99 | description:
100 | type: string
101 | example: This is a toggle description.
102 |
--------------------------------------------------------------------------------
/backend/src/routes/mapCenter.routes.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import * as mapCenterController from '../controllers/mapCenterController';
3 | import { verifyToken } from '../middlewares/verifyToken';
4 |
5 | /**
6 | * @swagger
7 | * components:
8 | * schemas:
9 | * MapCenterSchema:
10 | * type: object
11 | * properties:
12 | * map_center_id:
13 | * type: string
14 | * description: Pi user ID
15 | * longitude:
16 | * type: string
17 | * description: Longitude of the map center
18 | * latitude:
19 | * type: string
20 | * description: Latitude of the map center
21 | */
22 | const mapCenterRoutes = Router();
23 |
24 | /**
25 | * @swagger
26 | * /api/v1/map-center/{type}:
27 | * get:
28 | * tags:
29 | * - Map Center
30 | * summary: Get the user's map center by type *
31 | * parameters:
32 | * - name: type
33 | * in: path
34 | * required: true
35 | * schema:
36 | * type: string
37 | * description: The type of the map center to retrieve
38 | * responses:
39 | * 200:
40 | * description: Successful response
41 | * content:
42 | * application/json:
43 | * schema:
44 | * $ref: '/api/docs/MapCenterSchema.yml#/components/schemas/GetMapCenterRs'
45 | * 404:
46 | * description: Map Center not found | User not found
47 | * 401:
48 | * description: Unauthorized
49 | * 400:
50 | * description: Bad request
51 | * 500:
52 | * description: Internal server error
53 | */
54 | mapCenterRoutes.get(
55 | '/:type',
56 | verifyToken,
57 | mapCenterController.getMapCenter
58 | );
59 |
60 | /**
61 | * @swagger
62 | * /api/v1/map-center/save:
63 | * put:
64 | * tags:
65 | * - Map Center
66 | * summary: Save a new map center or update existing map center *
67 | * requestBody:
68 | * required: true
69 | * content:
70 | * application/json:
71 | * schema:
72 | * $ref: '/api/docs/MapCenterSchema.yml#/components/schemas/SaveMapCenterRq'
73 | * responses:
74 | * 200:
75 | * description: Successful response
76 | * content:
77 | * application/json:
78 | * schema:
79 | * $ref: '/api/docs/MapCenterSchema.yml#/components/schemas/SaveMapCenterRs'
80 | * 404:
81 | * description: User not found | Seller not found; Map Center failed to save
82 | * 401:
83 | * description: Unauthorized
84 | * 400:
85 | * description: Bad request
86 | * 500:
87 | * description: Internal server error
88 | */
89 | mapCenterRoutes.put(
90 | '/save',
91 | verifyToken,
92 | mapCenterController.saveMapCenter
93 | );
94 |
95 | export default mapCenterRoutes;
--------------------------------------------------------------------------------
/backend/src/controllers/userController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 |
3 | import * as jwtHelper from "../helpers/jwt";
4 | import * as userService from "../services/user.service";
5 | import { IUser } from "../types";
6 |
7 | import logger from '../config/loggingConfig';
8 |
9 | export const authenticateUser = async (req: Request, res: Response) => {
10 | const auth = req.body;
11 |
12 | try {
13 | const user = await userService.authenticate(auth.user);
14 | const token = jwtHelper.generateUserToken(user);
15 | const expiresDate = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); // 1 day
16 |
17 | logger.info(`User authenticated: ${user.pi_uid}`);
18 |
19 | return res.cookie("token", token, {httpOnly: true, expires: expiresDate, secure: true, priority: "high", sameSite: "lax"}).status(200).json({
20 | user,
21 | token,
22 | });
23 | } catch (error) {
24 | logger.error('Failed to authenticate user:', error);
25 | return res.status(500).json({ message: 'An error occurred while authenticating user; please try again later' });
26 | }
27 | };
28 |
29 | export const autoLoginUser = async(req: Request, res: Response) => {
30 | try {
31 | const currentUser = req.currentUser;
32 | logger.info(`Auto-login successful for user: ${currentUser?.pi_uid || "NULL"}`);
33 | res.status(200).json(currentUser);
34 | } catch (error) {
35 | logger.error(`Failed to auto-login user for userID ${ req.currentUser?.pi_uid }:`, error);
36 | return res.status(500).json({ message: 'An error occurred while auto-logging the user; please try again later' });
37 | }
38 | };
39 |
40 | export const getUser = async(req: Request, res: Response) => {
41 | const { pi_uid } = req.params;
42 | try {
43 | const currentUser: IUser | null = await userService.getUser(pi_uid);
44 | if (!currentUser) {
45 | logger.warn(`User not found with piUID: ${pi_uid}`);
46 | return res.status(404).json({ message: "User not found" });
47 | }
48 | logger.info(`Fetched user with piUID: ${pi_uid}`);
49 | res.status(200).json(currentUser);
50 | } catch (error) {
51 | logger.error(`Failed to fetch user for userID ${ pi_uid }:`, error);
52 | return res.status(500).json({ message: 'An error occurred while getting user; please try again later' });
53 | }
54 | };
55 |
56 | export const deleteUser = async (req: Request, res: Response) => {
57 | const currentUser = req.currentUser;
58 |
59 | try {
60 | const deletedData = await userService.deleteUser(currentUser?.pi_uid);
61 | logger.info(`Deleted user with piUID: ${currentUser?.pi_uid}`);
62 | res.status(200).json({ message: "User deleted successfully", deletedData });
63 | } catch (error) {
64 | logger.error(`Failed to delete user for userID ${ currentUser?.pi_uid }:`, error);
65 | return res.status(500).json({ message: 'An error occurred while deleting user; please try again later' });
66 | }
67 | };
68 |
--------------------------------------------------------------------------------
/backend/test/services/mapCenter.service.spec.ts:
--------------------------------------------------------------------------------
1 | import Seller from '../../src/models/Seller';
2 | import UserSettings from '../../src/models/UserSettings';
3 | import { getMapCenterById, createOrUpdateMapCenter } from '../../src/services/mapCenter.service';
4 |
5 | describe('getMapCenterById function', () => {
6 | it('should fetch the sell map center for the given seller ID', async () => {
7 | const sellerData = await Seller.findOne({ seller_id: '0a0a0a-0a0a-0a0a' });
8 |
9 | const result = await getMapCenterById('0a0a0a-0a0a-0a0a', 'sell');
10 |
11 | expect(result).toBeDefined();
12 | // assert that the result matches the expected sell map center
13 | expect(result).toEqual(expect.objectContaining(sellerData!.sell_map_center));
14 | });
15 |
16 | it('should fetch the search map center for the given user settings ID', async () => {
17 | const userSettingsData = await UserSettings.findOne({ user_settings_id: '0b0b0b-0b0b-0b0b' });
18 |
19 | const result = await getMapCenterById('0b0b0b-0b0b-0b0b', 'search');
20 |
21 | expect(result).toBeDefined();
22 | // assert that the result matches the expected search map center
23 | expect(result).toEqual(expect.objectContaining(userSettingsData!.search_map_center));
24 | });
25 |
26 | it('should return null for a non-existent map center ID', async () => {
27 | const randomType = ['sell', 'search'][Math.floor(Math.random() * 2)];
28 |
29 | const result = await getMapCenterById('0x0x0x-0x0x-0x0x', randomType);
30 | expect(result).toBeNull();
31 | });
32 | });
33 |
34 | describe('createOrUpdateMapCenter function', () => {
35 | it('should update the appropriate search_map_center instance for the given user settings ID', async () => {
36 | const updatedSearchMapCenter = {
37 | map_center_id: '0b0b0b-0b0b-0b0b',
38 | latitude: 42.8781,
39 | longitude: -88.6298
40 | };
41 |
42 | const result = await createOrUpdateMapCenter(
43 | '0b0b0b-0b0b-0b0b',
44 | updatedSearchMapCenter.latitude,
45 | updatedSearchMapCenter.longitude,
46 | 'search'
47 | );
48 |
49 | expect(result).toBeDefined();
50 | // assert that the search_map_center has been updated with new coordinates
51 | expect(result?.coordinates).toEqual([updatedSearchMapCenter.longitude, updatedSearchMapCenter.latitude]);
52 | });
53 |
54 | it('should update the appropriate sell_map_center instance for the given seller ID', async () => {
55 | const updatedSellMapCenter = {
56 | map_center_id: '0a0a0a-0a0a-0a0a',
57 | longitude: -88.6298,
58 | latitude: 42.8781
59 | };
60 |
61 | const result = await createOrUpdateMapCenter(
62 | '0a0a0a-0a0a-0a0a',
63 | updatedSellMapCenter.latitude,
64 | updatedSellMapCenter.longitude,
65 | 'sell'
66 | );
67 |
68 | expect(result).toBeDefined();
69 | // assert that the sell_map_center has been updated with new coordinates
70 | expect(result?.coordinates).toEqual([updatedSellMapCenter.longitude, updatedSellMapCenter.latitude]);
71 | });
72 | });
--------------------------------------------------------------------------------
/frontend/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './global.css';
2 | import { ReactNode } from 'react';
3 | import '../../sentry.client.config.mjs';
4 |
5 | export const dynamic = 'force-dynamic';
6 |
7 | export default function RootLayout({ children }: { children: ReactNode }) {
8 | return (
9 |
10 |
11 |
12 | Map of Pi
13 |
14 |
18 |
22 |
23 |
24 |
28 |
32 |
33 |
34 |
38 |
43 |
49 |
55 |
59 |
63 |
67 |
68 |
69 | {/* Google tag (gtag.js) */}
70 |
71 |
79 |
80 |
81 | {children}
82 |
83 |
84 | );
85 | }
--------------------------------------------------------------------------------
/frontend/src/components/shared/About/terms-of-service/TermsOfService.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import styles from './TermOfService.module.css';
4 |
5 | import { useTranslations } from 'next-intl';
6 |
7 | import { CloseButton } from '@/components/shared/Forms/Buttons/Buttons';
8 |
9 | const TermsOfServiceModel = (props: any) => {
10 |
11 | const t = useTranslations();
12 |
13 | const lastUpdated = "6/3/2024";
14 | const emailAddress = "philip@mapofpi.com";
15 |
16 | return (
17 | <>
18 | {props.toggleTermsOfService && (
19 |
20 |
props.setToggleTermsOfService(false)}>
23 |
24 |
25 |
props.setToggleTermsOfService(false)}/>
26 |
27 |
Map of Pi {t('POPUP.TERMS_OF_SERVICE_INFO.TITLE')}
28 |
{t('POPUP.TERMS_OF_SERVICE_INFO.LAST_UPDATED')}: {lastUpdated}
29 |
{t('POPUP.TERMS_OF_SERVICE_INFO.EMAIL_ADDRESS')}: {emailAddress}
30 |
31 |
1. {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.HEADER_1')}
32 |
33 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_1_1')}
34 |
35 |
36 |
2. {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.HEADER_2')}
37 |
38 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_2_1')}
39 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_2_2')}
40 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_2_3')}
41 |
42 |
43 |
3. {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.HEADER_3')}
44 |
45 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_3_1')}
46 |
47 |
48 |
4. {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.HEADER_4')}
49 |
50 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_4_1')}
51 |
52 |
53 |
5. {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.HEADER_5')}
54 |
55 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_5_1')}
56 |
57 |
58 |
6. {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.HEADER_6')}
59 |
60 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_6_1')}
61 |
62 |
63 |
7. {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.HEADER_7')}
64 |
65 | {t('POPUP.TERMS_OF_SERVICE_INFO.SECTIONS.CONTENT_7_1')}
66 |
67 |
68 |
69 |
70 | )}
71 | >
72 | );
73 | }
74 |
75 | export default TermsOfServiceModel;
76 |
--------------------------------------------------------------------------------
/backend/src/controllers/reviewFeedbackController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 |
3 | import * as reviewFeedbackService from "../services/reviewFeedback.service";
4 | import logger from "../config/loggingConfig";
5 |
6 | export const getReviews = async (req: Request, res: Response) => {
7 | const { review_receiver_id } = req.params;
8 | const { searchQuery } = req.query;
9 |
10 | try {
11 | // Call the service with the review_receiver_id and searchQuery
12 | const completeReviews = await reviewFeedbackService.getReviewFeedback(
13 | review_receiver_id,
14 | searchQuery as string
15 | );
16 |
17 | logger.info(`Retrieved reviews for receiver ID ${review_receiver_id} with search query "${searchQuery ?? 'none'}"`);
18 | return res.status(200).json(completeReviews);
19 | } catch (error) {
20 | logger.error(`Failed to get reviews for receiverID ${review_receiver_id}:`, error);
21 | return res.status(500).json({ message: 'An error occurred while getting reviews; please try again later' });
22 | }
23 | };
24 |
25 | export const getSingleReviewById = async (req: Request, res: Response) => {
26 | const { review_id } = req.params;
27 | try {
28 | const associatedReview = await reviewFeedbackService.getReviewFeedbackById(review_id);
29 | if (!associatedReview) {
30 | logger.warn(`Review with ID ${review_id} not found.`);
31 | return res.status(404).json({ message: "Review not found" });
32 | }
33 | logger.info(`Retrieved review with ID ${review_id}`);
34 | res.status(200).json(associatedReview);
35 | } catch (error) {
36 | logger.error(`Failed to get review for reviewID ${ review_id }:`, error);
37 | return res.status(500).json({ message: 'An error occurred while getting single review; please try again later' });
38 | }
39 | };
40 |
41 | export const addReview = async (req: Request, res: Response) => {
42 | try {
43 | const authUser = req.currentUser;
44 | const formData = req.body;
45 |
46 | if (!authUser) {
47 | logger.warn("No authenticated user found for adding review.");
48 | return res.status(401).json({ message: "Unauthorized" });
49 | } else if (authUser.pi_uid === formData.review_receiver_id) {
50 | logger.warn(`Attempted self review by user ${authUser.pi_uid}`);
51 | return res.status(400).json({ message: "Self review is prohibited" });
52 | }
53 |
54 | // image file handling (have to ts-ignore because tsc thinks the file can't have a location property, even though it can and does)
55 | const file = req.file;
56 | //@ts-ignore
57 | const image = file ? file.location : '';
58 |
59 | const newReview = await reviewFeedbackService.addReviewFeedback(authUser, formData, image);
60 | logger.info(`Added new review by user ${authUser.pi_uid} for receiver ID ${newReview.review_receiver_id}`);
61 | return res.status(200).json({ newReview });
62 | } catch (error) {
63 | logger.error(`Failed to add review for userID ${ req.currentUser?.pi_uid }:`, error);
64 | return res.status(500).json({ message: 'An error occurred while adding review; please try again later' });
65 | }
66 | };
67 |
--------------------------------------------------------------------------------
/backend/test/services/userSettings.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { addOrUpdateUserSettings } from '../../src/services/userSettings.service';
2 | import User from '../../src/models/User';
3 | import { DeviceLocationType } from '../../src/models/enums/deviceLocationType';
4 | import { IUser, IUserSettings } from '../../src/types';
5 |
6 | const formData = {
7 | user_name: 'test-user-1-updated',
8 | email: 'example-new@test.com',
9 | phone_number: '123-456-7890',
10 | image: 'http://example.com/image_new.jpg',
11 | findme: DeviceLocationType.GPS,
12 | search_map_center: { type: 'Point', coordinates: [-83.856077, 50.848447] }
13 | }
14 |
15 | describe('addOrUpdateUserSettings function', () => {
16 | it('should add new user settings when user_name is not empty', async () => {
17 | const userData = await User.findOne({ pi_username: 'TestUser1' }) as IUser;
18 |
19 | const userSettingsData = await addOrUpdateUserSettings(userData, formData, formData.image ?? '');
20 |
21 | expect(userSettingsData).toEqual(expect.objectContaining({
22 | user_settings_id: userData.pi_uid,
23 | user_name: formData.user_name,
24 | email: formData.email,
25 | phone_number: formData.phone_number,
26 | image: formData.image,
27 | findme: formData.findme,
28 | search_map_center: formData.search_map_center
29 | }));
30 | });
31 |
32 | it('should add new user settings when user_name is empty', async () => {
33 | const userData = await User.findOne({ pi_username: 'TestUser1' }) as IUser;
34 |
35 | const userSettingsData = await addOrUpdateUserSettings(
36 | userData, {
37 | ...formData, user_name: ""
38 | } as IUserSettings, formData.image ?? '');
39 |
40 | expect(userSettingsData).toEqual(expect.objectContaining({
41 | user_settings_id: userData.pi_uid,
42 | user_name: userData.pi_username,
43 | email: formData.email,
44 | phone_number: formData.phone_number,
45 | image: formData.image,
46 | findme: formData.findme,
47 | search_map_center: formData.search_map_center
48 | }));
49 | });
50 |
51 | it('should update existing user settings', async () => {
52 | const userData = await User.findOne({ pi_username: 'TestUser1' }) as IUser;
53 |
54 | const updatedUserSettingsData = {
55 | user_name: formData.user_name,
56 | email: formData.email,
57 | phone_number: formData.phone_number,
58 | image: formData.image,
59 | findme: formData.findme,
60 | search_map_center: formData.search_map_center
61 | } as IUserSettings;
62 |
63 | const userSettingsData = await addOrUpdateUserSettings(userData, updatedUserSettingsData, updatedUserSettingsData.image ?? '');
64 |
65 | expect(userSettingsData).toEqual(expect.objectContaining({
66 | user_settings_id: userData.pi_uid,
67 | user_name: updatedUserSettingsData.user_name,
68 | email: updatedUserSettingsData.email,
69 | phone_number: updatedUserSettingsData.phone_number,
70 | image: updatedUserSettingsData.image,
71 | findme: updatedUserSettingsData.findme,
72 | search_map_center: updatedUserSettingsData.search_map_center
73 | }));
74 | });
75 | });
--------------------------------------------------------------------------------
/backend/tasks/move_documents.js:
--------------------------------------------------------------------------------
1 | // usage: node move_documents.js sourceCollection targetCollection batchSize
2 |
3 | const mongoose = require('mongoose');
4 | const dbConnection = require('../build/src/config/dbConnection.js');
5 | // NOTE: This file will need to be manually uploaded to the pod running this script
6 | const documentIds = require('./documentIds.json');
7 |
8 | // Implementation for moveDocuments/insertBatch/deleteBatch copied and modified from
9 | // https://stackoverflow.com/questions/27039083/mongodb-move-documents-from-one-collection-to-another-collection
10 |
11 | const insertBatch = async (collection, documents) => {
12 | const bulkInsert = collection.initializeUnorderedBulkOp();
13 |
14 | let insertedIds = [];
15 | let id;
16 | documents.forEach(function(doc) {
17 | id = doc._id;
18 | // Insert without raising an error for duplicates
19 | bulkInsert.find({_id: id}).upsert().replaceOne(doc);
20 | insertedIds.push(id);
21 | });
22 |
23 | await bulkInsert.execute();
24 | return insertedIds;
25 | }
26 |
27 | const deleteBatch = async (collection, documents) => {
28 | const bulkRemove = collection.initializeUnorderedBulkOp();
29 |
30 | documents.forEach(function(doc) {
31 | bulkRemove.find({_id: doc._id}).deleteOne();
32 | });
33 |
34 | await bulkRemove.execute();
35 | }
36 |
37 | const moveDocuments = async (sourceCollectionName, targetCollectionName, batchSize) => {
38 | await dbConnection.connectDB();
39 | const db = mongoose.connection.db;
40 |
41 | const sourceCollection = db.collection(sourceCollectionName);
42 | const targetCollection = db.collection(targetCollectionName);
43 |
44 | const ids = documentIds.map(id => new mongoose.Types.ObjectId(id));
45 | filter = { _id: { $in: ids } };
46 |
47 | let count = await sourceCollection.find(filter).count();
48 | console.log("Moving " + count + " documents from " + sourceCollectionName + " to " + targetCollectionName);
49 | while ((count = await sourceCollection.find(filter).count()) > 0) {
50 | console.log(count + " documents remaining");
51 | sourceDocs = await sourceCollection.find(filter).limit(batchSize).toArray();
52 | idsOfCopiedDocs = await insertBatch(targetCollection, sourceDocs);
53 |
54 | targetDocs = await targetCollection.find({_id: { $in: idsOfCopiedDocs }}).toArray();
55 | await deleteBatch(sourceCollection, targetDocs);
56 | }
57 | console.log("Done!");
58 |
59 | return;
60 | }
61 |
62 | const main = async () => {
63 | let [_, __, sourceCollection, targetCollection, batchSize] = process.argv;
64 | batchSize = parseInt(batchSize);
65 |
66 | if ([sourceCollection, targetCollection, batchSize].includes(undefined)) {
67 | console.log("Error: Incorrect number of args.");
68 | console.log("Usage: node move_documents.js sourceCollection targetCollection batchSize");
69 |
70 | return;
71 | }
72 |
73 | if (isNaN(batchSize)) {
74 | console.log("Error: batchSize must be a Number. (Note: decimal values will be truncated to an integer.)");
75 |
76 | return;
77 | }
78 |
79 | await moveDocuments(sourceCollection, targetCollection, batchSize);
80 |
81 | return;
82 | }
83 |
84 | main().then((_) => {
85 | process.exit(0);
86 | }, (error) => {
87 | console.log(`An error occurred: ${error.message}`);
88 |
89 | process.exit(1);
90 | });
91 |
--------------------------------------------------------------------------------
/frontend/src/utils/geolocation.ts:
--------------------------------------------------------------------------------
1 | import { DeviceLocationType, IUserSettings } from '@/constants/types';
2 | import logger from '../../logger.config.mjs';
3 |
4 | // Get device location, first trying GPS, and then falling back to IP-based geolocation
5 | const getDeviceLocation = async (): Promise<[number, number] | null> => {
6 | if (navigator.geolocation) {
7 | try {
8 | const position = await new Promise((resolve, reject) =>
9 | navigator.geolocation.getCurrentPosition(resolve, reject, {
10 | enableHighAccuracy: true,
11 | timeout: 5000,
12 | maximumAge: 0,
13 | })
14 | );
15 | return [position.coords.latitude, position.coords.longitude];
16 | } catch (error) {
17 | logger.warn('GPS location error:', (error as GeolocationPositionError).message);
18 | return null
19 | }
20 | }
21 | logger.warn('Unable to get device location by GPS');
22 | return null;
23 | };
24 |
25 | // Function to check user search center and return appropriate location
26 | export const userLocation = async (userSettings: IUserSettings): Promise<[number, number] | null> => {
27 | if (!userSettings) {
28 | logger.warn('User settings not found');
29 | return null;
30 | }
31 |
32 | // Handle Automatic location finding preference
33 | if (userSettings.findme === DeviceLocationType.Automatic) {
34 | try {
35 | let location = await getDeviceLocation();
36 | if (location) {
37 | logger.info(`[Auto FindMe] GPS location: ${location[0]}, ${location[1]}`);
38 | return location;
39 | }
40 |
41 | // Fallback to search center if GPS fails
42 | if (userSettings.search_map_center?.coordinates) {
43 | const searchCenter = userSettings.search_map_center.coordinates;
44 | location = [searchCenter[1], searchCenter[0]];
45 | logger.info(`[Auto FindMe] Using search center location: ${location[0]}, ${location[1]}`);
46 | return location;
47 | }
48 |
49 | return null;
50 | } catch (error) {
51 | logger.error('Failed to retrieve automatic device location:', error);
52 | return null;
53 | }
54 | }
55 |
56 | // Handle GPS-only preference
57 | if (userSettings.findme === DeviceLocationType.GPS) {
58 | try {
59 | const location = await getDeviceLocation();
60 | if (location) {
61 | logger.info(`[GPS] User location: ${location[0]}, ${location[1]}`);
62 | return location;
63 | }
64 | return null;
65 | } catch (error) {
66 | logger.error('Failed to retrieve GPS device location:', error);
67 | return null;
68 | }
69 | }
70 |
71 | // Handle Search Center-only preference
72 | if (
73 | userSettings.findme === DeviceLocationType.SearchCenter &&
74 | Array.isArray(userSettings.search_map_center?.coordinates) &&
75 | userSettings.search_map_center.coordinates.length === 2
76 | ) {
77 | const searchCenter = userSettings.search_map_center.coordinates;
78 | const location: [number, number] = [searchCenter[1], searchCenter[0]]; // Ensures it's a tuple of exactly two numbers
79 | logger.info(`[Search Center] User location: ${location[0]}, ${location[1]}`);
80 | return location;
81 | } else {
82 | logger.warn('Invalid search center coordinates.');
83 | return null;
84 | }
85 | };
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | Map of Pi
2 |
3 |
4 |
5 | [](https://github.com/pi-apps/PiOS/blob/main/pi-commerce.md)
6 | 
7 | 
8 |
9 |
10 |
11 |
12 |
Map of Pi is a mobile application developed to help Pi community members easily locate local businesses that accept Pi as payment. This project was initiated as part of the Pi Commerce Hackathon with the goal of facilitating Pi transactions and connecting businesses with the Pi community.
13 |
14 |
15 | ## Table of Contents
16 |
17 | - [Brand Design](#brand-design)
18 | - [Tech Stack](#tech-stack)
19 | - [Backend Local Execution](#backend-local-execution)
20 | - [Team](#team)
21 | - [Contributions](#contributions)
22 |
23 | ## Brand Design
24 |
25 | | App Logo | App Icon |
26 | | ------------- |:-------------:|
27 | | |
28 |
29 | ## Tech Stack 📊
30 |
31 | - **Frontend**: NextJS/ React, TypeScript, HTML, SCSS, CSS
32 | - **Backend**: Express/ NodeJS, REST API
33 | - **Database**: MongoDB
34 | - **DevOps**: Docker, GitHub Actions
35 |
36 | ## Backend Local Execution
37 |
38 | The Map of Pi Back End is a [Node.js](https://nodejs.org/) project.
39 |
40 | ### Build the Project
41 |
42 | - Run `yarn run build` to build the project; compile Typescript into Javascript for production to the `.dist` folder.
43 | - The build artifacts are bundled for production mode.
44 |
45 | ### Execute the Development Server
46 |
47 | - Create .env file from the .env.development template and replace placeholders with actual values.
48 | - Execute `yarn run dev` to connect to nodemon and MongoDB server.
49 | - Navigate to http://localhost:8001/ in your browser.
50 | - Execute **Frontend Local Execution** for integration testing. Alternatively, utilize API tools like Insomnia or Postman to execute the API endpoints.
51 | - The application will automatically reload if you change any of the source files.
52 | - For local debugging in VS Code, attach the runtime server to the appropriate Process ID.
53 |
54 | ### Execute Unit Tests
55 |
56 | - Run `yarn run test` to execute the unit tests via [Jest](https://jestjs.io/).
57 |
58 | ## Team 🧑👩🦱🧔👨🏾🦱👨🏾
59 |
60 | ### Project Manager
61 | - Philip Jennings
62 |
63 | ### Marketing
64 | - Bonnie Ford
65 | - Joseph Ciccone
66 |
67 | ### Solution Design / UX
68 | - Femma Ashraf
69 | - Oluwabukola Adesina
70 | - Folorunsho Omotunde
71 | - Henry Fasakin
72 |
73 | ### Technical Lead/ DevOps
74 | - Danny Lee
75 |
76 | ### Technical Advisor
77 | - Zoltan Magyar
78 |
79 | ### Application Developers
80 | - Darin Hajou
81 | - Rokundo Soleil
82 | - Ayomikun Omotosho
83 | - Yusuf Adisa
84 | - Francis Mwaura
85 | - Samuel Oluyomi
86 |
87 | ## Contributions
88 |
89 |
90 |
We welcome contributions from the community to improve the Map of Pi project.
91 |
92 |
--------------------------------------------------------------------------------
/backend/src/services/admin/toggle.service.ts:
--------------------------------------------------------------------------------
1 | import Toggle from "../../models/misc/Toggle";
2 | import { IToggle } from "../../types";
3 |
4 | import logger from "../../config/loggingConfig";
5 |
6 | export const getToggles = async (): Promise => {
7 | try {
8 | const toggles = await Toggle.find().sort({ createdAt: -1 }).exec();
9 | logger.info(`Successfully retrieved ${toggles.length} toggle(s)`);
10 | return toggles;
11 | } catch (error: any) {
12 | logger.error(`Failed to retrieve toggles: ${ error }`);
13 | throw error;
14 | }
15 | };
16 |
17 | export const getToggleByName = async (name: string): Promise => {
18 | try {
19 | const toggle = await Toggle.findOne({ name }).exec();
20 | return toggle ? toggle as IToggle : null;
21 | } catch (error: any) {
22 | logger.error(`Failed to retrieve toggle with identifier ${ name }: ${ error }`);
23 | throw error;
24 | }
25 | };
26 |
27 | export const addToggle = async (toggleData: IToggle): Promise => {
28 | try {
29 | // Check if a toggle with the same name already exists
30 | const existingToggle = await Toggle.findOne({ name: toggleData.name }).exec();
31 | if (existingToggle) {
32 | throw new Error(`A toggle with the identifier ${toggleData.name} already exists.`);
33 | }
34 |
35 | // Create the new toggle instance
36 | const newToggle = new Toggle({
37 | ...toggleData
38 | });
39 | const savedToggle = await newToggle.save();
40 | return savedToggle as IToggle;
41 | } catch (error: any) {
42 | if (error.message.includes('already exists')) {
43 | throw error;
44 | }
45 | logger.error(`Failed to add toggle: ${ error }`);
46 | throw error;
47 | }
48 | };
49 |
50 | export const updateToggle = async (
51 | name: string,
52 | enabled: boolean,
53 | description?: string
54 | ): Promise => {
55 | try {
56 | const updateData: any = { enabled };
57 |
58 | // Only update the description if it's provided and not an empty string
59 | if (description !== undefined && description !== '') {
60 | updateData.description = description;
61 | }
62 |
63 | // Find and update the toggle by name
64 | const updatedToggle = await Toggle.findOneAndUpdate(
65 | { name },
66 | { $set: updateData },
67 | { new: true }
68 | ).exec();
69 |
70 | if (!updatedToggle) {
71 | throw new Error(`A toggle with the identifier ${name} does not exist.`);
72 | }
73 |
74 | logger.info('Toggle successfully updated in the database:', updatedToggle);
75 | return updatedToggle as IToggle;
76 | } catch (error: any) {
77 | if (error.message.includes('does not exist')) {
78 | throw error;
79 | }
80 | logger.error(`Failed to update toggle: ${ error }`);
81 | throw error;
82 | }
83 | };
84 |
85 | export const deleteToggleByName = async (name: string): Promise => {
86 | try {
87 | const deletedToggle = await Toggle.findOneAndDelete({ name }).exec();
88 |
89 | if (!deletedToggle) {
90 | logger.warn(`A toggle with the identifier ${name} does not exist.`);
91 | return null;
92 | }
93 | logger.info('Toggle successfully deleted in the database:', deletedToggle);
94 | return deletedToggle as IToggle;
95 | } catch (error: any) {
96 | logger.error(`Failed to delete toggle with identifier ${ name }: ${ error }`);
97 | throw error;
98 | }
99 | };
--------------------------------------------------------------------------------
/backend/src/types.ts:
--------------------------------------------------------------------------------
1 | import { Document, Types } from "mongoose";
2 | import { DeviceLocationType } from "./models/enums/deviceLocationType";
3 | import { RatingScale } from "./models/enums/ratingScale";
4 | import { SellerType } from "./models/enums/sellerType";
5 | import { FulfillmentType } from "./models/enums/fulfillmentType";
6 | import { StockLevelType } from "./models/enums/stockLevelType";
7 | import { TrustMeterScale } from "./models/enums/trustMeterScale";
8 |
9 | export interface IUser extends Document {
10 | pi_uid: string;
11 | pi_username: string;
12 | user_name: string;
13 | }
14 |
15 | export interface IUserSettings extends Document {
16 | user_settings_id: string;
17 | user_name: string;
18 | email?: string | null;
19 | phone_number?: string | null;
20 | image?: string;
21 | findme: DeviceLocationType;
22 | trust_meter_rating: TrustMeterScale;
23 | search_map_center?: {
24 | type: 'Point';
25 | coordinates: [number, number];
26 | };
27 | search_filters?: {
28 | include_active_sellers: Boolean;
29 | include_inactive_sellers: Boolean;
30 | include_test_sellers: Boolean;
31 | include_trust_level_100: Boolean;
32 | include_trust_level_80: Boolean;
33 | include_trust_level_50: Boolean;
34 | include_trust_level_0: Boolean;
35 | };
36 | }
37 |
38 | export interface ISeller extends Document {
39 | seller_id: string;
40 | name: string;
41 | seller_type: SellerType;
42 | description: string;
43 | image?: string;
44 | address?: string;
45 | average_rating: Types.Decimal128;
46 | sell_map_center: {
47 | type: 'Point';
48 | coordinates: [number, number];
49 | };
50 | order_online_enabled_pref: boolean;
51 | fulfillment_method: FulfillmentType;
52 | fulfillment_description?: string;
53 | }
54 |
55 | export interface ISellerItem extends Document {
56 | _id: string;
57 | seller_id: string;
58 | name: string;
59 | description: string;
60 | price: Types.Decimal128;
61 | stock_level: StockLevelType;
62 | image?: string;
63 | duration: number;
64 | expired_by: Date;
65 | createdAt: Date;
66 | updatedAt: Date;
67 | }
68 | export interface IReviewFeedback extends Document {
69 | _id: string;
70 | review_receiver_id: string;
71 | review_giver_id: string;
72 | reply_to_review_id: string | null;
73 | rating: RatingScale;
74 | comment?: string;
75 | image?: string;
76 | review_date: Date;
77 | }
78 |
79 | export interface CompleteFeedback {
80 | givenReviews: IReviewFeedbackOutput[];
81 | receivedReviews: IReviewFeedbackOutput[];
82 | }
83 |
84 | export interface IMapCenter {
85 | type: 'Point';
86 | coordinates: [number, number];
87 | }
88 |
89 | // Select specific fields from IUserSettings
90 | export type PartialUserSettings = Pick;
91 |
92 | // Combined interface representing a seller with selected user settings
93 | export interface ISellerWithSettings extends ISeller, PartialUserSettings {}
94 |
95 | export type PartialReview = {
96 | giver: string;
97 | receiver: string;
98 | }
99 |
100 | export interface IReviewFeedbackOutput extends IReviewFeedback, PartialReview {}
101 |
102 | export interface IToggle extends Document {
103 | name: string;
104 | enabled: boolean;
105 | description?: string;
106 | createdAt: Date;
107 | updatedAt: Date;
108 | }
--------------------------------------------------------------------------------
/frontend/src/components/shared/confirm.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from "next-intl";
2 | import { useRouter } from 'next/navigation';
3 | import React, { SetStateAction } from 'react';
4 | import { createPortal } from 'react-dom';
5 | import { IoMdClose } from 'react-icons/io';
6 |
7 | const ConfirmDialog = ({ show, onClose, message, url }: any) => {
8 | const t = useTranslations();
9 | const router = useRouter();
10 |
11 | const handleClicked = () => {
12 | router.push(url);
13 | }
14 |
15 | if (!show) return null;
16 |
17 | return createPortal(
18 |
19 |
20 |
24 |
25 |
26 |
27 |
{message}
28 |
29 |
33 | {t('SHARED.CONFIRM')}
34 |
35 |
36 |
37 |
38 |
,
39 | document.body
40 | );
41 | };
42 |
43 | export const ConfirmDialogX = ({ toggle, handleClicked, message }: any) => {
44 | const t = useTranslations();
45 |
46 | return createPortal(
47 |
48 |
49 |
53 |
54 |
55 |
56 |
{message}
57 |
58 |
62 | {t('SHARED.CONFIRM')}
63 |
64 |
65 |
66 |
67 |
,
68 | document.body
69 | );
70 | }
71 |
72 | export const Notification:React.FC<{
73 | message: string;
74 | showDialog: boolean;
75 | setShowDialog: React.Dispatch>;
76 | }> = ({ message, showDialog, setShowDialog }) => {
77 |
78 | const onClose = () => {
79 | setShowDialog(false);
80 | }
81 |
82 | return (
83 |
96 | )
97 | }
98 |
99 | export default ConfirmDialog;
--------------------------------------------------------------------------------
/frontend/src/constants/types.ts:
--------------------------------------------------------------------------------
1 | export interface IUser {
2 | pi_uid: string;
3 | pi_username: string;
4 | user_name: string;
5 | }
6 |
7 | export interface IUserSettings {
8 | user_settings_id?: string | null;
9 | user_name?: string | null;
10 | email?: string | null;
11 | phone_number?: string | null;
12 | image?: string;
13 | findme?: string;
14 | trust_meter_rating: number;
15 | search_map_center?: {
16 | type: 'Point';
17 | coordinates: [number, number];
18 | };
19 | search_filters?: {
20 | include_active_sellers: boolean | undefined;
21 | include_inactive_sellers: boolean | undefined;
22 | include_test_sellers: boolean | undefined;
23 | include_trust_level_100: boolean | undefined;
24 | include_trust_level_80: boolean | undefined;
25 | include_trust_level_50: boolean | undefined;
26 | include_trust_level_0: boolean | undefined;
27 | };
28 | }
29 |
30 | export interface ISeller {
31 | seller_id: string;
32 | name: string;
33 | description: string;
34 | seller_type: string;
35 | image: string;
36 | address: string;
37 | average_rating: {
38 | $numberDecimal: string;
39 | };
40 | sell_map_center: {
41 | type: 'Point';
42 | coordinates: [number, number];
43 | };
44 | coordinates: [number, number];
45 | order_online_enabled_pref: boolean;
46 | fulfillment_method: string;
47 | fulfillment_description?: string;
48 | }
49 |
50 | export interface IReviewFeedback {
51 | _id: string;
52 | review_receiver_id: string;
53 | review_giver_id: string;
54 | reply_to_review_id: string | null;
55 | rating: number;
56 | comment: string;
57 | image: string;
58 | review_date: string;
59 | }
60 |
61 | export interface ReviewInt {
62 | heading: string;
63 | date: string;
64 | time: string;
65 | giver: string;
66 | receiver: string;
67 | reviewId: string;
68 | receiverId: string;
69 | giverId: string;
70 | reaction: string;
71 | unicode: string;
72 | image: string;
73 | }
74 |
75 | export enum DeviceLocationType {
76 | Automatic = 'auto',
77 | GPS = 'deviceGPS',
78 | SearchCenter = 'searchCenter'
79 | }
80 |
81 | export enum FulfillmentType {
82 | CollectionByBuyer = 'Collection by buyer',
83 | DeliveredToBuyer = 'Delivered to buyer'
84 | }
85 |
86 | export enum StockLevelType {
87 | available_1 = '1 available',
88 | available_2 = '2 available',
89 | available_3 = '3 available',
90 | many = 'Many available',
91 | made_to_order = 'Made to order',
92 | ongoing_service = 'Ongoing service',
93 | sold = 'Sold'
94 | }
95 |
96 | // Select specific fields from IUserSettings
97 | export type PartialUserSettings = Pick;
98 |
99 | // Combined interface representing a seller with selected user settings
100 | export interface ISellerWithSettings extends ISeller, PartialUserSettings {}
101 |
102 | export type SellerItem = {
103 | _id: string;
104 | seller_id: string;
105 | name: string;
106 | description?: string;
107 | duration: number;
108 | stock_level: StockLevelType;
109 | image?: string;
110 | price: {
111 | $numberDecimal: number;
112 | };
113 | created_at?: Date;
114 | updated_at?: Date;
115 | expired_by?: Date;
116 | }
117 |
118 | export type PartialReview = {
119 | giver: string;
120 | receiver: string;
121 | }
122 |
123 | export interface IReviewOutput extends IReviewFeedback, PartialReview {}
--------------------------------------------------------------------------------
/frontend/src/services/userSettingsApi.ts:
--------------------------------------------------------------------------------
1 | import axiosClient from "@/config/client";
2 | import { getMultipartFormDataHeaders } from "@/utils/api";
3 |
4 | import logger from '../../logger.config.mjs';
5 |
6 | // Fetch the user settings of the user
7 | export const fetchUserSettings = async () => {
8 | try {
9 | logger.info('Fetching user settings..');
10 | const response = await axiosClient.post(`/user-preferences/me`);
11 | if (response.status === 200) {
12 | logger.info(`Fetch user settings successful with Status ${response.status}`, {
13 | data: response.data
14 | });
15 | return response.data;
16 | } else {
17 | logger.error(`Fetch user settings failed with Status ${response.status}`);
18 | return null;
19 | }
20 | } catch (error) {
21 | logger.error('Fetch user settings encountered an error:', error);
22 | throw new Error('Failed to fetch user settings. Please try again later.');
23 | }
24 | };
25 |
26 | // Fetch a single pioneer user settings
27 | export const fetchSingleUserSettings = async (sellerId: String) => {
28 | try {
29 | logger.info(`Fetching user settings for seller ID: ${sellerId}`);
30 | const response = await axiosClient.get(`/user-preferences/${sellerId}`);
31 | if (response.status === 200) {
32 | logger.info(`Fetch single user settings successful with Status ${response.status}`, {
33 | data: response.data
34 | });
35 | return response.data;
36 | } else {
37 | logger.error(`Fetch single user settings failed with Status ${response.status}`);
38 | return null;
39 | }
40 | } catch (error) {
41 | logger.error('Fetch single user settings encountered an error:', error);
42 | throw new Error('Failed to fetch single user settings. Please try again later.');
43 | }
44 | };
45 |
46 | // Create new or update existing user settings
47 | export const createUserSettings = async (formData: FormData) => {
48 | try {
49 | logger.info('Creating or updating user settings with formData..');
50 | const headers = getMultipartFormDataHeaders();
51 |
52 | const response = await axiosClient.put('/user-preferences/add', formData, { headers });
53 |
54 | if (response.status === 200) {
55 | logger.info(`Create or update user settings successful with Status ${response.status}`, {
56 | data: response.data
57 | });
58 | return response.data;
59 | } else {
60 | logger.error(`Create or update user settings failed with Status ${response.status}`);
61 | return null;
62 | }
63 | } catch (error) {
64 | logger.error('Create or update user settings encountered an error:', error);
65 | throw new Error('Failed to create or update user settings. Please try again later.');
66 | }
67 | };
68 |
69 | // Fetch the user location of the user
70 | export const fetchUserLocation = async () => {
71 | try {
72 | logger.info('Fetching user location..');
73 | const headers = getMultipartFormDataHeaders();
74 | const response = await axiosClient.get(`/user-preferences/location/me`, { headers });
75 | if (response.status === 200) {
76 | logger.info(`Fetch user location successful with Status ${response.status}`, {
77 | data: response.data
78 | });
79 | return response.data;
80 | } else {
81 | logger.error(`Fetch user location failed with Status ${response.status}`);
82 | return null;
83 | }
84 | } catch (error) {
85 | logger.error('Fetch user location encountered an error:', error);
86 | throw error;
87 | }
88 | };
89 |
--------------------------------------------------------------------------------