├── 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 | 8 | 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 |
12 |
13 |
14 |
15 |
16 | 17 |
18 |
19 |
20 |
21 |
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 |
18 |
21 |
24 |
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 |
24 |

25 |
26 |
27 | Trust-o-meter rating 34 |
35 |
36 |
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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 | 12 | ); 13 | }; 14 | 15 | export const Button = (props: any) => { 16 | const { styles, icon, label, disabled, onClick } = props; 17 | return ( 18 | 26 | ); 27 | }; 28 | 29 | export const OutlineBtn = (props: any) => { 30 | const { styles, icon, label, disabled, onClick } = props; 31 | return ( 32 | 40 | ); 41 | }; 42 | 43 | export const YellowBtn = (props: any) => { 44 | return ( 45 |
46 | 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 | [![Hackathon](https://img.shields.io/badge/hackathon-PiCommerce-purple.svg)](https://github.com/pi-apps/PiOS/blob/main/pi-commerce.md) 6 | ![Status](https://img.shields.io/badge/status-active-success.svg) 7 | ![License](https://img.shields.io/badge/license-PIOS-blue.svg) 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 | | map-of-pi-logo-revised-3 | map-of-pi-app-icon-revised-3b-transparent 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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 | 19 |
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 | -------------------------------------------------------------------------------- /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 |