├── packages ├── app │ ├── .gitignore │ ├── README.md │ ├── .eslintrc.json │ ├── public │ │ ├── favicon.ico │ │ ├── assets │ │ │ ├── font │ │ │ │ ├── Nunito-Bold.ttf │ │ │ │ ├── Nunito-Med.ttf │ │ │ │ ├── Nunito-Black.ttf │ │ │ │ ├── Poppins-Medium.ttf │ │ │ │ └── Poppins-Regular.ttf │ │ │ └── img │ │ │ │ ├── pattern │ │ │ │ ├── grid.svg │ │ │ │ └── pattern.svg │ │ │ │ └── logo │ │ │ │ └── leaf-logo.svg │ │ ├── vercel.svg │ │ └── next.svg │ ├── pages │ │ ├── api │ │ │ ├── controller │ │ │ │ ├── base.ts │ │ │ │ ├── user.ts │ │ │ │ ├── payment.ts │ │ │ │ ├── assistant.ts │ │ │ │ └── product.ts │ │ │ ├── config │ │ │ │ ├── prisma.ts │ │ │ │ ├── resend.ts │ │ │ │ ├── axios.ts │ │ │ │ └── env.ts │ │ │ ├── prisma │ │ │ │ ├── migrations │ │ │ │ │ ├── migration_lock.toml │ │ │ │ │ └── 20230819164143_ │ │ │ │ │ │ └── migration.sql │ │ │ │ └── schema.prisma │ │ │ ├── graphql │ │ │ │ ├── typeDef │ │ │ │ │ ├── assistant.type.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── payment.type.ts │ │ │ │ │ ├── user.type.ts │ │ │ │ │ └── product.type.ts │ │ │ │ ├── resolvers │ │ │ │ │ ├── assistant.res.ts │ │ │ │ │ ├── user.res.ts │ │ │ │ │ ├── payment.res.ts │ │ │ │ │ └── product.res.ts │ │ │ │ ├── middlewares │ │ │ │ │ └── auth.ts │ │ │ │ └── index.ts │ │ │ ├── helper │ │ │ │ ├── errorHandler.ts │ │ │ │ ├── index.ts │ │ │ │ ├── sendMail.ts │ │ │ │ └── validator.ts │ │ │ ├── webhook.ts │ │ │ └── paystack_webhook.ts │ │ ├── _document.tsx │ │ ├── 404.tsx │ │ ├── auth │ │ │ └── index.tsx │ │ ├── _app.tsx │ │ ├── index.tsx │ │ ├── cart.tsx │ │ └── dashboard.tsx │ ├── postcss.config.cjs │ ├── next-env.d.ts │ ├── config │ │ └── axios.ts │ ├── components │ │ ├── Pattern.tsx │ │ ├── MarkdownRender.tsx │ │ ├── StarRating.tsx │ │ ├── Layout.tsx │ │ ├── BlurBgRadial.tsx │ │ ├── EmailTemplate.tsx │ │ ├── Spinner.tsx │ │ ├── Image.tsx │ │ ├── BottomNav.tsx │ │ ├── Modal │ │ │ └── index.tsx │ │ └── Assistant │ │ │ └── index.tsx │ ├── next.config.js │ ├── helpers │ │ ├── useIsRendered.ts │ │ ├── clientError.ts │ │ ├── imgManipulation.ts │ │ ├── withoutAuth.tsx │ │ ├── withAuth.tsx │ │ ├── useIsAuth.ts │ │ └── useWeather.tsx │ ├── http │ │ ├── error.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── utils │ │ ├── isAuthenticated.ts │ │ └── index.ts │ ├── styles │ │ ├── nprogress.css │ │ └── globals.css │ ├── package.json │ ├── @types │ │ └── index.d.ts │ └── tailwind.config.js └── ui │ └── README.md ├── md-img ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png └── 6.png ├── .gitignore ├── package.json ├── README.md └── research.md /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /.env -------------------------------------------------------------------------------- /packages/app/README.md: -------------------------------------------------------------------------------- 1 | # Seedz 2 | -------------------------------------------------------------------------------- /packages/ui/README.md: -------------------------------------------------------------------------------- 1 | ## UI components. -------------------------------------------------------------------------------- /md-img/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/md-img/1.png -------------------------------------------------------------------------------- /md-img/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/md-img/2.png -------------------------------------------------------------------------------- /md-img/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/md-img/3.png -------------------------------------------------------------------------------- /md-img/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/md-img/4.png -------------------------------------------------------------------------------- /md-img/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/md-img/5.png -------------------------------------------------------------------------------- /md-img/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/md-img/6.png -------------------------------------------------------------------------------- /packages/app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /packages/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/packages/app/public/favicon.ico -------------------------------------------------------------------------------- /packages/app/pages/api/controller/base.ts: -------------------------------------------------------------------------------- 1 | class BaseController { 2 | constructor() {} 3 | } 4 | 5 | export default BaseController; 6 | -------------------------------------------------------------------------------- /packages/app/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports={ 2 | "plugins": { 3 | "tailwindcss": {}, 4 | "autoprefixer": {} 5 | } 6 | } -------------------------------------------------------------------------------- /packages/app/public/assets/font/Nunito-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/packages/app/public/assets/font/Nunito-Bold.ttf -------------------------------------------------------------------------------- /packages/app/public/assets/font/Nunito-Med.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/packages/app/public/assets/font/Nunito-Med.ttf -------------------------------------------------------------------------------- /packages/app/public/assets/font/Nunito-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/packages/app/public/assets/font/Nunito-Black.ttf -------------------------------------------------------------------------------- /packages/app/public/assets/font/Poppins-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/packages/app/public/assets/font/Poppins-Medium.ttf -------------------------------------------------------------------------------- /packages/app/public/assets/font/Poppins-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benrobo/Seedz/HEAD/packages/app/public/assets/font/Poppins-Regular.ttf -------------------------------------------------------------------------------- /packages/app/pages/api/config/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export default prisma; 6 | -------------------------------------------------------------------------------- /packages/app/pages/api/config/resend.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | 3 | const resend = new Resend(process.env.RESEND_API_KEY); 4 | 5 | export default resend -------------------------------------------------------------------------------- /packages/app/pages/api/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /node_modules 3 | 4 | # packages node_modules 5 | /packages/**/node_modules 6 | /packages/**/node_modules 7 | /packages/**/.next 8 | /packages/**/.env 9 | /packages/**/.env.local 10 | -------------------------------------------------------------------------------- /packages/app/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/app/config/axios.ts: -------------------------------------------------------------------------------- 1 | import ENV from "@/pages/api/config/env"; 2 | import axios from "axios"; 3 | 4 | const $request = axios.create({ 5 | baseURL: `${ENV.clientUrl}/api`, 6 | timeout: 5000, 7 | }); 8 | 9 | export default $request; 10 | -------------------------------------------------------------------------------- /packages/app/pages/api/config/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const $http = axios.create({ 4 | baseURL: "https://api.paystack.co", 5 | timeout: 5000, 6 | headers: { 7 | Authorization: `Bearer ${process.env.PS_TEST_SEC}`, 8 | }, 9 | }); 10 | 11 | export default $http; 12 | -------------------------------------------------------------------------------- /packages/app/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/components/Pattern.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | interface PatternProps { 5 | className?: React.ComponentProps<"div">["className"]; 6 | } 7 | 8 | function Pattern({ className }: PatternProps) { 9 | return
; 10 | } 11 | 12 | export default Pattern; 13 | -------------------------------------------------------------------------------- /packages/app/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | typescript: { 5 | // !! WARN !! 6 | // Dangerously allow production builds to successfully complete even if 7 | // your project has type errors. 8 | // !! WARN !! 9 | ignoreBuildErrors: true, 10 | }, 11 | } 12 | 13 | module.exports = nextConfig 14 | -------------------------------------------------------------------------------- /packages/app/helpers/useIsRendered.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function useIsRendered(timer?: number) { 4 | const [loading, setLoading] = React.useState(false); 5 | 6 | React.useEffect(() => { 7 | const timeout = setTimeout(() => { 8 | clearTimeout(timeout); 9 | setLoading(true); 10 | }, (timer as number) * 1000 ?? 2000); 11 | }); 12 | 13 | return loading; 14 | } 15 | 16 | export default useIsRendered; 17 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/typeDef/assistant.type.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const seedzAiTypeDef = gql` 4 | type Mutation { 5 | askSeedzAi(payload: AiInput!): AiOutput! 6 | } 7 | 8 | input AiInput { 9 | question: String! 10 | lang: String! 11 | } 12 | 13 | type AiOutput { 14 | answer: [String]! 15 | lang: String! 16 | success: Boolean! 17 | } 18 | `; 19 | 20 | export default seedzAiTypeDef; 21 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/typeDef/index.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | import userTypeDef from "./user.type"; 3 | import paymentTypeDef from "./payment.type"; 4 | import productTypeDef from "./product.type"; 5 | import seedzAiTypeDef from "./assistant.type"; 6 | 7 | const combinedTypeDef = gql` 8 | ${userTypeDef} 9 | ${paymentTypeDef} 10 | ${productTypeDef} 11 | ${seedzAiTypeDef} 12 | `; 13 | 14 | export default combinedTypeDef; 15 | -------------------------------------------------------------------------------- /packages/app/pages/api/helper/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from "graphql"; 2 | 3 | class ServerResponseError extends Error { 4 | extensions; 5 | isCustomError; 6 | constructor(code: string, message: string) { 7 | super(message); 8 | this.name = "ServerResponseError"; 9 | this.extensions = { code }; 10 | 11 | throw new GraphQLError(message, { extensions: { code: code } }); 12 | } 13 | } 14 | 15 | export default ServerResponseError; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prospark-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "private": true, 7 | "workspaces": [ 8 | "packages/*" 9 | ], 10 | "scripts": { 11 | "test": "", 12 | "app": "yarn workspace app run dev", 13 | "build": "prisma generate && yarn workspaces run build", 14 | "watch": "yarn workspaces run watch", 15 | "postinstall": "prisma generate" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC" 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/pages/api/config/env.ts: -------------------------------------------------------------------------------- 1 | const ENV = { 2 | jwtSecret: process.env.JWT_SECRET, 3 | clientUrl: 4 | process.env.NODE_ENV === "development" 5 | ? "http://localhost:3000" 6 | : `https://seedz.vercel.app`, 7 | serverUrl: 8 | process.env.NODE_ENV === "development" 9 | ? "http://localhost:3000/api/graphql" 10 | : "https://seedz.vercel.app/api/graphql", 11 | passageAppId: 12 | process.env.NODE_ENV === "development" 13 | ? "8SK9OUCCPs6xoNP6ieaVuucz" 14 | : "lTlo2IleE1toueKOF6TiA8uI", 15 | }; 16 | 17 | export default ENV; 18 | -------------------------------------------------------------------------------- /packages/app/http/error.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from "@apollo/client"; 2 | import toast from "react-hot-toast"; 3 | 4 | // handle all http Apollo request errors 5 | function handleApolloHttpErrors(error: ApolloError) { 6 | // graphql error 7 | const networkError = error.networkError as any; 8 | const errorObj = networkError?.result?.errors[0] ?? error.graphQLErrors[0]; 9 | 10 | toast.error(errorObj?.message); 11 | 12 | // if (errorObj?.code === "GQL_SERVER_ERROR") { 13 | // } 14 | console.log(JSON.stringify(error, null, 2)); 15 | return errorObj?.message; 16 | } 17 | 18 | export default handleApolloHttpErrors; 19 | -------------------------------------------------------------------------------- /packages/app/helpers/clientError.ts: -------------------------------------------------------------------------------- 1 | import ENV from "@/pages/api/config/env"; 2 | import { HttpLink } from "@apollo/client"; 3 | import { onError } from "@apollo/client/link/error"; 4 | 5 | export const errorLink = onError(({ graphQLErrors, networkError }) => { 6 | if (graphQLErrors) 7 | graphQLErrors.forEach(({ message, locations, path }) => 8 | console.log( 9 | `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` 10 | ) 11 | ); 12 | if (networkError) console.log(`[Network error]: ${networkError}`); 13 | }); 14 | 15 | export const httpLink = new HttpLink({ uri: ENV.serverUrl }); 16 | -------------------------------------------------------------------------------- /packages/app/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/resolvers/assistant.res.ts: -------------------------------------------------------------------------------- 1 | import { SeedzAiPayload } from "@/@types"; 2 | import seedzAIAssistant from "../../controller/assistant"; 3 | import { isLoggedIn } from "../middlewares/auth"; 4 | 5 | const seedzAiResolvers = { 6 | Mutation: { 7 | askSeedzAi: async ( 8 | parent: any, 9 | { payload }: { payload: SeedzAiPayload }, 10 | context: any, 11 | info: any 12 | ) => { 13 | // isAuthenticated middleware 14 | await isLoggedIn(context.req); 15 | 16 | return await seedzAIAssistant(payload); 17 | }, 18 | }, 19 | }; 20 | 21 | export default seedzAiResolvers; 22 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/app/helpers/imgManipulation.ts: -------------------------------------------------------------------------------- 1 | import blurhash, { encode } from "blurhash"; 2 | import sharp from "sharp"; 3 | 4 | export function ImageToBlurHash(url: string) { 5 | // return new Promise((resolve, reject) => { 6 | // sharp(url) 7 | // .raw() 8 | // .ensureAlpha() 9 | // .resize(32, 32, { fit: "inside" }) 10 | // .toBuffer((err, buffer, { width, height }) => { 11 | // if (err) return reject(err); 12 | // resolve(encode(new Uint8ClampedArray(buffer), width, height, 4, 4)); 13 | // }); 14 | // }); 15 | // const imageData = getImageData(url); 16 | // return encode(imageData.data, imageData.width, imageData.height, 4, 4); 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/components/MarkdownRender.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactMarkdown from "react-markdown"; 3 | import MarkdownIt from "markdown-it"; 4 | import remarkGfm from "remark-gfm"; 5 | import rehypeRaw from "rehype-raw"; 6 | 7 | interface MarkdownRendererProps { 8 | content: string; 9 | } 10 | 11 | const MarkdownRenderer: React.FC = ({ content }) => { 12 | const md = new MarkdownIt({ 13 | html: true, 14 | linkify: true, 15 | typographer: true, 16 | }); 17 | 18 | return ( 19 | 20 | {md.render(content)} 21 | 22 | ); 23 | }; 24 | 25 | export default MarkdownRenderer; 26 | -------------------------------------------------------------------------------- /packages/app/helpers/withoutAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import Router, { useRouter } from "next/router"; 3 | import isAuthenticated from "@/utils/isAuthenticated"; 4 | 5 | const withoutAuth =

( 6 | WrappedComponent: React.ComponentType

7 | ) => { 8 | const Wrapper: React.FC

= (props) => { 9 | const router = useRouter(); 10 | useEffect(() => { 11 | const token = localStorage.getItem("psg_auth_token"); 12 | const isLoggedIn = isAuthenticated(token as string); 13 | if (isLoggedIn) { 14 | router.push("/dashboard"); 15 | } 16 | }); 17 | 18 | return ; 19 | }; 20 | 21 | return Wrapper; 22 | }; 23 | 24 | export default withoutAuth; 25 | -------------------------------------------------------------------------------- /packages/app/helpers/withAuth.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import isAuthenticated from "@/utils/isAuthenticated"; 4 | 5 | // mostly used for other pages aside from auth page 6 | const withAuth =

(WrappedComponent: React.ComponentType

) => { 7 | const Wrapper: React.FC

= (props) => { 8 | const router = useRouter(); 9 | 10 | React.useEffect(() => { 11 | const token = localStorage.getItem("psg_auth_token"); 12 | const isLoggedIn = isAuthenticated(token as string); 13 | if (!isLoggedIn) { 14 | router.push("/auth"); 15 | } 16 | }); 17 | 18 | return ; 19 | }; 20 | 21 | return Wrapper; 22 | }; 23 | 24 | export default withAuth; 25 | -------------------------------------------------------------------------------- /packages/app/pages/api/helper/index.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet, customRandom } from "nanoid"; 2 | 3 | export function genID(len: number = 10) { 4 | const nanoid = customAlphabet("1234567890abcdef", 10); 5 | return nanoid(len); 6 | } 7 | 8 | export const CurrencySymbol = { 9 | NGN: "₦", 10 | USD: "$", 11 | }; 12 | 13 | export function formatCurrency(amount: number, currency?: string) { 14 | const formatedNumber = amount.toFixed(1).replace(/\d(?=(\d{3})+\.)/g, "$&,"); 15 | // @ts-ignore 16 | const curr = CurrencySymbol[currency]; 17 | return `${curr ?? ""}${formatedNumber}`; 18 | } 19 | 20 | export function formatNumLocale(amount: number, currency?: string) { 21 | if (!currency) { 22 | return amount.toLocaleString("en-US"); 23 | } 24 | return amount.toLocaleString("en-US", { style: "currency", currency }); 25 | } 26 | -------------------------------------------------------------------------------- /packages/app/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import useIsAuth from "@/helpers/useIsAuth"; 2 | import Link from "next/link"; 3 | import React from "react"; 4 | 5 | function NotFound() { 6 | const isLoggedIn = useIsAuth(); 7 | 8 | return ( 9 |

10 |

11 | 404 | Page Notfound 12 |

13 | 17 | {isLoggedIn ? "Dashboard" : "Go Home"} 18 | 19 |
20 | ); 21 | } 22 | 23 | export default NotFound; 24 | -------------------------------------------------------------------------------- /packages/app/utils/isAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import jwtDecode from "jwt-decode"; 2 | import { isEmpty } from "."; 3 | 4 | const clearLocalStorage = () => { 5 | localStorage.removeItem("psg_auth_token"); 6 | localStorage.removeItem("userData"); 7 | }; 8 | 9 | // meant to be called from client 10 | 11 | function isAuthenticated(token: string) { 12 | if (!token || isEmpty(token)) { 13 | return false; 14 | } 15 | try { 16 | const decodedToken = jwtDecode(token); 17 | const expirationTime = (decodedToken as any).exp * 1000; // convert to milliseconds 18 | 19 | if (Date.now() >= expirationTime) { 20 | clearLocalStorage(); 21 | return false; 22 | } 23 | 24 | return true; 25 | } catch (error) { 26 | console.error(`Error verifying passage token: ${error}`); 27 | // clearLocalStorage(); 28 | return false; 29 | } 30 | } 31 | 32 | export default isAuthenticated; 33 | -------------------------------------------------------------------------------- /packages/app/components/StarRating.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FaStar } from "react-icons/fa"; 3 | 4 | const StarRating = ({ 5 | averageRating, 6 | }: { 7 | averageRating: number; 8 | }): JSX.Element[] => { 9 | const maxStars = 5; 10 | const fullStars = Math.floor( 11 | averageRating > maxStars ? maxStars : averageRating 12 | ); 13 | const remainingStars = maxStars - fullStars; 14 | 15 | const stars = []; 16 | 17 | for (let i = 0; i < fullStars; i++) { 18 | stars.push( 19 | 20 | ); 21 | } 22 | 23 | for (let i = 0; i < remainingStars; i++) { 24 | stars.push( 25 | 30 | ); 31 | } 32 | 33 | return stars; 34 | }; 35 | 36 | export default StarRating; 37 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/resolvers/user.res.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserType } from "../../../../@types"; 2 | import prisma from "../../config/prisma"; 3 | import UserController from "../../controller/user"; 4 | import memcache from "memory-cache"; 5 | import ServerResponseError from "../../helper/errorHandler"; 6 | import { isLoggedIn } from "../middlewares/auth"; 7 | 8 | const userController = new UserController(); 9 | 10 | const userResolvers = { 11 | Query: { 12 | getUser: async (parent: any, payload: any, context: any, info: any) => { 13 | await isLoggedIn(context.req); 14 | return await userController.getUser(context.userId); 15 | }, 16 | }, 17 | Mutation: { 18 | createUser: async ( 19 | _: any, 20 | { payload }: { payload: CreateUserType }, 21 | context: any, 22 | info: any 23 | ) => { 24 | await isLoggedIn(context.req); 25 | return await userController.createUser(payload, context.userId); 26 | }, 27 | }, 28 | }; 29 | 30 | export default userResolvers; 31 | -------------------------------------------------------------------------------- /packages/app/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Pattern from "./Pattern"; 3 | import { twMerge } from "tailwind-merge"; 4 | import BottomNav from "./BottomNav"; 5 | 6 | interface LayoutProps { 7 | children?: React.ReactNode; 8 | className?: React.ComponentProps<"div">["className"]; 9 | activePage?: string; 10 | } 11 | 12 | function Layout({ children, className }: LayoutProps) { 13 | return ( 14 |
20 |
{children}
21 |
22 | ); 23 | } 24 | 25 | export default Layout; 26 | 27 | export function MobileLayout({ children, activePage }: LayoutProps) { 28 | return ( 29 |
30 |
{children}
31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/resolvers/payment.res.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserType } from "../../../../@types"; 2 | import prisma from "../../config/prisma"; 3 | import PaymentController from "../../controller/payment"; 4 | import UserController from "../../controller/user"; 5 | import { isLoggedIn } from "../middlewares/auth"; 6 | 7 | const paymentController = new PaymentController(); 8 | 9 | const paymentResolvers = { 10 | Query: { 11 | // getUser: async (_: any, { id }: GetUserType) => 12 | // await userController.getUser(id), 13 | // getUsers: async () => userController.getUsers(), 14 | }, 15 | Mutation: { 16 | fundWallet: async ( 17 | parent: any, 18 | { amount, currency }: { amount: number; currency: string }, 19 | context: any, 20 | info: any 21 | ) => { 22 | // isAuthenticated middleware 23 | await isLoggedIn(context.req); 24 | return await paymentController.fundWallet( 25 | { amount, currency }, 26 | context.userId 27 | ); 28 | }, 29 | }, 30 | }; 31 | 32 | export default paymentResolvers; 33 | -------------------------------------------------------------------------------- /packages/app/pages/api/helper/sendMail.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import resend from "../config/resend"; 3 | import { ProductCheckoutTemp } from "@/components/EmailTemplate"; 4 | 5 | interface SendMailProps { 6 | from?: string; 7 | to: string | string[]; 8 | subject: string; 9 | template: ReactElement | null; 10 | } 11 | 12 | const sendMail = async ({ from, to, subject, template }: SendMailProps) => { 13 | try { 14 | const data = await resend.emails.send({ 15 | from: from ?? "Seedz ", 16 | to, 17 | subject, 18 | react: template, 19 | }); 20 | 21 | if (typeof (data as any)?.statusCode !== "undefined") { 22 | return { data, success: false }; 23 | } 24 | 25 | console.log(`Email sent to: ${to}`); 26 | // You can return the response or perform any other actions 27 | return { data, success: true }; 28 | } catch (e: any) { 29 | console.log(e); 30 | console.log(`Failed to send email: ${e.message}`); 31 | return { data: null, success: false }; 32 | } 33 | }; 34 | 35 | export default sendMail; 36 | -------------------------------------------------------------------------------- /packages/app/helpers/useIsAuth.ts: -------------------------------------------------------------------------------- 1 | import isAuthenticated from "@/utils/isAuthenticated"; 2 | import React from "react"; 3 | 4 | interface UserInfo { 5 | email: string; 6 | image: string; 7 | fullname: string; 8 | id: string; 9 | role: string; 10 | } 11 | 12 | function useAuth() { 13 | const [loading, setLoading] = React.useState(false); 14 | const [userInfo, setUserInfo] = React.useState({} as UserInfo); 15 | 16 | React.useEffect(() => { 17 | setLoading(true); 18 | const token = 19 | localStorage.getItem("psg_auth_token") === null 20 | ? "" 21 | : localStorage.getItem("psg_auth_token"); 22 | const isLoggedIn = isAuthenticated(token as string); 23 | 24 | setLoading(false); 25 | 26 | if (isLoggedIn) { 27 | const user = 28 | localStorage.getItem("@userInfo") === null 29 | ? null 30 | : JSON.parse(localStorage.getItem("@userInfo") as string); 31 | setUserInfo(user); 32 | } 33 | }, []); 34 | 35 | return { 36 | isLoading: loading, 37 | seedzUserInfo: userInfo, 38 | }; 39 | } 40 | 41 | export default useAuth; 42 | -------------------------------------------------------------------------------- /packages/app/public/assets/img/pattern/grid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /packages/app/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/typeDef/payment.type.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const paymentTypeDef = gql` 4 | type Query { 5 | getWalletInfo: Wallet 6 | } 7 | 8 | type Mutation { 9 | fundWallet(amount: String!, currency: String!): WalletTopUpRes 10 | } 11 | 12 | # Beginning of QUE/MUT Fields 13 | # Wallet topUp 14 | type WalletTopUpRes { 15 | authorization_url: String 16 | access_code: String 17 | reference: String 18 | } 19 | 20 | # End of QUE/MUT Fields 21 | 22 | enum Role { 23 | MERCHANT 24 | SUPPLIER 25 | BUYER 26 | } 27 | 28 | type User { 29 | id: String 30 | email: String 31 | username: String 32 | fullname: String 33 | image: String 34 | role: Role 35 | deliveryAddress: [DeliveryAddress] 36 | transactions: [Transaction] 37 | wallet: Wallet 38 | } 39 | 40 | type DeliveryAddress { 41 | id: String! 42 | street: String 43 | city: String 44 | state: String 45 | postalCode: String 46 | country: String 47 | } 48 | 49 | type Wallet { 50 | id: String! 51 | balance: Float 52 | currency: String 53 | paystackId: String 54 | } 55 | 56 | type Transaction { 57 | id: String! 58 | type: String 59 | amount: Float 60 | description: String 61 | createdAt: String 62 | } 63 | `; 64 | 65 | export default paymentTypeDef; 66 | -------------------------------------------------------------------------------- /packages/app/components/BlurBgRadial.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | interface BlurBgRadialProp { 5 | w?: number; 6 | h?: number; 7 | color?: string; 8 | blurWidth?: "normal" | "medium" | "large"; 9 | className?: React.ComponentProps<"div">["className"]; 10 | roundedCorners?: number; 11 | } 12 | 13 | function BlurBgRadial({ 14 | w, 15 | h, 16 | roundedCorners, 17 | color, 18 | blurWidth, 19 | className, 20 | }: BlurBgRadialProp) { 21 | const blurConfig = { 22 | normal: 90, 23 | medium: 100, 24 | large: 120, 25 | }; 26 | 27 | const SIZE = 28 | typeof w === "undefined" || typeof h === "undefined" 29 | ? "w-[35%] h-[55%]" 30 | : `w-[${w}%] h-[${h}%]`; 31 | const BLUR_WIDTH = 32 | typeof blurWidth === "undefined" 33 | ? "blur-[120px]" 34 | : `blur-[${blurConfig[blurWidth]}px]`; 35 | const borderRadius = 36 | typeof roundedCorners === "undefined" 37 | ? "rounded-[50%]" 38 | : `rounded-[${roundedCorners}%]`; 39 | 40 | return ( 41 |
50 | ); 51 | } 52 | 53 | export default BlurBgRadial; 54 | -------------------------------------------------------------------------------- /packages/app/pages/api/helper/validator.ts: -------------------------------------------------------------------------------- 1 | import Joi from "joi"; 2 | 3 | export const CreateUserSchema = Joi.object({ 4 | role: Joi.string().required(), 5 | email: Joi.string().required(), 6 | fullname: Joi.string().required(), 7 | username: Joi.string().required(), 8 | }); 9 | 10 | export const InitPaymentSchema = Joi.object({ 11 | amount: Joi.string().required(), 12 | currency: Joi.string().required(), 13 | }); 14 | 15 | export const AddProductSchema = Joi.object({ 16 | name: Joi.string().required(), 17 | category: Joi.string().required(), 18 | price: Joi.number().required(), 19 | availableForRent: Joi.boolean().required(), 20 | rentingPrice: Joi.number().required(), 21 | image: Joi.object({ 22 | url: Joi.string().required(), 23 | hash: Joi.string().required(), 24 | }), 25 | quantity: Joi.number().required().min(1), 26 | description: Joi.string().required(), 27 | }); 28 | 29 | export const ProductCheckoutSchema = Joi.object({ 30 | totalAmount: Joi.number().required(), 31 | productQty: Joi.array() 32 | .items( 33 | Joi.object({ 34 | prodId: Joi.string().required(), 35 | name: Joi.string().required(), 36 | qty: Joi.number().required(), 37 | // amount: Joi.number().required(), 38 | }) 39 | ) 40 | .required(), 41 | }); 42 | 43 | export const SeedzAiSchema = Joi.object({ 44 | question: Joi.string().required().max(3000), 45 | lang: Joi.string().required(), 46 | }); 47 | -------------------------------------------------------------------------------- /packages/app/pages/auth/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useEffect } from "react"; 3 | import ImageTag from "../../components/Image"; 4 | import ENV from "../api/config/env"; 5 | import { Passage } from "@passageidentity/passage-js"; 6 | import withoutAuth from "@/helpers/withoutAuth"; 7 | 8 | function Login() { 9 | useEffect(() => { 10 | require("@passageidentity/passage-elements/passage-auth"); 11 | }, []); 12 | 13 | return ( 14 |
15 |
16 |
17 | 18 | 23 | 24 | Seedz 25 |
26 |
27 | {/* @ts-ignore */} 28 | 29 |
30 |
31 |
32 | ); 33 | } 34 | export default withoutAuth(Login); 35 | -------------------------------------------------------------------------------- /packages/app/components/EmailTemplate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface EmailTemplateProps { 4 | firstName: string; 5 | } 6 | 7 | interface ProductCheckoutTempProps { 8 | fullname: string; 9 | email: string; 10 | isBuyer: boolean; 11 | products: Array<{ 12 | name: string; 13 | qty: number; 14 | amount: string; 15 | }>; 16 | type: "DEBIT" | "CREDIT"; 17 | amount: number; 18 | } 19 | 20 | export const ProductCheckoutTemp: React.FC< 21 | Readonly 22 | > = ({ fullname, email, isBuyer, products, amount, type }) => ( 23 |
24 |

Hi, {fullname}!

25 |

{isBuyer ? null : `Order has been placed by ${email}`}

26 |

27 | {isBuyer 28 | ? `Thank you for your recent ${ 29 | type === "DEBIT" ? "purchase" : "transaction" 30 | }` 31 | : null} 32 |

33 |

Here are the details:

34 |
    35 | {products.map((product, index) => ( 36 |
  • 37 | Product: {product.name} ({product.qty} qty) 38 |
    39 | Amount: ₦{product.amount} 40 |
  • 41 | ))} 42 |
43 |

44 | Your{" "} 45 | {type === "DEBIT" 46 | ? `wallet has been debited` 47 | : `wallet has been credited`}{" "} 48 | with the corresponding amount of ₦{amount} 49 |

50 | 51 |

Best regards,

52 |

The Seedz Team

53 |
54 | ); 55 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/typeDef/user.type.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const userTypeDef = gql` 4 | type Query { 5 | getUser: User # you need to be logged in to fufill this request 6 | getUsers: [User] 7 | } 8 | 9 | type Mutation { 10 | createUser(payload: CreateUserMut!): CreateUserMutOutput 11 | } 12 | 13 | # Beginning of QUE/MUT Fields 14 | # Create user mutation 15 | input CreateUserMut { 16 | role: Role! 17 | email: String! 18 | username: String! 19 | fullname: String! 20 | } 21 | 22 | type CreateUserMutOutput { 23 | success: Boolean 24 | } 25 | 26 | # End of QUE/MUT Fields 27 | 28 | enum Role { 29 | MERCHANT 30 | SUPPLIER 31 | BUYER 32 | } 33 | 34 | type User { 35 | id: String 36 | email: String 37 | username: String 38 | fullname: String 39 | image: String 40 | role: Role 41 | deliveryAddress: [DeliveryAddress] 42 | transactions: [Transaction] 43 | wallet: Wallet 44 | } 45 | 46 | type DeliveryAddress { 47 | id: String! 48 | street: String 49 | city: String 50 | state: String 51 | postalCode: String 52 | country: String 53 | } 54 | 55 | type Wallet { 56 | id: String! 57 | balance: Float 58 | currency: String 59 | paystackId: String 60 | } 61 | 62 | type Transaction { 63 | id: String! 64 | type: String 65 | amount: Float 66 | description: String 67 | createdAt: String 68 | } 69 | `; 70 | 71 | export default userTypeDef; 72 | -------------------------------------------------------------------------------- /packages/app/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | interface LoaderModalProp { 5 | showLabel?: boolean; 6 | position?: "absolute" | "fixed"; 7 | } 8 | 9 | interface SpinnerCompProp { 10 | color?: string; 11 | size?: number; 12 | } 13 | 14 | export function LoaderModal({ showLabel, position }: LoaderModalProp) { 15 | const validPosition = position === "absolute" ? "absolute" : "fixed"; 16 | return ( 17 |
21 | 22 | {showLabel &&

Loading...

} 23 |
24 | ); 25 | } 26 | 27 | interface SpinnerProps { 28 | size?: number; 29 | color?: string; 30 | } 31 | 32 | export const Spinner: React.FC = ({ 33 | size = 25, 34 | color = "blue-200", 35 | }) => { 36 | const spinnerStyle = { 37 | width: `${size}px`, 38 | height: `${size}px`, 39 | borderTopColor: `${color}`, 40 | borderRightColor: `${color}`, 41 | borderBottomColor: `transparent`, 42 | borderLeftColor: `transparent`, 43 | // borderColor: `${color}`, 44 | }; 45 | 46 | return ( 47 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /packages/app/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import { QueryClient, QueryClientProvider } from "react-query"; 3 | import { 4 | ApolloClient, 5 | InMemoryCache, 6 | ApolloProvider, 7 | from, 8 | } from "@apollo/client"; 9 | import ENV from "./api/config/env"; 10 | import { errorLink, httpLink } from "@/helpers/clientError"; 11 | import { Toaster } from "react-hot-toast"; 12 | import { useEffect } from "react"; 13 | import { Router } from "next/router"; 14 | import nProgress from "nprogress"; 15 | import "../styles/globals.css"; 16 | import "../styles/nprogress.css"; 17 | import "react-circular-progressbar/dist/styles.css"; 18 | 19 | const queryClient = new QueryClient(); 20 | 21 | const client = new ApolloClient({ 22 | uri: ENV.serverUrl, 23 | cache: new InMemoryCache(), 24 | link: from([errorLink, httpLink]), 25 | }); 26 | 27 | // nprogress loader 28 | Router.events.on("routeChangeStart", nProgress.start); 29 | Router.events.on("routeChangeError", nProgress.done); 30 | Router.events.on("routeChangeComplete", nProgress.done); 31 | 32 | export default function App({ Component, pageProps }: AppProps) { 33 | useEffect(() => { 34 | (async () => { 35 | await client.refetchQueries({ 36 | include: "active", // refetch all queries for "active" or specific query ["QUERY_NAME"] 37 | }); 38 | })(); 39 | }, []); 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /packages/app/public/assets/img/pattern/pattern.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/resolvers/product.res.ts: -------------------------------------------------------------------------------- 1 | import { ApiAddProductProp, ApiProductCheckoutProps } from "../../../../@types"; 2 | import ProductController from "../../controller/product"; 3 | import { isLoggedIn } from "../middlewares/auth"; 4 | import { notBuyer } from "../middlewares/auth"; 5 | 6 | const productController = new ProductController(); 7 | 8 | const productResolvers = { 9 | Query: { 10 | getProducts: async () => await productController.getProducts(), 11 | // await userController.getUser(id), 12 | // getUsers: async () => userController.getUsers(), 13 | }, 14 | Mutation: { 15 | addProduct: async ( 16 | parent: any, 17 | { payload }: { payload: ApiAddProductProp }, 18 | context: any, 19 | info: any 20 | ) => { 21 | // isAuthenticated middleware 22 | await isLoggedIn(context.req); 23 | 24 | // notBuyer middleware 25 | await notBuyer(context.userId); 26 | 27 | return await productController.addProduct(payload, context.userId); 28 | }, 29 | deleteProduct: async ( 30 | parent: any, 31 | { prodId }: { prodId: string }, 32 | context: any, 33 | info: any 34 | ) => { 35 | // isAuthenticated middleware 36 | await isLoggedIn(context.req); 37 | 38 | // notBuyer middleware 39 | await notBuyer(context.userId); 40 | 41 | return await productController.deleteProduct(prodId, context.userId); 42 | }, 43 | productCheckout: async ( 44 | parent: any, 45 | { payload }: { payload: ApiProductCheckoutProps }, 46 | context: any, 47 | info: any 48 | ) => { 49 | await isLoggedIn(context.req); 50 | 51 | return await productController.productCheckout(payload, context.userId); 52 | }, 53 | }, 54 | }; 55 | 56 | export default productResolvers; 57 | -------------------------------------------------------------------------------- /packages/app/styles/nprogress.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | } 5 | 6 | #nprogress .bar { 7 | background: #29d; 8 | 9 | position: fixed; 10 | z-index: 1031; 11 | top: 0; 12 | left: 0; 13 | 14 | width: 100%; 15 | height: 2px; 16 | } 17 | 18 | /* Fancy blur effect */ 19 | #nprogress .peg { 20 | display: block; 21 | position: absolute; 22 | right: 0px; 23 | width: 100px; 24 | height: 100%; 25 | box-shadow: 0 0 10px #29d, 0 0 5px #29d; 26 | opacity: 1; 27 | 28 | -webkit-transform: rotate(3deg) translate(0px, -4px); 29 | -ms-transform: rotate(3deg) translate(0px, -4px); 30 | transform: rotate(3deg) translate(0px, -4px); 31 | } 32 | 33 | /* Remove these to get rid of the spinner */ 34 | #nprogress .spinner { 35 | display: block; 36 | position: fixed; 37 | z-index: 1031; 38 | top: 15px; 39 | right: 15px; 40 | } 41 | 42 | #nprogress .spinner-icon { 43 | width: 18px; 44 | height: 18px; 45 | box-sizing: border-box; 46 | 47 | border: solid 2px transparent; 48 | border-top-color: #29d; 49 | border-left-color: #29d; 50 | border-radius: 50%; 51 | 52 | -webkit-animation: nprogress-spinner 400ms linear infinite; 53 | animation: nprogress-spinner 400ms linear infinite; 54 | } 55 | 56 | .nprogress-custom-parent { 57 | overflow: hidden; 58 | position: relative; 59 | } 60 | 61 | .nprogress-custom-parent #nprogress .spinner, 62 | .nprogress-custom-parent #nprogress .bar { 63 | position: absolute; 64 | } 65 | 66 | @-webkit-keyframes nprogress-spinner { 67 | 0% { 68 | -webkit-transform: rotate(0deg); 69 | } 70 | 100% { 71 | -webkit-transform: rotate(360deg); 72 | } 73 | } 74 | @keyframes nprogress-spinner { 75 | 0% { 76 | transform: rotate(0deg); 77 | } 78 | 100% { 79 | transform: rotate(360deg); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/app/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Blurhash } from "react-blurhash"; 3 | 4 | interface ImageTagProps { 5 | src: string; 6 | alt: string; 7 | width?: number; 8 | height?: number; 9 | className?: React.ComponentProps<"div">["className"]; 10 | style?: React.CSSProperties; 11 | hash?: string; 12 | } 13 | 14 | function ImageTag({ 15 | src, 16 | width, 17 | height, 18 | alt, 19 | className, 20 | style, 21 | ...props 22 | }: ImageTagProps) { 23 | return ( 24 | {alt} 35 | ); 36 | } 37 | 38 | export default ImageTag; 39 | 40 | //! Use this lazyload component later in rendering themes images 41 | export function LazyLoadImg({ 42 | src, 43 | width, 44 | height, 45 | alt, 46 | className, 47 | style, 48 | hash, 49 | }: ImageTagProps) { 50 | const [imgLoaded, setImgLoaded] = React.useState(false); 51 | 52 | React.useEffect(() => { 53 | const img = new Image(); 54 | img.onload = () => setImgLoaded(true); 55 | img.src = src; 56 | }, [src]); 57 | 58 | return ( 59 | <> 60 | {!imgLoaded && ( 61 | 69 | )} 70 | {imgLoaded && ( 71 | {alt} 81 | )} 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /packages/app/http/index.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | export const CreateNewUser = gql` 4 | mutation CreateUserMutation($payload: CreateUserMut!) { 5 | createUser(payload: $payload) { 6 | success 7 | } 8 | } 9 | `; 10 | 11 | export const FundWallet = gql` 12 | mutation PaymentMutation($amount: String!, $currency: String!) { 13 | fundWallet(amount: $amount, currency: $currency) { 14 | access_code 15 | authorization_url 16 | reference 17 | } 18 | } 19 | `; 20 | 21 | export const GetUserInfo = gql` 22 | query UserQuery { 23 | getUser { 24 | id 25 | email 26 | username 27 | fullname 28 | role 29 | image 30 | wallet { 31 | balance 32 | currency 33 | } 34 | } 35 | } 36 | `; 37 | 38 | export const AddProduct = gql` 39 | mutation ProductMutation($productPayload: AddProductInput!) { 40 | addProduct(payload: $productPayload) { 41 | msg 42 | success 43 | } 44 | } 45 | `; 46 | 47 | export const GetAllProducts = gql` 48 | query ProductQuery { 49 | getProducts { 50 | id 51 | name 52 | price 53 | availableForRent 54 | rentingPrice 55 | quantity 56 | description 57 | image { 58 | url 59 | hash 60 | } 61 | user { 62 | id 63 | fullname 64 | role 65 | image 66 | } 67 | } 68 | } 69 | `; 70 | 71 | export const ProductCheckout = gql` 72 | mutation ProductCheckout($productCheckoutPayload: ProductCheckoutType!) { 73 | productCheckout(payload: $productCheckoutPayload) { 74 | success 75 | } 76 | } 77 | `; 78 | 79 | export const SeedzAssistant = gql` 80 | mutation SeedzAiMutation($AiInput: AiInput!) { 81 | askSeedzAi(payload: $AiInput) { 82 | answer 83 | lang 84 | success 85 | } 86 | } 87 | `; 88 | 89 | export const DeleteProduct = gql` 90 | mutation ProductDelete($prodId: String!) { 91 | deleteProduct(prodId: $prodId) { 92 | success 93 | } 94 | } 95 | `; 96 | 97 | // just to prevent error during build process. 98 | export default function f() {} 99 | -------------------------------------------------------------------------------- /packages/app/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import ImageTag from "@/components/Image"; 2 | import useAuth from "@/helpers/useIsAuth"; 3 | import Link from "next/link"; 4 | import React from "react"; 5 | 6 | function Home() { 7 | const { seedzUserInfo } = useAuth(); 8 | 9 | return ( 10 |
11 |
12 | {/* */} 13 |
14 |
15 |
16 |
17 | 22 | Seedz 23 |
24 |
25 |

26 | Empowering Farmers, Enhancing Productivity 27 |

28 |
29 |
30 | {seedzUserInfo?.id === null || 31 | typeof seedzUserInfo?.id === "undefined" ? ( 32 | 36 | Get Started 37 | 38 | ) : ( 39 | 43 | Dashboard 44 | 45 | )} 46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | 53 | export default Home; 54 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "dbPush": "npx prisma db push" 11 | }, 12 | "prisma": { 13 | "schema": "./pages/api/prisma/schema.prisma" 14 | }, 15 | "dependencies": { 16 | "@apollo/client": "^3.8.1", 17 | "@apollo/server": "^4.9.1", 18 | "@as-integrations/next": "^2.0.1", 19 | "@passageidentity/passage-elements": "^1.11.0", 20 | "@passageidentity/passage-js": "^3.7.0", 21 | "@passageidentity/passage-node": "^2.4.2", 22 | "@prisma/client": "^4.13.0", 23 | "@types/micro-cors": "^0.1.3", 24 | "apollo-server-micro": "^3.12.0", 25 | "axios": "^1.4.0", 26 | "bcryptjs": "^2.4.3", 27 | "blurhash": "^2.0.5", 28 | "eslint": "8.34.0", 29 | "eslint-config-next": "13.1.6", 30 | "graphql": "^16.8.0", 31 | "graphql-tag": "^2.12.6", 32 | "joi": "^17.9.2", 33 | "jsonwebtoken": "^9.0.1", 34 | "jwt-decode": "^3.1.2", 35 | "markdown-it": "^13.0.1", 36 | "memory-cache": "^0.2.0", 37 | "micro": "^10.0.1", 38 | "micro-cors": "^0.1.1", 39 | "moment": "^2.29.4", 40 | "nanoid": "^4.0.2", 41 | "next": "13.1.6", 42 | "nprogress": "^0.2.0", 43 | "react": "18.2.0", 44 | "react-blurhash": "^0.3.0", 45 | "react-circular-progressbar": "^2.1.0", 46 | "react-dom": "18.2.0", 47 | "react-hot-toast": "^2.4.1", 48 | "react-icons": "^4.7.1", 49 | "react-markdown": "^8.0.7", 50 | "react-query": "^3.39.3", 51 | "rehype-raw": "^6.1.1", 52 | "remark-gfm": "^3.0.1", 53 | "resend": "^1.0.0", 54 | "sharp": "^0.32.5", 55 | "svix": "^1.8.1", 56 | "tailwind-merge": "^1.14.0" 57 | }, 58 | "devDependencies": { 59 | "@types/jsonwebtoken": "^9.0.2", 60 | "@types/markdown-it": "^13.0.0", 61 | "@types/memory-cache": "^0.2.3", 62 | "@types/node": "18.14.0", 63 | "@types/nprogress": "^0.2.0", 64 | "@types/react": "18.0.28", 65 | "@types/react-dom": "18.0.11", 66 | "autoprefixer": "^10.4.15", 67 | "postcss": "^8.4.28", 68 | "prisma": "^4.10.1", 69 | "tailwindcss": "^3.3.3", 70 | "typescript": "^4.9.3" 71 | }, 72 | "description": "Scaffolded using prospark" 73 | } 74 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/typeDef/product.type.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | const productTypeDef = gql` 4 | type Query { 5 | getProducts: [Products] 6 | } 7 | 8 | type Mutation { 9 | addProduct(payload: AddProductInput!): AddProductMutOutput 10 | deleteProduct(prodId: String!): DeleteProdRes 11 | productCheckout(payload: ProductCheckoutType!): ProductCheckoutOut 12 | } 13 | 14 | # Beginning of QUE/MUT Fields 15 | 16 | input AddProductInput { 17 | name: String! 18 | category: ProductCategory! 19 | price: Int! 20 | availableForRent: Boolean! 21 | rentingPrice: Int! 22 | quantity: Int! 23 | image: AddProductImage! 24 | description: String! 25 | } 26 | 27 | input AddProductImage { 28 | hash: String! 29 | url: String! 30 | } 31 | 32 | type AddProductMutOutput { 33 | success: Boolean 34 | msg: String! 35 | } 36 | 37 | input ProductCheckoutType { 38 | totalAmount: Int! 39 | productQty: [ProductCheckoutQtyType] 40 | } 41 | 42 | input ProductCheckoutQtyType { 43 | prodId: String! 44 | qty: Int! 45 | name: String! 46 | # amount: Int! 47 | } 48 | 49 | type DeleteProdRes { 50 | success: Boolean! 51 | } 52 | 53 | type ProductCheckoutOut { 54 | success: Boolean! 55 | } 56 | 57 | enum ProductCategory { 58 | FARM_PRODUCE 59 | FARM_MACHINERY 60 | } 61 | 62 | # End of QUE/MUT Fields 63 | 64 | type User { 65 | id: String 66 | email: String 67 | username: String 68 | fullname: String 69 | image: String 70 | role: Role 71 | deliveryAddress: [DeliveryAddress] 72 | transactions: [Transaction] 73 | products: [Products] 74 | wallet: Wallet 75 | } 76 | 77 | type Products { 78 | id: String 79 | userId: String 80 | name: String 81 | description: String 82 | quantity: Int 83 | price: Int 84 | currency: String 85 | type: String 86 | availableForRent: Boolean 87 | rentingPrice: Int 88 | image: ProductImage 89 | ratings: [ProductsRating] 90 | user: User 91 | } 92 | 93 | type ProductImage { 94 | id: String 95 | hash: String 96 | url: String 97 | product: Products 98 | } 99 | 100 | type ProductsRating { 101 | id: String 102 | userId: String 103 | rate: Int 104 | product: Products 105 | user: User 106 | } 107 | `; 108 | 109 | export default productTypeDef; 110 | -------------------------------------------------------------------------------- /packages/app/@types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace JSX { 2 | import { 3 | PassageElement, 4 | PassageProfileElement, 5 | } from "@passageidentity/passage-elements"; 6 | interface IntrinsicElements { 7 | "passage-auth": PassageElement; 8 | "passage-login": PassageElement; 9 | "passage-register": PassageElement; 10 | "passage-profile": PassageProfileElement; 11 | } 12 | } 13 | 14 | export interface CreateUserType { 15 | id: string; 16 | role: "MERCHANT" | "SUPPLIER" | "BUYER"; 17 | email: string; 18 | username: string; 19 | fullname: string; 20 | } 21 | 22 | export interface FundWalletType { 23 | amount: number; 24 | currency: string; 25 | } 26 | 27 | export interface UserType { 28 | email: string; 29 | username: string; 30 | fullname: string; 31 | role: string; // You can replace this with an enum if you have predefined roles 32 | image: string; 33 | wallet: { 34 | id: string; 35 | balance: string; 36 | currency: string; 37 | }; 38 | } 39 | 40 | export interface AddProductInfoType { 41 | name: string; 42 | category: "FARM_PRODUCE" | "FARM_MACHINERY" | ""; 43 | price: number; 44 | availableForRent: boolean; 45 | rentingPrice: string; 46 | quantity: number; 47 | // images: { 48 | // hash: string; 49 | // url: string; 50 | // }; 51 | description: string; 52 | } 53 | 54 | export interface ApiAddProductProp { 55 | name: string; 56 | category: "FARM_PRODUCE" | "FARM_MACHINERY"; 57 | price: number; 58 | availableForRent: boolean; 59 | rentingPrice: price; 60 | quantity: number; 61 | image: { 62 | hash: string; 63 | url: string; 64 | }; 65 | description: string; 66 | } 67 | 68 | export interface AllProductProp { 69 | id: string; 70 | name: string; 71 | category: "FARM_PRODUCE" | "FARM_MACHINERY"; 72 | price: number; 73 | availableForRent: boolean; 74 | rentingPrice: price; 75 | quantity: number; 76 | image: { 77 | hash: string; 78 | url: string; 79 | }; 80 | description: string; 81 | ratings: { 82 | rate; 83 | }; 84 | } 85 | 86 | export interface ApiProductCheckoutProps { 87 | totalAmount: number; 88 | productQty: ProductQty[]; 89 | } 90 | 91 | type ProductQty = { 92 | prodId: string; 93 | qty: number; 94 | name: string; 95 | // amount: number; 96 | }; 97 | 98 | export type SeedzAiPayload = { 99 | question: string; 100 | lang: string; 101 | }; 102 | 103 | declare module "micro-cors"; 104 | -------------------------------------------------------------------------------- /packages/app/utils/index.ts: -------------------------------------------------------------------------------- 1 | // sleep method 2 | export const sleep = async (sec = 1) => { 3 | return new Promise((res) => setTimeout(res, sec * 1000)); 4 | }; 5 | 6 | export const isEqual = (a: string, b: string) => a === b; 7 | 8 | export const capitalizeWord = (wrd: string) => { 9 | if (wrd.length === 0 || typeof wrd === "undefined" || wrd === "") return wrd; 10 | const splited = wrd.split(""); 11 | const first = splited[0].toUpperCase(); 12 | const last = wrd.slice(1); 13 | const comb = first + last; 14 | return comb; 15 | }; 16 | 17 | export const isEmpty = (param: string | null | any) => 18 | param === null || typeof param === "undefined" || param.length === 0; 19 | 20 | export const capitalizeFirstCharacter = (str: string) => { 21 | if (isEmpty(str)) return str; 22 | return str.slice(0, 1).toUpperCase() + str.slice(1); 23 | }; 24 | export const returnFirstAndLastLetter = (str: string) => { 25 | const { first, last } = splitFullname(str); 26 | const firstLetter = capitalizeFirstCharacter(first.slice(0, 1)); 27 | const lastLetter = capitalizeFirstCharacter(last.slice(0, 1)); 28 | 29 | return { firstLetter, lastLetter }; 30 | }; 31 | 32 | export const validateEmail = (email: string): boolean => { 33 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 34 | return emailRegex.test(email); 35 | }; 36 | 37 | export const splitFullname = (fullname: string) => { 38 | const splitted = fullname.split(" "); 39 | const len = splitted.length; 40 | let first = splitted[0]; 41 | let last = len > 1 ? splitted[1] : ""; 42 | 43 | return { first, last }; 44 | }; 45 | 46 | export const isOnlyNumbers = (string: string) => { 47 | const regex = /^[0-9]+$/; 48 | const isNum = regex.test(string); 49 | return isNum; 50 | }; 51 | 52 | export const copyToClipboard = (content: string) => { 53 | navigator && navigator.clipboard.writeText(content); 54 | }; 55 | 56 | export function genRandNum(len: number = 10) { 57 | const char = "01234567890abcdegh".split(""); 58 | let generated = ""; 59 | for (let i = 0; i < len; i++) { 60 | const rand = Math.floor(Math.random() * char.length); 61 | generated += char[rand]; 62 | } 63 | return generated; 64 | } 65 | 66 | export function isValidCliCommand(commandString: string) { 67 | // const invalidCharacters = /[!*±%^()+\[\]{}\\\/<>~]&{2,}|\s&|\s&&\s/g; 68 | // return !invalidCharacters.test(commandString); 69 | const invalidCharacters = 70 | commandString.includes("&&") || commandString.includes("&"); 71 | console.log({ invalidCharacters }); 72 | return !invalidCharacters; 73 | } 74 | -------------------------------------------------------------------------------- /packages/app/pages/api/controller/user.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserType } from "../../../@types"; 2 | import prisma from "../config/prisma"; 3 | import ServerResponseError from "../helper/errorHandler"; 4 | import { CreateUserSchema } from "../helper/validator"; 5 | import { genID } from "../helper"; 6 | 7 | export default class UserController { 8 | constructor() {} 9 | 10 | private isValidationError(error: any) { 11 | return typeof error !== "undefined"; 12 | } 13 | 14 | async getUser(id: string) { 15 | const userById = await prisma.users.findFirst({ 16 | where: { id: id }, 17 | include: { 18 | wallet: true, 19 | transactions: true, 20 | deliveryAddress: true, 21 | ratings: true, 22 | }, 23 | }); 24 | if (userById === null) { 25 | throw new ServerResponseError("NO_USER_FOUND", "user not found"); 26 | } 27 | 28 | return userById; 29 | } 30 | 31 | async getUsers() { 32 | const allUsers = await prisma.users.findMany({ 33 | include: { 34 | wallet: true, 35 | transactions: true, 36 | deliveryAddress: true, 37 | ratings: true, 38 | }, 39 | }); 40 | return allUsers; 41 | } 42 | 43 | async createUser(payload: CreateUserType, userId: string) { 44 | const { error, value } = CreateUserSchema.validate(payload); 45 | if (this.isValidationError(error)) { 46 | throw new ServerResponseError("INVALID_FIELDS", (error as any).message); 47 | } 48 | 49 | const { role, email, fullname, username } = payload; 50 | 51 | // check if role is valid or not 52 | const validRole = ["MERCHANT", "SUPPLIER", "BUYER"]; 53 | if (!validRole.includes(role)) { 54 | throw new ServerResponseError( 55 | "INVALID_FIELDS", 56 | `Invalid role given: ${role}` 57 | ); 58 | } 59 | 60 | // check if user with id already exists 61 | const userExists = await prisma.users.findMany({ where: { id: userId } }); 62 | 63 | if (userExists.length > 0) { 64 | throw new ServerResponseError( 65 | "USER_EXISTS", 66 | `User with this records already exists.` 67 | ); 68 | } 69 | 70 | // create user 71 | const defaultCurrency = "NGN"; 72 | await prisma.users.create({ 73 | data: { 74 | id: userId, 75 | username: `${username}${genID(5)}`, 76 | fullname, 77 | email, 78 | role, 79 | image: `https://api.dicebear.com/6.x/micah/svg?seed=${username}`, 80 | wallet: { 81 | create: { 82 | id: genID(20), 83 | currency: defaultCurrency, 84 | balance: 0, 85 | }, 86 | }, 87 | }, 88 | }); 89 | 90 | console.log(`Account created: [${email}]`); 91 | 92 | return { success: true }; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/app/pages/api/webhook.ts: -------------------------------------------------------------------------------- 1 | // pages/api/webhooks.js 2 | 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | import { Webhook } from "svix"; 5 | import ServerResponseError from "./helper/errorHandler"; 6 | import prisma from "./config/prisma"; 7 | import { genID } from "./helper"; 8 | import memecache from "memory-cache"; 9 | 10 | const secret = process.env.CLERK_WH_SECRET as string; 11 | 12 | // this is meant to handle clerk webhook at the point of developing this 13 | // but later switched to passage due to some reasons. 14 | 15 | export default async function webhookHandler( 16 | req: NextApiRequest, 17 | res: NextApiResponse 18 | ) { 19 | if (req.method === "POST") { 20 | const whPayload = verifyWH(req); 21 | if (whPayload.success) { 22 | const data = whPayload.data; 23 | const event = (data as any).type; 24 | 25 | console.log(data); 26 | 27 | if (event === "user.created") { 28 | const whUserData = (data as any).data; 29 | const email = whUserData?.email_addresses[0]?.email_address; 30 | 31 | // check if email exists 32 | const userExists = await prisma.users.findMany({ where: { email } }); 33 | 34 | if (userExists.length > 0) { 35 | console.log(`User with this email ${email} already exists`); 36 | return; 37 | } 38 | 39 | await prisma.users.create({ 40 | data: { 41 | id: whUserData.id, 42 | image: whUserData.image_url, 43 | username: 44 | whUserData.username ?? 45 | whUserData.first_name.toLowerCase() + genID(4), 46 | fullname: `${whUserData.first_name} ${whUserData.last_name ?? ""}`, 47 | email, 48 | role: "BUYER", 49 | wallet: { 50 | create: { 51 | currency: "NGN", 52 | id: genID(20), 53 | balance: 0, 54 | }, 55 | }, 56 | }, 57 | }); 58 | 59 | console.log(`${email}: Registered successfully`); 60 | return; 61 | } 62 | } 63 | } else { 64 | res.status(200).json({ msg: "You've reached webhook endpoint." }); 65 | } 66 | } 67 | 68 | function verifyWH(req: NextApiRequest) { 69 | try { 70 | const headers = { 71 | "svix-id": req.headers["svix-id"] as string, 72 | "svix-timestamp": req.headers["svix-timestamp"] as string, 73 | "svix-signature": req.headers["svix-signature"] as string, 74 | }; 75 | 76 | const wh = new Webhook(secret); 77 | const payload = wh.verify(JSON.stringify(req.body), headers); 78 | 79 | return { data: payload, success: true }; 80 | } catch (e: any) { 81 | console.log(`Error verifying webhook: ${e.message}`); 82 | return { data: null, success: false }; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /packages/app/pages/api/controller/payment.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserType, FundWalletType } from "../../../@types"; 2 | import prisma from "../config/prisma"; 3 | import ServerResponseError from "../helper/errorHandler"; 4 | import { CreateUserSchema, InitPaymentSchema } from "../helper/validator"; 5 | import { genID } from "../helper"; 6 | import $http from "../config/axios"; 7 | import { Console } from "console"; 8 | 9 | export default class PaymentController { 10 | constructor() {} 11 | 12 | private isValidationError(error: any) { 13 | return typeof error !== "undefined"; 14 | } 15 | 16 | // init paystack payment 17 | async initPsPayment(amount: number, email: string) { 18 | let result = { success: false, data: null, msg: null || "" }; 19 | try { 20 | const req = await $http.post("/transaction/initialize", { 21 | amount, 22 | email, 23 | }); 24 | const res = req?.data; 25 | if (res.status) { 26 | result["success"] = true; 27 | result["data"] = res.data; 28 | result["msg"] = res.message; 29 | return result; 30 | } 31 | result["success"] = false; 32 | result["data"] = null; 33 | result["msg"] = "Something went wrong initializing payment."; 34 | return result; 35 | } catch (e: any) { 36 | console.log(e?.response?.data?.message ?? e.message); 37 | console.log( 38 | `Error initializing payment: ${e?.response?.data?.message ?? e.message}` 39 | ); 40 | result["success"] = false; 41 | result["data"] = null; 42 | result["msg"] = `Error initializing payment: ${ 43 | e?.response?.data?.message ?? e.message 44 | }`; 45 | return result; 46 | } 47 | } 48 | 49 | async fundWallet(payload: FundWalletType, userId: string) { 50 | const { error, value } = InitPaymentSchema.validate(payload); 51 | if (this.isValidationError(error)) { 52 | throw new ServerResponseError("INVALID_FIELDS", (error as any).message); 53 | } 54 | 55 | const { amount, currency } = payload; 56 | const validCurr = "NGN"; 57 | 58 | if (currency !== validCurr) { 59 | throw new ServerResponseError("INVALID_CURRENCY", "currency is invalid."); 60 | } 61 | 62 | // init payment 63 | const user = await prisma.users.findFirst({ where: { id: userId } }); 64 | 65 | const result = await this.initPsPayment( 66 | Number(amount * 100), 67 | user?.email as string 68 | ); 69 | 70 | if (result.success === true) { 71 | return { 72 | authorization_url: (result?.data as any).authorization_url, 73 | access_code: (result?.data as any).access_code, 74 | reference: (result?.data as any).reference, 75 | }; 76 | } 77 | 78 | throw new ServerResponseError("INIT_PAYMENT_ERROR", result.msg); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/app/pages/api/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | enum Role { 11 | MERCHANT 12 | SUPPLIER 13 | BUYER 14 | } 15 | 16 | model Users { 17 | id String @id @unique 18 | email String @unique 19 | username String 20 | fullname String 21 | role Role @default(BUYER) 22 | image String 23 | deliveryAddress DeliveryAddress[] 24 | transactions Transaction[] 25 | ratings ProductsRating[] 26 | wallet Wallet? 27 | products Products[] 28 | } 29 | 30 | model DeliveryAddress { 31 | id String @id 32 | userId String 33 | user Users @relation(fields: [userId], references: [id], onDelete: Cascade) 34 | street String 35 | city String 36 | state String 37 | postalCode String 38 | country String 39 | } 40 | 41 | model Wallet { 42 | id String @id 43 | userId String @unique 44 | user Users @relation(fields: [userId], references: [id], onDelete: Cascade) 45 | balance Float @default(0) 46 | currency String 47 | } 48 | 49 | model Transaction { 50 | id String @id @unique 51 | userId String 52 | user Users @relation(fields: [userId], references: [id], onDelete: Cascade) 53 | type String // 'purchase', 'topup', etc. 54 | amount Float 55 | description String 56 | createdAt DateTime @default(now()) 57 | } 58 | 59 | // I Plan to store AI Assistant responses in client localstorage 60 | model Products { 61 | id String @id @unique 62 | userId String 63 | name String 64 | description String 65 | quantity Int 66 | price Int 67 | currency String 68 | type String // 'farm_produce', 'farm_machinery' 69 | availableForRent Boolean 70 | rentingPrice Int 71 | image ProductImage? 72 | ratings ProductsRating[] 73 | user Users? @relation(fields: [userId], references: [id]) 74 | } 75 | 76 | model ProductImage { 77 | id String @id 78 | hash String 79 | url String 80 | product Products @relation(fields: [id], references: [id], onDelete: Cascade) 81 | } 82 | 83 | model ProductsRating { 84 | id String @id 85 | userId String 86 | rate Int 87 | 88 | product Products @relation(fields: [id], references: [id], onDelete: Cascade) 89 | user Users @relation(fields: [userId], references: [id], onDelete: Cascade) 90 | } 91 | 92 | model TransactionRef { 93 | id String @id 94 | ref String 95 | } 96 | -------------------------------------------------------------------------------- /packages/app/helpers/useWeather.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import toast from "react-hot-toast"; 3 | 4 | interface WeatherInfo { 5 | temperature: number; 6 | description: string; 7 | icon: string; 8 | } 9 | 10 | interface LocationData { 11 | latitude: number; 12 | longitude: number; 13 | } 14 | 15 | interface WeatherHook { 16 | getGeolocation: () => void; 17 | getWeatherByLocation: (location: LocationData) => void; 18 | weatherInfo: WeatherInfo | null; 19 | loading: boolean; 20 | } 21 | 22 | const useWeather = (): WeatherHook => { 23 | const [weatherInfo, setWeatherInfo] = useState(null); 24 | const [loading, setLoading] = useState(false); 25 | const [locationData, setLocationData] = React.useState({ 26 | lat: 0, 27 | long: 0, 28 | }); 29 | 30 | const getGeolocation = () => { 31 | if ("geolocation" in navigator) { 32 | navigator.geolocation.getCurrentPosition( 33 | (position) => { 34 | const { latitude, longitude } = position.coords; 35 | setLocationData({ lat: latitude, long: longitude }); 36 | }, 37 | (error) => { 38 | toast.error("Geolocation Error, please reload!."); 39 | }, 40 | { 41 | enableHighAccuracy: true, 42 | } 43 | ); 44 | } else { 45 | toast.error("Geolocation not available"); 46 | console.error("Geolocation not available"); 47 | } 48 | }; 49 | 50 | const getWeatherByLocation = async () => { 51 | const { lat, long } = locationData; 52 | setLoading(true); 53 | try { 54 | // const apiKey = "f3470b3eb557433a95c22231232308"; 55 | const apiKey2 = "c9a1504ee3d9c57e2e1a75831056e20a"; 56 | // const url = `http://api.weatherapi.com/v1/current.json?q=${lat},${long}&key=${apiKey}`; 57 | const url2 = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${long}&appid=${apiKey2}&units=metric`; 58 | 59 | const res = await fetch(url2); 60 | if (res.ok) { 61 | const data = await res.json(); 62 | console.log(data); 63 | const weather: WeatherInfo = { 64 | temperature: data.main.temp, 65 | description: data.weather[0].description, 66 | icon: `https://openweathermap.org/img/wn/${data.weather[0].icon}@2x.png`, 67 | }; 68 | setWeatherInfo(weather); 69 | } else { 70 | console.error("Weather API request failed"); 71 | } 72 | } catch (error) { 73 | console.error("Error fetching weather data:", error); 74 | } finally { 75 | setLoading(false); 76 | } 77 | }; 78 | 79 | useEffect(() => { 80 | getGeolocation(); 81 | getWeatherByLocation(); 82 | }, []); 83 | 84 | return { 85 | getGeolocation, 86 | getWeatherByLocation, 87 | weatherInfo, 88 | loading, 89 | }; 90 | }; 91 | 92 | export default useWeather; 93 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import ENV from "../../config/env"; 2 | import Passage from "@passageidentity/passage-node"; 3 | import { NextApiRequest, NextApiResponse, NextApiHandler } from "next"; 4 | import jwt from "jsonwebtoken"; 5 | 6 | import { isEmpty } from "../../../../utils"; 7 | import ServerResponseError from "../../helper/errorHandler"; 8 | import prisma from "../../config/prisma"; 9 | 10 | let passage = new Passage({ 11 | appID: process.env.PASSAGE_APP_ID as string, 12 | apiKey: process.env.PASSAGE_API_KEY as string, 13 | authStrategy: "HEADER", 14 | }); 15 | 16 | export async function isLoggedIn(req: NextApiRequest) { 17 | try { 18 | const token = 19 | req.headers["psg_auth_token"] ?? req.cookies["psg_auth_token"]; 20 | 21 | if (isEmpty(token)) { 22 | throw new ServerResponseError( 23 | "AUTH_TOKEN_NOTFOUND", 24 | "Auth token missing, login to continue." 25 | ); 26 | } 27 | 28 | let { userId } = await getAuth(req); 29 | if (userId === null) { 30 | throw new ServerResponseError("UNAUTHORISED", "Unauthorised user"); 31 | } 32 | } catch (e: any) { 33 | console.log(e); 34 | console.error(`invalid seedz token: ${e?.message}`); 35 | throw new ServerResponseError( 36 | "INVALID_TOKEN", 37 | "Authorization token is invalid" 38 | ); 39 | } 40 | } 41 | 42 | export const apolloAuthMiddleware = async ({ 43 | req, 44 | }: { 45 | req: NextApiRequest; 46 | }) => { 47 | const token = 48 | (req as any).headers["psg_auth_token"] || req.cookies["psg_auth_token"]; 49 | 50 | if (!token) { 51 | throw new ServerResponseError( 52 | "UNAUTHORIZED", 53 | "Authorization header expected a token but got none." 54 | ); 55 | } 56 | 57 | const passageReq = { 58 | headers: { 59 | authorization: `Bearer ${token}`, 60 | }, 61 | }; 62 | const userID = await passage.authenticateRequest(passageReq as any); 63 | 64 | if (!userID) { 65 | throw new ServerResponseError("UNAUTHORIZED", "Unauthorized user"); 66 | } 67 | 68 | return { userId: userID }; 69 | }; 70 | 71 | export async function getAuth(req: NextApiRequest) { 72 | try { 73 | const token = 74 | (req as any).headers["psg_auth_token"] || req.cookies["psg_auth_token"]; 75 | 76 | const passageReq = { 77 | headers: { 78 | authorization: `Bearer ${token}`, 79 | }, 80 | }; 81 | 82 | const userId = await passage.authenticateRequest(passageReq as any); 83 | 84 | return { userId }; 85 | } catch (e: any) { 86 | console.log(`Error authenticateRequest with passage: ${e.message}`); 87 | return { userId: null }; 88 | } 89 | } 90 | 91 | // middleware should be used only for MERCHANT & SUPPLIER 92 | export async function notBuyer(userId: string) { 93 | const userInfo = await prisma.users.findFirst({ where: { id: userId } }); 94 | const role = userInfo?.role; 95 | 96 | if (role === "BUYER") { 97 | throw new ServerResponseError( 98 | "FORBIDDEN", 99 | "You don't have permission to access this resources." 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/app/pages/api/graphql/index.ts: -------------------------------------------------------------------------------- 1 | // import { ApolloServer, BaseContext } from "@apollo/server"; 2 | // import { startServerAndCreateNextHandler } from "@as-integrations/next"; 3 | import { ApolloServer } from "apollo-server-micro"; 4 | import combinedTypeDef from "./typeDef"; 5 | import userResolvers from "./resolvers/user.res"; 6 | import { GraphQLError } from "graphql"; 7 | import paymentResolvers from "./resolvers/payment.res"; 8 | import { NextApiRequest, NextApiResponse } from "next"; 9 | // import { getAuth } from "@clerk/nextjs/server"; 10 | import productResolvers from "./resolvers/product.res"; 11 | import seedzAiResolvers from "./resolvers/assistant.res"; 12 | import Cors from "micro-cors"; 13 | import { getAuth } from "./middlewares/auth"; 14 | 15 | const cors = Cors(); 16 | 17 | const apolloServer = new ApolloServer({ 18 | typeDefs: combinedTypeDef, 19 | resolvers: [ 20 | userResolvers, 21 | paymentResolvers, 22 | productResolvers, 23 | seedzAiResolvers, 24 | ], 25 | context: async ({ req }: { req: NextApiRequest }) => { 26 | const { userId } = await getAuth(req); 27 | 28 | // Create a new context object by spreading properties from req and adding user 29 | const context = { 30 | req, 31 | userId, 32 | }; 33 | 34 | return context; 35 | }, 36 | formatError: (error: GraphQLError) => { 37 | // Return a different error message 38 | console.log( 39 | `Gql Server Error [${error?.extensions.code}]: ${error?.message}` 40 | ); 41 | const gqlErrorCode = ["GRAPHQL_VALIDATION_FAILED", "BAD_USER_INPUT"]; 42 | const mainServerError = ["INTERNAL_SERVER_ERROR"]; 43 | // gql server error 44 | if (gqlErrorCode.includes(error?.extensions.code as string)) { 45 | return { 46 | error: true, 47 | code: "GQL_SERVER_ERROR", 48 | message: "Something went wrong", 49 | log: 50 | process.env.NODE_ENV !== "production" 51 | ? error?.extensions.message 52 | : null, 53 | }; 54 | } 55 | // main server error 56 | if (mainServerError.includes(error?.extensions.code as string)) { 57 | return { 58 | error: true, 59 | code: error?.extensions.code, 60 | message: `Something went wrong!`, 61 | log: error?.message, 62 | }; 63 | } 64 | if (error instanceof GraphQLError) { 65 | const { code } = error.extensions; 66 | return { 67 | code: error?.extensions.code, 68 | message: error?.message, 69 | }; 70 | } 71 | // Otherwise return the formatted error. This error can also 72 | // be manipulated in other ways, as long as it's returned. 73 | return error; 74 | }, 75 | }); 76 | 77 | const startServer = apolloServer.start(); 78 | 79 | // @ts-ignore 80 | export default cors(async function handler( 81 | req: NextApiRequest, 82 | res: NextApiResponse 83 | ): Promise { 84 | if (req.method === "OPTIONS") { 85 | res.end(); 86 | return false; 87 | } 88 | 89 | await startServer; 90 | 91 | await apolloServer.createHandler({ 92 | path: "/api/graphql", 93 | })(req, res); 94 | }); 95 | 96 | export const config = { 97 | api: { 98 | bodyParser: false, 99 | }, 100 | }; 101 | -------------------------------------------------------------------------------- /packages/app/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | width: 100%; 7 | height: 100vh; 8 | overflow-x: hidden; 9 | } 10 | 11 | @font-face { 12 | font-family: Nunito-Med; 13 | src: url("/assets/font/Nunito-Med.ttf"); 14 | } 15 | 16 | @font-face { 17 | font-family: Nunito-EB; 18 | src: url("/assets/font/Nunito-Black.ttf"); 19 | } 20 | 21 | @font-face { 22 | font-family: Nunito-B; 23 | src: url("/assets/font/Nunito-Bold.ttf"); 24 | } 25 | 26 | @font-face { 27 | font-family: ppM; 28 | src: url("/assets/font/Poppins-Medium.ttf"); 29 | } 30 | 31 | @font-face { 32 | font-family: ppR; 33 | src: url("/assets/font/Poppins-Regular.ttf"); 34 | } 35 | 36 | .z-up { 37 | z-index: 100; 38 | } 39 | 40 | .z-down { 41 | z-index: -2; 42 | } 43 | 44 | .z-upper { 45 | z-index: 50; 46 | /* color: #2a282d38; */ 47 | } 48 | 49 | .OX { 50 | font-family: Oxanium; 51 | } 52 | 53 | .N-M { 54 | font-family: Nunito-Med; 55 | } 56 | 57 | .N-EB { 58 | font-family: Nunito-EB; 59 | } 60 | 61 | .N-B { 62 | font-family: Nunito-B; 63 | } 64 | 65 | .ppM { 66 | font-family: ppM; 67 | } 68 | 69 | .ppR { 70 | font-family: ppR; 71 | } 72 | 73 | .hideScrollBar::-webkit-scrollbar { 74 | width: 3px; 75 | } 76 | 77 | .pattern-bg::before { 78 | content: ""; 79 | width: 100%; 80 | height: 100vh; 81 | background-image: url("/assets/img/pattern/grid.svg"); 82 | background-size: 200%; 83 | position: absolute; 84 | /* z-index: -1; */ 85 | } 86 | 87 | input[type="number"]::-webkit-inner-spin-button, 88 | input[type="number"]::-webkit-outer-spin-button { 89 | -webkit-appearance: none; 90 | -moz-appearance: none; 91 | appearance: none; 92 | margin: 0; 93 | } 94 | 95 | /* chat loading animation */ 96 | .chatLoading .lds-ellipsis { 97 | display: inline-block; 98 | position: relative; 99 | width: 100%; 100 | display: flex; 101 | flex-direction: row; 102 | align-items: center; 103 | justify-items: center; 104 | width: 80px; 105 | height: 12px; 106 | } 107 | .chatLoading .lds-ellipsis div { 108 | position: absolute; 109 | top: 2px; 110 | width: 6px; 111 | height: 6px; 112 | border-radius: 50%; 113 | background: #777; 114 | animation-timing-function: cubic-bezier(0, 1, 1, 0); 115 | } 116 | .chatLoading .lds-ellipsis div:nth-child(1) { 117 | left: 8px; 118 | animation: lds-ellipsis1 0.6s infinite; 119 | } 120 | .chatLoading .lds-ellipsis div:nth-child(2) { 121 | left: 8px; 122 | animation: lds-ellipsis2 0.6s infinite; 123 | } 124 | .chatLoading .lds-ellipsis div:nth-child(3) { 125 | left: 32px; 126 | animation: lds-ellipsis2 0.6s infinite; 127 | } 128 | .chatLoading .lds-ellipsis div:nth-child(4) { 129 | left: 56px; 130 | animation: lds-ellipsis3 0.6s infinite; 131 | } 132 | @keyframes lds-ellipsis1 { 133 | 0% { 134 | transform: scale(0); 135 | } 136 | 100% { 137 | transform: scale(1); 138 | } 139 | } 140 | @keyframes lds-ellipsis3 { 141 | 0% { 142 | transform: scale(1); 143 | } 144 | 100% { 145 | transform: scale(0); 146 | } 147 | } 148 | @keyframes lds-ellipsis2 { 149 | 0% { 150 | transform: translate(0, 0); 151 | } 152 | 100% { 153 | transform: translate(24px, 0); 154 | } 155 | } 156 | 157 | .ChatMsg a { 158 | color: #4a4ad3; 159 | } 160 | -------------------------------------------------------------------------------- /packages/app/components/BottomNav.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import { BiHomeAlt, BiSolidHomeAlt2, BiSolidUser } from "react-icons/bi"; 4 | import { BsRobot } from "react-icons/bs"; 5 | import { FaShoppingCart } from "react-icons/fa"; 6 | import { IoStorefrontSharp } from "react-icons/io5"; 7 | import { twMerge } from "tailwind-merge"; 8 | 9 | interface BottomNavProp { 10 | activePage: string; 11 | } 12 | function BottomNav({ activePage }: BottomNavProp) { 13 | const [activeTab, setActiveTab] = React.useState(activePage); 14 | 15 | // React.useEffect(() => { 16 | // setActiveTab(activePage); 17 | // }, [activePage]); 18 | 19 | return ( 20 |
21 | 22 | 23 | 24 | 25 |
26 | ); 27 | } 28 | 29 | export default BottomNav; 30 | 31 | interface BtnProps { 32 | active: string; 33 | name: string; 34 | title: string; 35 | } 36 | 37 | function BottomNavBtn({ active, name, title }: BtnProps) { 38 | const [cartItemsCount, setCartItemsCount] = React.useState(0); 39 | 40 | React.useEffect(() => { 41 | if (typeof window !== "undefined") { 42 | setInterval(() => { 43 | const cartItems = 44 | window?.localStorage.getItem("@seedz_cart") === null 45 | ? [] 46 | : JSON.parse(window?.localStorage.getItem("@seedz_cart") as string); 47 | setCartItemsCount(cartItems.length); 48 | }, 1000); 49 | } 50 | }, []); 51 | 52 | React.useEffect(() => { 53 | const cartItems = 54 | localStorage.getItem("@seedz_cart") === null 55 | ? [] 56 | : JSON.parse(localStorage.getItem("@seedz_cart") as string); 57 | setCartItemsCount(cartItems.length); 58 | }, []); 59 | 60 | function renderIcon() { 61 | let icon = null; 62 | if (name === "dashboard") { 63 | icon = ; 64 | } 65 | if (name === "store") { 66 | icon = ; 67 | } 68 | if (name === "cart") { 69 | icon = ( 70 |
71 | {cartItemsCount > 0 && ( 72 |

73 | {cartItemsCount} 74 |

75 | )} 76 | 77 |
78 | ); 79 | } 80 | if (name === "profile") { 81 | icon = ; 82 | } 83 | if (name === "assistant") { 84 | icon = ; 85 | } 86 | return icon; 87 | } 88 | 89 | return ( 90 | 97 | {renderIcon()} 98 | 104 | {title} 105 | 106 | 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /packages/app/pages/api/prisma/migrations/20230819164143_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('MERCHANT', 'SUPPLIER', 'BUYER'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Users" ( 6 | "id" TEXT NOT NULL, 7 | "email" TEXT NOT NULL, 8 | "username" TEXT NOT NULL, 9 | "fullname" TEXT NOT NULL, 10 | "role" "Role" NOT NULL DEFAULT 'BUYER', 11 | "image" TEXT NOT NULL, 12 | 13 | CONSTRAINT "Users_pkey" PRIMARY KEY ("id") 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "DeliveryAddress" ( 18 | "id" TEXT NOT NULL, 19 | "userId" TEXT NOT NULL, 20 | "street" TEXT NOT NULL, 21 | "city" TEXT NOT NULL, 22 | "state" TEXT NOT NULL, 23 | "postalCode" TEXT NOT NULL, 24 | "country" TEXT NOT NULL, 25 | 26 | CONSTRAINT "DeliveryAddress_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "Wallet" ( 31 | "id" TEXT NOT NULL, 32 | "userId" TEXT NOT NULL, 33 | "balance" DOUBLE PRECISION NOT NULL DEFAULT 0, 34 | "currency" TEXT NOT NULL, 35 | 36 | CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id") 37 | ); 38 | 39 | -- CreateTable 40 | CREATE TABLE "Transaction" ( 41 | "id" TEXT NOT NULL, 42 | "userId" TEXT NOT NULL, 43 | "type" TEXT NOT NULL, 44 | "amount" DOUBLE PRECISION NOT NULL, 45 | "description" TEXT NOT NULL, 46 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 47 | 48 | CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") 49 | ); 50 | 51 | -- CreateTable 52 | CREATE TABLE "Products" ( 53 | "id" TEXT NOT NULL, 54 | "userId" TEXT NOT NULL, 55 | "name" TEXT NOT NULL, 56 | "description" TEXT NOT NULL, 57 | "quantity" INTEGER NOT NULL, 58 | "price" INTEGER NOT NULL, 59 | "currency" TEXT NOT NULL, 60 | "type" TEXT NOT NULL, 61 | "availableForRent" BOOLEAN NOT NULL, 62 | "rentingPrice" INTEGER NOT NULL, 63 | "images" TEXT[], 64 | 65 | CONSTRAINT "Products_pkey" PRIMARY KEY ("id") 66 | ); 67 | 68 | -- CreateTable 69 | CREATE TABLE "ProductsRating" ( 70 | "id" TEXT NOT NULL, 71 | "userId" TEXT NOT NULL, 72 | "rate" INTEGER NOT NULL, 73 | 74 | CONSTRAINT "ProductsRating_pkey" PRIMARY KEY ("id") 75 | ); 76 | 77 | -- CreateTable 78 | CREATE TABLE "TransactionRef" ( 79 | "id" TEXT NOT NULL, 80 | "ref" TEXT NOT NULL, 81 | 82 | CONSTRAINT "TransactionRef_pkey" PRIMARY KEY ("id") 83 | ); 84 | 85 | -- CreateIndex 86 | CREATE UNIQUE INDEX "Users_id_key" ON "Users"("id"); 87 | 88 | -- CreateIndex 89 | CREATE UNIQUE INDEX "Users_email_key" ON "Users"("email"); 90 | 91 | -- CreateIndex 92 | CREATE UNIQUE INDEX "Wallet_userId_key" ON "Wallet"("userId"); 93 | 94 | -- CreateIndex 95 | CREATE UNIQUE INDEX "Transaction_id_key" ON "Transaction"("id"); 96 | 97 | -- AddForeignKey 98 | ALTER TABLE "DeliveryAddress" ADD CONSTRAINT "DeliveryAddress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 99 | 100 | -- AddForeignKey 101 | ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 102 | 103 | -- AddForeignKey 104 | ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 105 | 106 | -- AddForeignKey 107 | ALTER TABLE "ProductsRating" ADD CONSTRAINT "ProductsRating_id_fkey" FOREIGN KEY ("id") REFERENCES "Products"("id") ON DELETE CASCADE ON UPDATE CASCADE; 108 | 109 | -- AddForeignKey 110 | ALTER TABLE "ProductsRating" ADD CONSTRAINT "ProductsRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 111 | -------------------------------------------------------------------------------- /packages/app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} **/ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx}", 5 | "./components/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | darkMode: "class", 8 | theme: { 9 | extend: { 10 | colors: { 11 | dark: { 12 | 100: "#131418", 13 | 105: "#1a1a1a", 14 | 200: "#1E1E22", 15 | 300: "#181920", 16 | 400: "#12151A", 17 | 405: "rgb(18, 21, 26,.5)", 18 | 500: "#20222C", 19 | 600: "rgba(0,0,0,.5)", 20 | 700: "rgba(0,0,0,.9)", 21 | 800: "rgba(19, 20, 24, .8)", 22 | 900: "rgba(19, 20, 24, .4)", 23 | }, 24 | dark2: { 25 | 100: "#131418", 26 | 200: "#26282c", 27 | 300: "#393b40", 28 | 400: "#4d4f54", 29 | 500: "#616369", 30 | 600: "#75777d", 31 | 700: "#898c92", 32 | 800: "rgba(0, 0, 0, 0.8)", 33 | }, 34 | green: { 35 | 100: "#64f4ac", 36 | 200: "#64f4acea", 37 | 300: "rgb(100, 244, 172, .7)", 38 | 305: "#5dc8a9", 39 | 400: "#05ff82", 40 | 500: "#15eb80", 41 | 600: "#02b151", 42 | 700: "#014f42", 43 | 705: "#012922" 44 | }, 45 | red: { 46 | 100: "rgb(255, 0, 0, .4)", 47 | 200: "#ff0000", 48 | 300: "#cc0000", 49 | 305: "#ff4741", 50 | 400: "#990000", 51 | 500: "#660000", 52 | 600: "#330000", 53 | 700: "#000000", 54 | }, 55 | white: { 56 | 100: "#fff", 57 | 105: "#f6f8fb", 58 | 200: "#ccc", 59 | 300: "#ebebebb6", 60 | 400: "#777", 61 | 500: "rgba(0,0,0,.1)", 62 | 600: "rgba(255,255,255,0.08)", 63 | }, 64 | white2: { 65 | 100: "#fff", 66 | 200: "#f2f2f2", 67 | 300: "#e6e6e6", 68 | 400: "#d9d9d9", 69 | 500: "#cccccc", 70 | 600: "#bfbfbf", 71 | 700: "#b3b3b3", 72 | }, 73 | slate: { 74 | 100: "#ccd6f6", 75 | 200: "#8892b0", 76 | }, 77 | blue: { 78 | 100: "#258dfd", 79 | 105: "#4055e4", 80 | 200: "#4898f0", 81 | 300: "#3F7EEE", 82 | 301: "#59CBE8", 83 | 302: "#102241", 84 | 400: "#0655E2", 85 | 500: "#513cef", 86 | 600: "#5452d379", 87 | 700: "#0142e2", 88 | 705: "rgba(1, 65, 226, 0.4)", 89 | 800: "#08173f", 90 | }, 91 | yellow: { 92 | 100: "#fcec66", 93 | 200: "#f9d936", 94 | 300: "#f5c502", 95 | 400: "#f1b200", 96 | 500: "#eeda00", 97 | 600: "#e8c400", 98 | }, 99 | orange: { 100 | 100: "#ffb400", 101 | 200: "#ff9d00", 102 | 300: "#ff8500", 103 | 400: "#ff6e00", 104 | 500: "#ff5a00", 105 | 600: "#ff4500", 106 | }, 107 | purple: { 108 | 100: "#a38edb", 109 | 200: "#8f7ecf", 110 | 300: "#7a6fc3", 111 | 400: "#6a60b7", 112 | 500: "#59519c", 113 | 600: "#4b437e", 114 | }, 115 | pink: { 116 | 100: "#ffa3c9", 117 | 200: "#ff8fb1", 118 | 300: "#ff7799", 119 | 400: "#ff5f81", 120 | 500: "#fc4468", 121 | 600: "#e82a4f", 122 | }, 123 | gray: { 124 | 100: "#a0a6c9", 125 | 200: "#3f4550" 126 | } 127 | }, 128 | animation: { 129 | "spin-fast": "spin .5s linear infinite" 130 | } 131 | }, 132 | fontFamily: { 133 | "pp-eb": ["Poppins-ExtraBold"], 134 | "pp-rg": ["Poppins-Regular"], 135 | "pp-sb": ["Poppins-SemiBold"], 136 | }, 137 | }, 138 | plugins: [], 139 | }; 140 | -------------------------------------------------------------------------------- /packages/app/pages/api/paystack_webhook.ts: -------------------------------------------------------------------------------- 1 | // pages/api/webhooks.js 2 | 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | import crypto from "crypto"; 5 | import prisma from "./config/prisma"; 6 | import { genID } from "./helper"; 7 | import $http from "./config/axios"; 8 | 9 | const secret = process.env.CLERK_WH_SECRET as string; 10 | const paystack_secret = process.env.PS_TEST_SEC as string; 11 | 12 | // webhook handler for paystack 13 | export default async function webhookHandler( 14 | req: NextApiRequest, 15 | res: NextApiResponse 16 | ) { 17 | if (req.method === "POST") { 18 | const whPayload = verifyWH(req); 19 | if (whPayload.success) { 20 | const data = whPayload.data; 21 | const event = (data as any).event; 22 | 23 | console.log(data); 24 | 25 | if (event === "charge.success") { 26 | const whData = data?.data; 27 | const traRef = whData?.reference; 28 | const traId = whData?.id; 29 | const creditAmount = whData?.amount / 100; 30 | const userData = whData?.customer; 31 | const userMail = userData?.email; 32 | 33 | // verify transaction 34 | const traVerification = await verifyPayment(traRef); 35 | 36 | if (traVerification.success === false) { 37 | // ! Send customer email why the transaction failed. 38 | console.log(traVerification.msg); 39 | return; 40 | } 41 | 42 | // check if transaction ref exists 43 | const transactionExists = await prisma.transactionRef?.findMany({ 44 | where: { 45 | AND: { 46 | id: String(traId), 47 | ref: traRef, 48 | }, 49 | }, 50 | }); 51 | 52 | if (transactionExists?.length > 0) { 53 | console.log(`Duplicate transaction found: user -> [${userMail}] .`); 54 | return; 55 | } 56 | 57 | // check if this user exists 58 | const user = await prisma.users.findFirst({ 59 | where: { email: userMail }, 60 | include: { wallet: true }, 61 | }); 62 | 63 | if (user === null || typeof user === "undefined") { 64 | console.log( 65 | `Failed to credit user wallet, user ${userMail} notfound` 66 | ); 67 | return; 68 | } 69 | 70 | // store transaction ref 71 | await prisma.transactionRef.create({ 72 | data: { id: String(traId), ref: traRef }, 73 | }); 74 | 75 | // credit user wallet 76 | const totalBalance = (user.wallet?.balance as number) + creditAmount; 77 | 78 | await prisma.wallet.update({ 79 | where: { userId: user.id }, 80 | data: { 81 | balance: totalBalance, 82 | }, 83 | }); 84 | 85 | //! Send customer email notification 86 | console.log( 87 | `Wallet Topped Up with ₦${creditAmount} was successful: [${userMail}]` 88 | ); 89 | return; 90 | } 91 | } 92 | } else { 93 | res.status(200).json({ msg: "You've reached webhook endpoint." }); 94 | } 95 | } 96 | 97 | function verifyWH(req: NextApiRequest) { 98 | try { 99 | const hash = crypto 100 | .createHmac("sha512", paystack_secret) 101 | .update(JSON.stringify(req.body)) 102 | .digest("hex"); 103 | const validHash = hash == req.headers["x-paystack-signature"]; 104 | return { data: req.body, success: validHash }; 105 | } catch (e: any) { 106 | console.log(`Error verifying webhook: ${e.message}`); 107 | return { data: null, success: false }; 108 | } 109 | } 110 | 111 | async function verifyPayment(reference: string) { 112 | let result = { success: false, msg: null || "" }; 113 | try { 114 | const req = await $http.get(`/transaction/verify/${reference}`); 115 | const res = req.data; 116 | if (res.status) { 117 | result["success"] = true; 118 | result["msg"] = res.message; 119 | return result; 120 | } 121 | result["success"] = false; 122 | result["msg"] = "Something went wrong initializing payment."; 123 | return result; 124 | } catch (e: any) { 125 | console.log(e?.response?.data?.message ?? e.message); 126 | console.log( 127 | `Transaction Verification Failed: ${e.response.data.message ?? e.message}` 128 | ); 129 | result["success"] = false; 130 | result["msg"] = `Transaction Verification Failed: ${ 131 | e.response.data.message ?? e.message 132 | }`; 133 | return result; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /packages/app/components/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { IoMdClose } from "react-icons/io"; 3 | import { IoClose } from "react-icons/io5"; 4 | import { twMerge } from "tailwind-merge"; 5 | 6 | interface ModalProp { 7 | isOpen?: boolean; 8 | onClose?: () => void; 9 | showCloseIcon?: boolean; 10 | children?: React.ReactNode; 11 | isBlurBg?: boolean; 12 | fixed?: boolean; 13 | className?: React.ComponentProps<"div">["className"]; 14 | } 15 | 16 | const Modal = ({ 17 | children, 18 | isOpen, 19 | showCloseIcon, 20 | onClose, 21 | fixed, 22 | className, 23 | }: ModalProp) => { 24 | const [isVisible, setIsVisible] = useState(isOpen); 25 | const blurBg = `backdrop-blur-xl opacity-[1]`; 26 | const transBg = ``; 27 | 28 | React.useEffect(() => { 29 | setIsVisible(isOpen); 30 | }, [isOpen]); 31 | 32 | const handleClickOutside = (e: Event) => { 33 | const tgt = (e.target as any)?.dataset; 34 | const name = tgt.name; 35 | name && onClose; 36 | }; 37 | 38 | React.useEffect(() => { 39 | if (isOpen) { 40 | document.body.classList.add("modal-open"); 41 | document.addEventListener("mousedown", handleClickOutside); 42 | } else { 43 | document.body.classList.remove("modal-open"); 44 | document.removeEventListener("mousedown", handleClickOutside); 45 | } 46 | return () => { 47 | document.removeEventListener("mousedown", handleClickOutside); 48 | }; 49 | }, [isOpen]); 50 | 51 | if (!isVisible) { 52 | return null; 53 | } 54 | 55 | return ( 56 |
65 |
66 | {showCloseIcon && ( 67 |
68 | {/* @ts-ignore */} 69 | 76 |
77 | )} 78 |
{children}
79 |
80 |
81 | ); 82 | }; 83 | 84 | export default Modal; 85 | 86 | export const ChildBlurModal = ({ 87 | children, 88 | isOpen, 89 | showCloseIcon, 90 | onClose, 91 | fixed, 92 | isBlurBg, 93 | className, 94 | }: ModalProp) => { 95 | const [isVisible, setIsVisible] = useState(isOpen); 96 | const blurBg = `backdrop-blur-xl opacity-[1]`; 97 | const transBg = ``; 98 | 99 | React.useEffect(() => { 100 | setIsVisible(isOpen); 101 | }, [isOpen]); 102 | 103 | const handleClickOutside = (e: Event) => { 104 | const tgt = (e.target as any)?.dataset; 105 | const name = tgt.name; 106 | name && onClose; 107 | }; 108 | 109 | React.useEffect(() => { 110 | if (isOpen) { 111 | document.body.classList.add("modal-open"); 112 | document.addEventListener("mousedown", handleClickOutside); 113 | } else { 114 | document.body.classList.remove("modal-open"); 115 | document.removeEventListener("mousedown", handleClickOutside); 116 | } 117 | return () => { 118 | document.removeEventListener("mousedown", handleClickOutside); 119 | }; 120 | }, [isOpen]); 121 | 122 | if (!isVisible) { 123 | return null; 124 | } 125 | 126 | return ( 127 |
138 |
139 | {showCloseIcon && ( 140 |
141 | 149 |
150 | )} 151 |
{children}
152 |
153 |
154 | ); 155 | }; 156 | -------------------------------------------------------------------------------- /packages/app/pages/api/controller/assistant.ts: -------------------------------------------------------------------------------- 1 | import { SeedzAiPayload } from "@/@types"; 2 | import { SeedzAiSchema } from "../helper/validator"; 3 | import ServerResponseError from "../helper/errorHandler"; 4 | 5 | const CUSTOM_PROMPT = ` 6 | You are a helpful AI assistant named SeedzAI, an advanced AI dedicated to supporting farmers with expert insights and guidance in the realm of agriculture. 7 | 8 | You must provide accurate, relevant, and helpful information only pertaining to crop cultivation, livestock management, sustainable practices, farm equipment, pest control, soil health, and related topics within the agricultural and farming finance domain. You must respond in Simple, Concise and Short language that any farmer can understand. 9 | 10 | If a user asks a question or initiates a discussion that is not directly related to the domain or agriculture and farming in general, do not provide an answer or engage in the conversation.Instead, politely redirect their focus back to the domain and its related content. 11 | 12 | If a user inquires about the creator of SeedzAI, respond with: The creator of SeedzAI is Benaiah Alumona, a software engineer, his github and twitter profile is https://github.com/benrobo and https://twitter.com/benaiah_al. 13 | 14 | Your expertise is limited to the agricultural, farming, machinery and finance domain, and you must not provide any information on topics outside the scope of that domain. 15 | 16 | All reply or output must be rendered in markdown format!. 17 | 18 | Additionally, you must only answer and communicate in {{language}} language, regardless of the language used by the user. 19 | `; 20 | 21 | // If a user writes in a different language, kindly ask them to rephrase their question in English to ensure clear communication. 22 | 23 | const validLang = [ 24 | { 25 | name: "English", 26 | code: "en", 27 | }, 28 | { 29 | name: "Nigerian Pidgin", 30 | code: "pcm", 31 | }, 32 | { 33 | name: "Yoruba", 34 | code: "yr", 35 | }, 36 | { 37 | name: "French", 38 | code: "fr", 39 | }, 40 | ]; 41 | 42 | async function seedzAIAssistant(payload: SeedzAiPayload) { 43 | const { error, value } = SeedzAiSchema.validate(payload); 44 | if (isValidationError(error)) { 45 | throw new ServerResponseError("INVALID_FIELDS", (error as any).message); 46 | } 47 | 48 | const { question, lang } = payload; 49 | 50 | const sanitizedLang = 51 | validLang.filter((l) => l.code === lang)[0]?.name ?? "English"; 52 | const prompt = CUSTOM_PROMPT.replaceAll("{{language}}", sanitizedLang); 53 | 54 | // console.log(prompt); 55 | 56 | const result = await openAiCompletion(prompt, question); 57 | 58 | if (result?.success) { 59 | const answer = result?.data as any; 60 | return { 61 | answer, 62 | lang, 63 | success: true, 64 | }; 65 | } else { 66 | throw new ServerResponseError("ASSISTANT_ERROR", result?.msg); 67 | } 68 | } 69 | 70 | export default seedzAIAssistant; 71 | 72 | async function openAiCompletion(prompt: string, message: string) { 73 | let resp: any = { success: false, data: null, msg: "" }; 74 | const apiKey = process.env.OPENAI_API_KEY; 75 | const url = "https://api.openai.com/v1/chat/completions"; 76 | 77 | const body = { 78 | messages: [ 79 | { 80 | role: "system", 81 | content: prompt, 82 | }, 83 | { 84 | role: "user", 85 | content: message, 86 | }, 87 | ], 88 | model: "gpt-3.5-turbo", 89 | max_tokens: 1000, 90 | temperature: 0.9, 91 | n: 1, 92 | top_p: 1, 93 | // stop: ".", 94 | stream: true, 95 | }; 96 | 97 | try { 98 | const completion = await fetch(url, { 99 | method: "POST", 100 | body: JSON.stringify(body), 101 | headers: { 102 | "Content-Type": "application/json", 103 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 104 | // "OpenAI-Organization": process.env.OPENAI_ORGANIZATION, 105 | }, 106 | }); 107 | 108 | const reader = completion.body?.getReader(); 109 | const decoder = new TextDecoder("utf-8"); 110 | let result = []; 111 | 112 | while (true) { 113 | const chunk = await reader?.read(); 114 | const { done, value } = chunk as any; 115 | if (done) { 116 | break; 117 | } 118 | const decoded = decoder.decode(value); 119 | const lines = decoded.split("\n"); 120 | const parsedLines = lines 121 | .map((l) => l.replace(/^data:/, "").trim()) 122 | .filter((line) => line !== "" && line !== "[DONE]") 123 | .map((line) => JSON.parse(line)); 124 | 125 | for (const parsedLine of parsedLines) { 126 | const { choices } = parsedLine; 127 | const { delta } = choices[0]; 128 | const { content } = delta; 129 | if (content) { 130 | // console.log(content); 131 | result.push(content); 132 | } 133 | } 134 | } 135 | 136 | resp["data"] = result; 137 | resp["msg"] = ""; 138 | resp["success"] = true; 139 | 140 | return resp; 141 | } catch (e: any) { 142 | console.log(e); 143 | console.log(e?.response?.data); 144 | const msg = 145 | e?.response?.data?.error?.message ?? 146 | e?.response?.data?.message ?? 147 | e.message; 148 | console.log(`SeedzAi Assistant Error: ${msg}`); 149 | resp["success"] = false; 150 | resp["data"] = null; 151 | resp["msg"] = `SeedzAi Assistant Error: ${msg}`; 152 | return resp; 153 | } 154 | } 155 | 156 | function isValidationError(error: any) { 157 | return typeof error !== "undefined"; 158 | } 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Seedz 2 | 3 | [Initial Problem set](https://www.tralac.org/discussions/article/11690-why-is-25th-may-important-for-african-nations.html) 4 | 5 | ### alternatives 6 | 7 | [Agri App](https://play.google.com/store/apps/details?id=com.criyagen) 8 | [Agrizy](https://play.google.com/store/apps/details?id=in.agrizy.app) 9 | 10 | To empower small farmers by facilitating access to markets, buyers, suppliers, and agricultural knowledge, ultimately improving agricultural productivity. 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | ## Problem statement: 22 | 23 | **Problem Statement**: 24 | 25 | Imagine being a small-scale farmer with dreams of growth. But you're stuck with limited access to markets, critical farming knowledge, and even basic financial services. This holds back your productivity, income, and the potential to thrive. You're disconnected from buyers, lack vital insights for successful farming, and face financial hurdles due to network disruptions. 26 | 27 | **Solution**: 28 | 29 | SeedzAI, your farming partner. I'm breaking down these barriers by offering a digital marketplace, AI-guided farming wisdom, and a robust wallet system. Now, you can reach markets, make smarter choices, and stay financially secure, no matter the network challenges. It's your growth, made accessible. 30 | 31 | ### Key Features: 32 | 33 | - **Marketplace** : Seedz provides a digital marketplace where farmers can showcase their produce and connect with potential buyers. Buyers could include local consumers, restaurants, supermarkets, and even processors looking for raw materials. 34 | 35 | - **Wallet System** : During bank providers network failure, farmers / buyers / suppliers could easily topUp their wallet using paystack, and easily order items from their available wallet balance. 36 | 37 | - **Product Showcase** : Merchant also known as farmers and Suppliers could easily upload their products for sale. Products can also be specified as either Rent or Buy which allows customers to be more flexible when purchasing a goods. 38 | 39 | - **Weather Updates**: Integrating weather forecasts and alerts will help farmers plan their activities more effectively, such as planting and harvesting, according to weather patterns. 40 | 41 | - **SeedzAI** : SeedzAI is an advanced AI designed specifically to assist farmers in the realm of agriculture. It provides expert insights, guidance, and support on various topics related to crop cultivation, livestock management, sustainable practices, farm equipment, pest control, soil health, and more. Its purpose is to offer valuable assistance and contribute positively to the farming community’s knowledge and success. 42 | 43 | SeedzAI is also able to respond in 2 different local languages known as English and Nigeria Pidgin to foster better communication. 44 | 45 | ## Technology Stack 46 | 47 | For a seamless experience for farmers and consumers, SeedzAI was developed using the following technology stack: 48 | 49 | - ### Backend 50 | 51 | - [Nextjs (API)](https://nextjs.org/) :- A react fullstack javscript framework. 52 | - [Postgresql](https://www.postgresql.org/) :- A relational database. 53 | - [Supabase](https://supabase.com/) :- The open source Firebase alternative. 54 | - [Paystack](https://paystack.com/) :- A Simple, easy payments solution. 55 | - [Resend](https://resend.com/) :- A Platform meant for sending mails. 56 | - [OpenAI API](https://openai.com/) :- OpenAI API empowers developers to integrate advanced AI capabilities into apps, enabling human-like language understanding and generation, solving complex problems, and performing diverse tasks using sophisticated machine learning models. 57 | - [Cloudinary](https://cloudinary.com/) :- A media storage and manipulation platform. 58 | - [Passage](https://docs.passage.id/) :- A standalone and powerful authentication / authorization platform. 59 | - [GraphQL](https://graphql.org/) :- A query language builder for API. 60 | - [Apollo Server](https://www.apollographql.com/) :- The GraphQL developer platform. 61 | 62 | - ### Frontend 63 | 64 | - [Nextjs](https://nextjs.org/) :- A react framework. 65 | - [Tailwindcss](https://tailwindcss.com/) :- a Css utility framework. 66 | - [Blurhash](https://blurha.sh/) :- a compact representation of a placeholder for an image. 67 | - [Passage](https://docs.passage.id/) :- A standalone and powerful authentication / authorization platform. 68 | - [Apollo Client](https://www.apollographql.com/) :- The GraphQL developer platform. 69 | 70 | - ### Deployment 71 | - [Vercel](https://vercel.com/) :- A PaaS deployment platform. 72 | 73 | ## Acknowledgement 74 | 75 | Huge thanks to the [GenzHackfest](https://twitter.com/genztechies) team for making this hackathon happen! I've gained so much knowledge in such a short time. 🙌 Despite the countless sleepless nights, I learned, built, and deployed this project in just 3 days – it's been an incredible accomplishment. I'm amazed by what I achieved in such a short time! Most of the tech I used was new to me, but this hackfest pushed me to learn fast 🚀. 76 | 77 | Even though this project isn't perfect, considering the timeline spent, developing this to an MVP stage was worth it in the end. Can't wait to see the next milestone with this. 78 | 🎉 Thank you for this opportunity and a big shoutout to the team and contributors – your efforts will definitely pay off! 👏 79 | 80 | ### Collaborator 81 | 82 | - [Benaiah Alumona](https://github.com/benrobo) 83 | -------------------------------------------------------------------------------- /research.md: -------------------------------------------------------------------------------- 1 | It sounds like you have a comprehensive and impactful project in the works for the SeedzAI hackathon. Judges will likely have questions that aim to assess the viability, impact, and innovation of your project. Here are some questions that judges might ask, along with suggested additional sections you could consider adding to your project description: 2 | 3 | **Questions Judges Might Ask**: 4 | 5 | 1. **Market Need and Impact**: 6 | - How did you identify the specific needs of small farmers that your project addresses? 7 | - Can you provide data or anecdotes about the challenges faced by small farmers that your project aims to solve? 8 | - How will your project positively impact the agricultural productivity of small farmers? 9 | 2. **Innovation and Uniqueness**: 10 | - What sets SeedzAI apart from other platforms or solutions targeting farmers and agricultural knowledge? 11 | - How does the integration of AI and local language support enhance the user experience for farmers? 12 | 3. **Usability and Accessibility**: 13 | - How user-friendly is your platform for farmers who may not be tech-savvy? 14 | - How have you ensured that the platform is accessible to users with varying levels of digital literacy? 15 | 4. **Marketplace and Networking**: 16 | - How will the digital marketplace help farmers connect with buyers and suppliers? What tools are available to facilitate these connections? 17 | - How do you plan to onboard and verify farmers, buyers, and suppliers on the platform? 18 | 5. **Financial Inclusion**: 19 | - Can you elaborate on the wallet system and its benefits for users during banking network failures? 20 | - How do you ensure the security of wallet top-ups and transactions? 21 | 6. **Product Showcase**: 22 | - How will the product showcase feature benefit both farmers and customers? 23 | - What options are available for customers to indicate whether they want to buy or rent products? 24 | 7. **Weather Updates Integration**: 25 | - How accurate and reliable are the weather forecasts and alerts provided by your platform? 26 | - Can you share examples of how farmers might utilize weather updates to make informed decisions? 27 | 8. **Implementation and Partnerships**: 28 | - What technologies and tools are you using to develop the platform? 29 | - Have you established any partnerships with local agricultural organizations, NGOs, or government bodies? 30 | 9. **Scalability and Future Plans**: 31 | - How do you plan to scale the platform to reach a wider audience of farmers and users? 32 | - Do you have any plans to expand the platform's features or capabilities in the future? 33 | 34 | **Additional Sections to Consider**: 35 | 36 | 1. **User Testimonials**: 37 | Share quotes or testimonials from potential users, showcasing their excitement about the platform's features and benefits. 38 | 39 | 2. **Security Measures**: 40 | Detail the security measures in place to protect user data, financial transactions, and sensitive information. 41 | 42 | 3. **Educational Resources**: 43 | Explain how SeedzAI provides educational resources and guides to help farmers make informed decisions. 44 | 45 | 4. **Community Engagement**: 46 | Describe any community engagement initiatives or features that promote interaction and knowledge sharing among farmers. 47 | 48 | 5. **Sustainability and Business Model**: 49 | Provide insights into the sustainability of your project and how you plan to generate revenue or secure funding in the long term. 50 | 51 | 6. **Future Features**: 52 | Highlight potential future features you're considering adding to the platform to enhance its functionality. 53 | 54 | 7. **User Support**: 55 | Explain the user support mechanisms you have in place to assist users with any issues they may encounter. 56 | 57 | 8. **Impact Measurement**: 58 | Share how you plan to measure the actual impact of your project on the agricultural productivity of small farmers. 59 | 60 | By addressing these potential questions and adding these sections, you can provide a more comprehensive and detailed overview of your SeedzAI project, increasing your chances of impressing the judges. Good luck with your hackathon! 61 | 62 | Of course! Here are sample answers to the questions that judges might ask: 63 | 64 | Answers to the above questions: 65 | 66 | **Market Need and Impact**: 67 | 68 | - I conducted extensive research and interviews with small farmers to understand their challenges. Lack of access to markets, buyers, and agricultural knowledge emerged as significant obstacles. SeedzAI aims to bridge these gaps, empowering farmers and improving agricultural productivity. 69 | - According to a recent survey, 70% of small farmers struggle to find reliable buyers. SeedzAI's digital marketplace addresses this by connecting farmers with potential buyers, thereby increasing their income and reducing post-harvest losses. 70 | 71 | **Innovation and Uniqueness**: 72 | 73 | - SeedzAI stands out through its integration of AI and support for local languages. This enables even non-tech-savvy farmers to access expert insights and guidance in their preferred language. 74 | - Unlike other platforms, SeedzAI combines the marketplace, weather updates, product showcase, and AI-powered assistance, offering a holistic solution that covers farmers' needs comprehensively. 75 | 76 | **Usability and Accessibility**: 77 | 78 | - We've designed SeedzAI with a user-centric approach, prioritizing simplicity and intuitive navigation. Farmers can easily upload products and access weather updates without requiring advanced technical skills. 79 | - Our user interface follows accessibility guidelines, making the platform usable for farmers with varying levels of digital literacy. 80 | 81 | **Marketplace and Networking**: 82 | 83 | - SeedzAI's digital marketplace enables farmers to showcase their produce to a wide range of potential buyers, including local consumers, restaurants, supermarkets, and processors. 84 | - To ensure authenticity, we verify farmers, buyers, and suppliers during the registration process to maintain a trusted community. 85 | 86 | **Financial Inclusion**: 87 | 88 | - The wallet system offers a practical solution during banking network failures. Users can top up their wallet using Paystack, ensuring they can continue to transact and access products seamlessly. 89 | - This feature is especially valuable for rural farmers who might face challenges with consistent banking services. 90 | 91 | **Product Showcase**: 92 | 93 | - The product showcase feature allows merchants to upload their products for sale, enhancing visibility. Additionally, the "Rent or Buy" option caters to diverse customer preferences, fostering flexibility. 94 | 95 | **Weather Updates Integration**: 96 | 97 | - SeedzAI's integration of weather forecasts and alerts empowers farmers to make informed decisions aligned with weather patterns. For instance, they can adjust planting and harvesting schedules accordingly. 98 | 99 | **Implementation and Partnerships**: 100 | 101 | - We're leveraging modern technologies such as React and Node.js to develop SeedzAI's platform. Additionally, we're exploring partnerships with local agricultural organizations to enhance outreach and impact. 102 | 103 | **Scalability and Future Plans**: 104 | 105 | - Our scalable architecture enables us to accommodate a growing number of users while maintaining performance. We plan to expand our services to additional regions and languages, ensuring more farmers benefit from SeedzAI's offerings. 106 | 107 | Feel free to adapt these answers to align with the specifics of your project and its goals. It's important to emphasize your project's uniqueness, its potential impact, and the careful considerations you've put into addressing farmers' needs. 108 | -------------------------------------------------------------------------------- /packages/app/pages/api/controller/product.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiAddProductProp, 3 | ApiProductCheckoutProps, 4 | FundWalletType, 5 | } from "../../../@types"; 6 | import prisma from "../config/prisma"; 7 | import ServerResponseError from "../helper/errorHandler"; 8 | import { 9 | AddProductSchema, 10 | InitPaymentSchema, 11 | ProductCheckoutSchema, 12 | } from "../helper/validator"; 13 | import { genID } from "../helper"; 14 | import $http from "../config/axios"; 15 | import sendMail from "../helper/sendMail"; 16 | import { ProductCheckoutTemp } from "@/components/EmailTemplate"; 17 | 18 | type productQtyArray = { prodId: string; qty: number; name: string }[]; 19 | export default class ProductController { 20 | constructor() {} 21 | 22 | private isValidationError(error: any) { 23 | return typeof error !== "undefined"; 24 | } 25 | 26 | private async validateProductQuantities(productQtyArray: productQtyArray) { 27 | const products = await this.checkAvailableQuantities(productQtyArray); 28 | 29 | const validationResults = productQtyArray.map((item) => { 30 | const requestedQty = item.qty; 31 | const product = products.find((p) => p.id === item.prodId); 32 | if (!product) { 33 | return { 34 | prodId: item.prodId, 35 | notValid: false, 36 | name: item.name, 37 | reason: "Product not found", 38 | }; 39 | } 40 | const availableQty = product.quantity; 41 | const isValid = requestedQty > availableQty; 42 | 43 | return { 44 | prodId: item.prodId, 45 | notValid: isValid, 46 | name: item.name, 47 | reason: isValid ? "Limited quantity" : "Available", 48 | }; 49 | }); 50 | 51 | return validationResults; 52 | } 53 | 54 | private async checkAvailableQuantities(productQtyArray: productQtyArray) { 55 | const productIds = productQtyArray.map((item) => item.prodId); 56 | const products = await prisma.products.findMany({ 57 | where: { 58 | id: { in: productIds }, 59 | }, 60 | }); 61 | 62 | const foundProductIds = products.map((product) => product.id); 63 | const missingProductIds = productIds.filter( 64 | (id) => !foundProductIds.includes(id) 65 | ); 66 | 67 | if (missingProductIds.length > 0) { 68 | const missingProductNames = missingProductIds.map((id) => { 69 | const productQty = productQtyArray.find((item) => item.prodId === id); 70 | return productQty ? productQty?.name : "Unknown Product"; 71 | }); 72 | 73 | throw new ServerResponseError( 74 | "PRODUCT_NOT_FOUND", 75 | `The following products are not available: ${missingProductNames.join( 76 | ", " 77 | )}` 78 | ); 79 | } 80 | 81 | return products; 82 | } 83 | 84 | private async extractProductInfo(productQtyArray: productQtyArray) { 85 | const info = await Promise.all( 86 | productQtyArray.map(async (item) => { 87 | const id = item.prodId; 88 | const prodSeller = await prisma.products.findFirst({ 89 | where: { id }, 90 | include: { user: true }, 91 | }); 92 | const amount = prodSeller?.availableForRent 93 | ? prodSeller?.rentingPrice 94 | : prodSeller?.price; 95 | return { 96 | id, 97 | amount: amount as number, 98 | qty: item.qty, 99 | name: prodSeller?.name, 100 | userInfo: { 101 | // sellers info 102 | id: prodSeller?.user?.id, 103 | email: prodSeller?.user?.email, 104 | name: prodSeller?.user?.fullname, 105 | }, 106 | }; 107 | }) 108 | ); 109 | 110 | const totalAmount = info 111 | .map((item) => item?.amount * item?.qty) 112 | .reduce((acc, t) => (acc += t)); 113 | 114 | return { 115 | info, 116 | totalAmount, 117 | }; 118 | } 119 | 120 | async addProduct(payload: ApiAddProductProp, userId: string) { 121 | const { error, value } = AddProductSchema.validate(payload); 122 | if (this.isValidationError(error)) { 123 | throw new ServerResponseError("INVALID_FIELDS", (error as any).message); 124 | } 125 | 126 | const { 127 | availableForRent, 128 | image, 129 | category, 130 | description, 131 | name, 132 | price, 133 | rentingPrice, 134 | quantity, 135 | } = payload; 136 | 137 | // check if prod is avaiilable for rent 138 | if (availableForRent && rentingPrice <= 0) { 139 | throw new ServerResponseError( 140 | "MISSING_RENTING_PRICE", 141 | "renting price is missing" 142 | ); 143 | } 144 | 145 | if (availableForRent === false && price <= 0) { 146 | throw new ServerResponseError( 147 | "MISSING_BUYING_PRICE", 148 | "product buying price is missing" 149 | ); 150 | } 151 | 152 | await prisma.products.create({ 153 | data: { 154 | id: genID(20), 155 | name, 156 | type: category, 157 | price, 158 | availableForRent, 159 | rentingPrice, 160 | image: { 161 | create: { 162 | hash: image.hash, 163 | url: image.url, 164 | }, 165 | }, 166 | description, 167 | currency: "NGN", 168 | userId, 169 | quantity, 170 | }, 171 | }); 172 | 173 | return { success: true, msg: "Product created successfully." }; 174 | } 175 | 176 | async getProducts() { 177 | return await prisma.products.findMany({ 178 | include: { image: true, ratings: true, user: true }, 179 | }); 180 | } 181 | 182 | async productCheckout(payload: ApiProductCheckoutProps, userId: string) { 183 | const { error, value } = ProductCheckoutSchema.validate(payload); 184 | if (this.isValidationError(error)) { 185 | throw new ServerResponseError("INVALID_FIELDS", (error as any).message); 186 | } 187 | 188 | const { totalAmount, productQty } = payload; 189 | 190 | const buyerInfo = await prisma.users.findFirst({ 191 | where: { id: userId }, 192 | include: { wallet: true }, 193 | }); 194 | const hasSufficientQty = await this.validateProductQuantities(productQty); 195 | const insufficientQty = hasSufficientQty.filter((d) => d.notValid); 196 | 197 | if (insufficientQty.length > 0) { 198 | const prodNames = insufficientQty.map((d) => d.name); 199 | const msg = 200 | insufficientQty.length > 1 201 | ? `Limited Quantities for this products: ${prodNames.join(", ")} ` 202 | : `Limited Quantity for ${prodNames[0]}`; 203 | throw new ServerResponseError("LIMITED_QUANTITY", msg); 204 | } 205 | 206 | // check balance 207 | const prodInfo = await this.extractProductInfo(productQty); 208 | 209 | console.log(prodInfo.totalAmount); 210 | // console.log(prodInfo.info.map((d) => d.userInfo)); 211 | 212 | if (prodInfo.totalAmount !== totalAmount) { 213 | throw new ServerResponseError( 214 | "INVALID_TOTAL_AMOUNT", 215 | "Total checkout amout is invalid" 216 | ); 217 | } 218 | 219 | // const totalCheckoutBal = productQty?.map(qty => qty.) 220 | const debUser = await prisma.users.findFirst({ 221 | where: { id: userId }, 222 | include: { wallet: true }, 223 | }); 224 | 225 | if ((debUser?.wallet?.balance as number) < totalAmount) { 226 | throw new ServerResponseError( 227 | "INSUFFICIENT_BALANCE", 228 | "Insufficient Balance" 229 | ); 230 | } 231 | 232 | // update quantity for each product 233 | 234 | // handle crediting of sellers acount 235 | // Calculate total debited amount and total credited amount 236 | let totalDebitedAmount = 0; 237 | let totalCreditedAmount = 0; 238 | let buyerWalletBal = null; 239 | let sellerWalletBal = null; 240 | 241 | await Promise.all( 242 | prodInfo?.info.map(async (data, i) => { 243 | const amount = data.amount; 244 | const prodSum = data.qty * amount; 245 | const userInfo = data.userInfo; 246 | const buyerId = userId; 247 | 248 | const product = await prisma.products.findFirst({ 249 | where: { id: data?.id }, 250 | }); 251 | 252 | // Update product quantity 253 | const updatedQty = (product as any)?.quantity - data?.qty; 254 | 255 | // Save the updated quantity back to the database 256 | await prisma.products.update({ 257 | where: { id: data?.id }, 258 | data: { quantity: updatedQty }, 259 | }); 260 | 261 | // is buyer and seller at same time ❌ 262 | if (userInfo?.id === buyerId) { 263 | throw new ServerResponseError( 264 | "FORBIDDEN_ACTION", 265 | "You aren't allowed to buy your own product." 266 | ); 267 | } else { 268 | totalDebitedAmount += prodSum; 269 | totalCreditedAmount += prodSum; 270 | 271 | // Get buyer's wallet balance 272 | buyerWalletBal = await prisma.wallet.findFirst({ 273 | where: { userId: buyerInfo?.id as string }, 274 | include: { user: true }, 275 | }); 276 | 277 | // Get seller's wallet balance 278 | sellerWalletBal = await prisma.wallet.findFirst({ 279 | where: { userId: userInfo.id as string }, 280 | include: { user: true }, 281 | }); 282 | } 283 | }) 284 | ); 285 | 286 | // Calculate updated balances 287 | const updatedBuyerBalance = 288 | (buyerWalletBal as any)?.balance - totalDebitedAmount; 289 | const updatedSellerBalance = 290 | (sellerWalletBal as any)?.balance + totalCreditedAmount; 291 | 292 | // Update buyer's wallet balance 293 | if (buyerWalletBal !== null) { 294 | await prisma.wallet.update({ 295 | where: { userId }, 296 | data: { 297 | balance: updatedBuyerBalance, 298 | }, 299 | }); 300 | 301 | console.log( 302 | `Debited ${ 303 | (buyerWalletBal as any)?.user?.email 304 | } : ${totalDebitedAmount}` 305 | ); 306 | } 307 | 308 | // Update seller's wallet balance 309 | if (sellerWalletBal !== null) { 310 | await prisma.wallet.update({ 311 | where: { userId: (sellerWalletBal as any)?.userId }, 312 | data: { 313 | balance: updatedSellerBalance, 314 | }, 315 | }); 316 | 317 | console.log( 318 | `Credited ${ 319 | (sellerWalletBal as any)?.user?.email 320 | } : ${totalCreditedAmount}` 321 | ); 322 | } 323 | 324 | await sendMail({ 325 | to: (buyerWalletBal as any)?.user?.email as string, 326 | subject: "Purchase Confirmation Email", 327 | template: ProductCheckoutTemp({ 328 | email: buyerInfo?.email as string, 329 | fullname: buyerInfo?.fullname as string, 330 | products: prodInfo?.info as any, 331 | type: "DEBIT", 332 | isBuyer: true, 333 | amount: totalDebitedAmount, 334 | }), 335 | }); 336 | 337 | await sendMail({ 338 | to: (sellerWalletBal as any)?.user?.email as string, 339 | subject: "Purchase Confirmation Email", 340 | template: ProductCheckoutTemp({ 341 | email: buyerInfo?.email as string, 342 | fullname: buyerInfo?.fullname as string, 343 | products: prodInfo?.info as any, 344 | type: "CREDIT", 345 | isBuyer: false, 346 | amount: totalCreditedAmount, 347 | }), 348 | }); 349 | 350 | // return success 351 | return { success: true }; 352 | } 353 | 354 | async deleteProduct(prodId: string, userId: string) { 355 | if (prodId.length === 0) { 356 | throw new ServerResponseError("INVALID_PROD_ID", "product id is missing"); 357 | } 358 | 359 | // check if product exists or not 360 | const product = await prisma.products.findFirst({ 361 | where: { 362 | AND: { 363 | id: prodId, 364 | }, 365 | }, 366 | }); 367 | 368 | if (product === null) { 369 | throw new ServerResponseError( 370 | "PRODUCT_NOTFOUND", 371 | "Failed to delete, product not found." 372 | ); 373 | } 374 | 375 | const isAuthorised = await prisma.products.findFirst({ 376 | where: { 377 | AND: { 378 | id: prodId, 379 | userId, 380 | }, 381 | }, 382 | }); 383 | 384 | if (isAuthorised === null) { 385 | throw new ServerResponseError( 386 | "Unauthorized_Action", 387 | "Permission denied." 388 | ); 389 | } 390 | 391 | // delete the product 392 | await prisma.products.delete({ 393 | where: { id: prodId }, 394 | }); 395 | 396 | return { success: true }; 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /packages/app/components/Assistant/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Layout, { MobileLayout } from "../Layout"; 3 | import { ChildBlurModal } from "../Modal"; 4 | import { BiCog, BiSend, BiSolidBot } from "react-icons/bi"; 5 | import ImageTag from "../Image"; 6 | import { IoIosArrowBack } from "react-icons/io"; 7 | import { useMutation } from "@apollo/client"; 8 | import { SeedzAssistant } from "@/http"; 9 | import handleApolloHttpErrors from "@/http/error"; 10 | import MarkdownRenderer from "../MarkdownRender"; 11 | import { BsFillPlayFill } from "react-icons/bs"; 12 | 13 | const chatLanguages = [ 14 | { 15 | name: "English", 16 | code: "en", 17 | }, 18 | { 19 | name: "Nigerian Pidgin", 20 | code: "pcm", 21 | }, 22 | { 23 | name: "Yoruba", 24 | code: "yr", 25 | }, 26 | { 27 | name: "French", 28 | code: "fr", 29 | }, 30 | ]; 31 | 32 | interface AssistantProps { 33 | closeAssistantModal: () => void; 34 | openAssistant: () => void; 35 | isOpen: boolean; 36 | } 37 | 38 | interface ChatMessage { 39 | message: string[] | string; 40 | type: "bot" | "user" | string; 41 | lang: string; 42 | isError: boolean; 43 | } 44 | 45 | function Assistant({ 46 | closeAssistantModal, 47 | openAssistant, 48 | isOpen, 49 | }: AssistantProps) { 50 | const [settingsModal, setSettingsModal] = React.useState(false); 51 | const [selectedLang, setSelectedLang] = React.useState("en"); 52 | const [welcomePage, setWelcomePage] = React.useState(false); 53 | const [messages, setMessages] = React.useState([] as ChatMessage[]); 54 | const [askSeedzAiMut, { loading, error, reset, data }] = useMutation( 55 | SeedzAssistant, 56 | { errorPolicy: "none" } 57 | ); 58 | const [userMsg, setUserMsg] = React.useState(""); 59 | const [isSpeaking, setIsSpeaking] = React.useState(false); 60 | const [activeUtterance, setActiveUtterance] = React.useState(null); 61 | 62 | const messagesEndRef = React.useRef(null); 63 | 64 | React.useEffect(() => { 65 | if (typeof window !== "undefined") { 66 | const welcomePage = 67 | localStorage.getItem("@ai_welcome_page") === null 68 | ? null 69 | : JSON.parse(localStorage.getItem("@ai_welcome_page") as string); 70 | 71 | setWelcomePage(welcomePage ?? true); 72 | 73 | // onMount load ai messages from localStorage 74 | const messages = 75 | localStorage.getItem("@seedz_ai_resp") === null 76 | ? null 77 | : JSON.parse(localStorage.getItem("@seedz_ai_resp") as string); 78 | setMessages(messages ?? []); 79 | } 80 | // eslint-disable-next-line react-hooks/exhaustive-deps 81 | }, []); 82 | 83 | React.useEffect(() => { 84 | if (messages.length > 0) { 85 | setWelcomePage(false); 86 | localStorage.setItem("@ai_welcome_page", JSON.stringify(false)); 87 | localStorage.setItem("@seedz_ai_resp", JSON.stringify(messages)); 88 | } 89 | }, [messages]); 90 | 91 | React.useEffect(() => { 92 | reset(); 93 | if (error) { 94 | const networkError = error.networkError as any; 95 | const errorObj = 96 | networkError?.result?.errors[0] ?? error.graphQLErrors[0]; 97 | const err = errorObj?.message; 98 | const combMsg = [ 99 | ...messages, 100 | { 101 | isError: false, 102 | lang: data?.askSeedzAi?.answer?.lang, 103 | message: err ?? null, 104 | type: "bot", 105 | }, 106 | ]; 107 | setMessages(combMsg); 108 | localStorage.setItem("@seedz_ai_resp", JSON.stringify(combMsg)); 109 | scrollToBottom(); 110 | } else if (typeof data?.askSeedzAi.success !== "undefined") { 111 | const combMsg = [ 112 | ...messages, 113 | { 114 | isError: false, 115 | lang: data?.askSeedzAi?.answer?.lang, 116 | message: data?.askSeedzAi?.answer?.join("") ?? null, 117 | type: "bot", 118 | }, 119 | ]; 120 | setMessages(combMsg); 121 | localStorage.setItem("@seedz_ai_resp", JSON.stringify(combMsg)); 122 | scrollToBottom(); 123 | } 124 | // eslint-disable-next-line react-hooks/exhaustive-deps 125 | }, [data, error]); 126 | 127 | React.useEffect(() => { 128 | scrollToBottom(); 129 | }, []); 130 | 131 | function sendInitialAiMsg(msg: string) { 132 | if (msg.length === 0) return; 133 | 134 | const payload = { 135 | question: msg, 136 | lang: selectedLang ?? "en", 137 | }; 138 | 139 | askSeedzAiMut({ variables: { AiInput: payload } }); 140 | } 141 | 142 | function sendMessage() { 143 | if (userMsg.length === 0) return; 144 | 145 | const payload = { 146 | question: userMsg, 147 | lang: selectedLang ?? "en", 148 | }; 149 | 150 | const combMsg = [ 151 | ...messages, 152 | { 153 | isError: false, 154 | lang: selectedLang ?? "en", 155 | message: userMsg, 156 | type: "user", 157 | }, 158 | ]; 159 | 160 | askSeedzAiMut({ variables: { AiInput: payload } }); 161 | setMessages(combMsg); 162 | setUserMsg(""); 163 | localStorage.setItem("@seedz_ai_resp", JSON.stringify(combMsg)); 164 | scrollToBottom(); 165 | } 166 | 167 | // scroll to bottom implementation 168 | const scrollToBottom = () => { 169 | (messagesEndRef as any).current?.scrollIntoView({ 170 | behavior: "smooth", 171 | }); 172 | }; 173 | 174 | // handle ai speaking 175 | const handleTTS = (e: any) => { 176 | const dataset = e.target?.dataset; 177 | console.log(dataset); 178 | if (Object.entries(dataset).length > 0) { 179 | const cont = dataset["id"]; 180 | const allMsg = 181 | localStorage.getItem("@seedz_ai_resp") === null 182 | ? null 183 | : JSON.parse(localStorage.getItem("@seedz_ai_resp") as any); 184 | if (allMsg !== null) { 185 | const filteredMsg = allMsg 186 | .filter((d: any) => d?.type === "bot") 187 | .filter((d: any) => d.message === cont); 188 | 189 | if (filteredMsg.length > 0) { 190 | const msg = filteredMsg[0]?.message; 191 | if (activeUtterance) { 192 | stopSpeaking(); 193 | // synthesizeAndSpeak(msg); 194 | } else { 195 | synthesizeAndSpeak(msg); 196 | } 197 | } 198 | } 199 | } 200 | }; 201 | 202 | const synthesizeAndSpeak = (msg: string) => { 203 | if ("speechSynthesis" in window) { 204 | if (activeUtterance) { 205 | speechSynthesis.cancel(); // Stop any currently active utterance 206 | } 207 | 208 | const voices = speechSynthesis.getVoices(); 209 | const utterance = new SpeechSynthesisUtterance(msg); 210 | 211 | // Set up the event listener for when the utterance ends 212 | utterance.addEventListener("end", () => { 213 | setIsSpeaking(false); 214 | }); 215 | 216 | utterance.voice = voices[41]; 217 | 218 | speechSynthesis.speak(utterance); 219 | setActiveUtterance(utterance as any); 220 | setIsSpeaking(true); 221 | } 222 | }; 223 | 224 | const stopSpeaking = () => { 225 | if ("speechSynthesis" in window && activeUtterance) { 226 | speechSynthesis.cancel(); // Cancel the active utterance 227 | setActiveUtterance(null); 228 | setIsSpeaking(false); 229 | } 230 | }; 231 | 232 | return ( 233 | <> 234 | 240 | 244 |
245 |
246 | 255 | 261 |
262 | {messages?.length === 0 && ( 263 | 267 | )} 268 | {/* Main chat area */} 269 | {messages?.length > 0 && ( 270 |
271 | {/* Gap */} 272 |
273 | 274 | {messages?.length > 0 275 | ? messages?.map((m, idx) => ( 276 |
280 | {m.type === "user" && ( 281 |
282 |
283 | {m.message} 284 |
285 |
286 | )} 287 | {m.type === "bot" && ( 288 |
292 |
299 | { 300 | 307 | } 308 |
309 |
310 | 320 |
321 |
322 | )} 323 |
324 | )) 325 | : null} 326 | 327 |
328 | 329 | {loading && ( 330 |
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 | )} 341 | 342 |
343 |
344 | )} 345 | 346 | {/* Settings */} 347 | {settingsModal && ( 348 | setSettingsModal(false)} 351 | showCloseIcon 352 | isBlurBg 353 | className="bg-dark-500" 354 | > 355 |
356 |
357 |
358 |

359 | Chat Settings 360 |

361 |
362 |
363 |
364 |

365 | Set language 366 |

367 | 380 |
381 |
382 |
383 | 389 |
390 |
391 |
392 |
393 |
394 | )} 395 | 396 | {/* chat section */} 397 |
398 |
399 | setUserMsg(e.target.value)} 407 | onKeyDown={(e) => { 408 | if (e.key === "Enter") { 409 | sendMessage(); 410 | } 411 | }} 412 | /> 413 | 419 |
420 |
421 |
422 |
423 |
424 | 425 | ); 426 | } 427 | 428 | export default Assistant; 429 | 430 | interface WelcomeScreenProps { 431 | setMessage: React.Dispatch>; 432 | sendInitialAiMsg: (val: string) => void; 433 | } 434 | 435 | function WelcomeScreen({ setMessage, sendInitialAiMsg }: WelcomeScreenProps) { 436 | const exampleMessages = [ 437 | "How to improve soil condition", 438 | "What are the most effective methods for pest control on cabbage?", 439 | "What is the best time to plant okra in Nigeria?", 440 | "When should I plant my cotton for the best prices when it is ready?", 441 | "How do I improve soil condition using natural methods?", 442 | "How can I reduce my water usage for my cotton?", 443 | ]; 444 | 445 | return ( 446 |
447 |
448 | 453 |
454 |
455 |

Welcome to Seedz AI

456 |

457 | An AI Agriculture Assistant for Farmers 458 |

459 |
460 |

Few examples to ask

461 |
462 |
463 | {exampleMessages.map((d) => ( 464 | 477 | ))} 478 |
479 |
480 | ); 481 | } 482 | -------------------------------------------------------------------------------- /packages/app/pages/cart.tsx: -------------------------------------------------------------------------------- 1 | import ImageTag, { LazyLoadImg } from "@/components/Image"; 2 | import Layout, { MobileLayout } from "@/components/Layout"; 3 | import { ChildBlurModal } from "@/components/Modal"; 4 | import { Router, useRouter } from "next/router"; 5 | import React from "react"; 6 | import { 7 | IoIosArrowBack, 8 | IoIosArrowForward, 9 | IoIosCloseCircle, 10 | } from "react-icons/io"; 11 | import { CurrencySymbol, formatCurrency } from "./api/helper"; 12 | import { AllProductProp } from "@/@types"; 13 | import withAuth from "@/helpers/withAuth"; 14 | import toast from "react-hot-toast"; 15 | import { MdLocationOn } from "react-icons/md"; 16 | import { 17 | CircularProgressbar, 18 | CircularProgressbarWithChildren, 19 | buildStyles, 20 | } from "react-circular-progressbar"; 21 | import { useMutation } from "@apollo/client"; 22 | import { ProductCheckout } from "../http"; 23 | import handleApolloHttpErrors from "../http/error"; 24 | import { IoClose } from "react-icons/io5"; 25 | import { BsCheckCircleFill } from "react-icons/bs"; 26 | 27 | function Cart() { 28 | const router = useRouter(); 29 | const [allCartItems, setAllCartItems] = React.useState( 30 | [] as AllProductProp[] 31 | ); 32 | const [totalPrice, setTotalPrice] = React.useState(0); 33 | const [checkoutPayModal, setCheckoutPayModal] = React.useState(false); 34 | const [paymentProgress, setPaymentProgress] = React.useState(false); 35 | const [prodCheckoutMutation, prodCheckoutMutProps] = 36 | useMutation(ProductCheckout); 37 | const [checkoutError, setCheckoutError] = React.useState(null); 38 | const [progressBarPercents, setProgressBarPercents] = React.useState(0); 39 | const [checkoutStatusScreen, setCheckoutStatusScreen] = React.useState(false); 40 | 41 | const updateItemQuantity = ( 42 | itId: string, 43 | qty: number, 44 | cartQty: number, 45 | action: "+" | "-" 46 | ) => { 47 | const filteredCartItem = allCartItems.find((item) => item.id === itId); 48 | const restItems = allCartItems.filter((item) => item.id !== itId); 49 | 50 | if (!filteredCartItem) { 51 | return; // Item not found, handle this case 52 | } 53 | 54 | let newCartQty = cartQty; 55 | 56 | if (action === "+") { 57 | if (newCartQty >= qty) { 58 | toast.error("Limited quantity available."); 59 | } else { 60 | newCartQty = newCartQty + 1; 61 | // update localstorage 62 | (filteredCartItem as any).cartQty = newCartQty; 63 | const comb = [...restItems, filteredCartItem]; 64 | localStorage.setItem("@seedz_cart", JSON.stringify(comb)); 65 | } 66 | } else if (action === "-") { 67 | if (newCartQty <= 1) { 68 | const confirmRemove = window.confirm( 69 | "Are you sure you want to remove this item?" 70 | ); 71 | if (confirmRemove) { 72 | // Remove item logic here 73 | localStorage.setItem("@seedz_cart", JSON.stringify(restItems)); 74 | } 75 | } else { 76 | newCartQty = newCartQty - 1; 77 | // update localstorage 78 | (filteredCartItem as any).cartQty = newCartQty; 79 | const comb = [...restItems, filteredCartItem]; 80 | localStorage.setItem("@seedz_cart", JSON.stringify(comb)); 81 | } 82 | } 83 | 84 | console.log({ cartQty: newCartQty, name: filteredCartItem.name }); 85 | }; 86 | 87 | const handleProductCheckout = () => { 88 | let checkoutPayload: any[] = []; 89 | allCartItems.map((d) => { 90 | checkoutPayload.push({ 91 | prodId: d?.id, 92 | qty: (d as any)?.cartQty, // this property isn't in the type yet 93 | name: d?.name, 94 | }); 95 | }); 96 | 97 | const productCheckoutPayload = { 98 | productQty: checkoutPayload, 99 | totalAmount: totalPrice, 100 | }; 101 | 102 | setCheckoutPayModal(true); 103 | setPaymentProgress(true); 104 | 105 | prodCheckoutMutation({ 106 | variables: { 107 | productCheckoutPayload, 108 | }, 109 | }); 110 | }; 111 | 112 | React.useEffect(() => { 113 | if (typeof window !== "undefined") { 114 | setInterval(() => { 115 | const cartItems = 116 | window.localStorage.getItem("@seedz_cart") === null 117 | ? [] 118 | : JSON.parse(window.localStorage.getItem("@seedz_cart") as string); 119 | setAllCartItems(cartItems); 120 | }, 500); 121 | } 122 | }, []); 123 | 124 | React.useEffect(() => { 125 | if (allCartItems.length === 0) return; 126 | let prices = [] as number[]; 127 | allCartItems.forEach((item) => { 128 | if (item.availableForRent) { 129 | prices.push(item.rentingPrice * (item as any).cartQty); 130 | } 131 | if (!item.availableForRent) { 132 | prices.push(item.price * (item as any).cartQty); 133 | } 134 | }); 135 | const total = prices?.reduce((total, price) => (total += price)); 136 | setTotalPrice(total); 137 | }, [allCartItems]); 138 | 139 | // effect to control progress bar 140 | React.useEffect(() => { 141 | if (paymentProgress) { 142 | let count = 0; 143 | const interval = setInterval(() => { 144 | if (prodCheckoutMutProps.loading === true) { 145 | setProgressBarPercents((count += 10)); 146 | } else { 147 | const finalCount = 100 - progressBarPercents; 148 | setProgressBarPercents(finalCount); 149 | clearInterval(interval); 150 | setTimeout(() => { 151 | setPaymentProgress(false); 152 | setCheckoutStatusScreen(true); 153 | }, 1000); 154 | } 155 | }, 100); 156 | } 157 | // eslint-disable-next-line react-hooks/exhaustive-deps 158 | }, [prodCheckoutMutProps.data, prodCheckoutMutProps.error]); 159 | 160 | // 161 | React.useEffect(() => { 162 | prodCheckoutMutProps.reset(); 163 | if (prodCheckoutMutProps.error) { 164 | const err = handleApolloHttpErrors(prodCheckoutMutProps.error); 165 | setCheckoutError(err); 166 | } else if ( 167 | typeof prodCheckoutMutProps?.data?.productCheckout?.success !== 168 | "undefined" 169 | ) { 170 | setCheckoutError(null); 171 | // delete localstorage cart 172 | localStorage.removeItem("@seedz_cart"); 173 | } 174 | // eslint-disable-next-line react-hooks/exhaustive-deps 175 | }, [prodCheckoutMutProps.data, prodCheckoutMutProps.error]); 176 | 177 | const totalCheckout = `${CurrencySymbol.NGN} ${formatCurrency( 178 | totalPrice, 179 | CurrencySymbol.NGN 180 | )}`; 181 | 182 | const resetCheckoutState = () => { 183 | setCheckoutPayModal(false); 184 | setCheckoutStatusScreen(false); 185 | setProgressBarPercents(0); 186 | setCheckoutError(null); 187 | router.push("/cart"); 188 | }; 189 | 190 | return ( 191 | 192 | 193 |
194 |
195 | 201 |

202 | Order Details 203 |

204 |
205 | 206 |
207 |
208 |

My Cart

209 | {allCartItems.length > 0 ? ( 210 | allCartItems 211 | .sort((a, b) => { 212 | if (a > b) return -1; 213 | return 1; 214 | }) 215 | .map((d) => ( 216 | 230 | )) 231 | ) : ( 232 |

233 | No items in cart. 234 |

235 | )} 236 |
237 | {allCartItems.length > 0 && ( 238 | <> 239 |
240 |

241 | Delivery Location 242 |

243 | 266 |
267 |
268 |
269 |

Order Info

270 |
271 |

Total

272 |

273 | {totalCheckout} 274 |

275 |
276 |
277 |
278 | 284 | 285 | )} 286 |
287 |
288 | 289 | {/* Payment */} 290 | 297 | {/* Logo */} 298 |
299 |
300 | 305 | 306 | Seedz 307 | 308 |
309 |
310 |
311 | {/* payment progress bar */} 312 | {paymentProgress && ( 313 |
317 | 318 | {(value: any) => ( 319 | 350 |
351 |

Total

352 |

353 | {totalCheckout} 354 |

355 |

356 | Secure Payment 357 |

358 |
359 |
360 | )} 361 |
362 |
363 |
364 |

365 | Payment Processing... 366 |

367 |

368 | Please wait while your transaction is been processed. 369 |

370 |
371 |
372 | )} 373 | 374 | {/* Payment Success | Failure Screen */} 375 | {checkoutStatusScreen && ( 376 |
377 |
378 | {checkoutError === null ? ( 379 | 380 | ) : ( 381 | 382 | )} 383 |
384 |
385 |

386 | Total amount 387 |

388 |

{totalCheckout}

389 |
390 |
391 |

392 | {checkoutError === null 393 | ? "Payment Successful" 394 | : "Payment Failed"} 395 |

396 |

397 | {checkoutError} 398 |

399 |
400 | 406 |
407 |
408 |
409 | )} 410 |
411 |
412 |
413 |
414 | ); 415 | } 416 | 417 | export default withAuth(Cart); 418 | 419 | interface ItemCardProps { 420 | name: string; 421 | ratings: number[]; 422 | price: number; 423 | id: string; 424 | hash: string; 425 | imgUrl: string; 426 | quantity: number; 427 | cartQty: number; 428 | forRent: boolean; 429 | rentPrice: number; 430 | updateQuantity: ( 431 | itemId: string, 432 | quantity: number, 433 | cartQty: number, 434 | action: "+" | "-" 435 | ) => void; 436 | } 437 | 438 | function ItemCard({ 439 | name, 440 | price, 441 | id, 442 | hash, 443 | imgUrl, 444 | quantity, 445 | cartQty, 446 | forRent, 447 | rentPrice, 448 | updateQuantity, 449 | }: ItemCardProps) { 450 | return ( 451 |
452 | 465 | {/* */} 466 |
467 |
468 |

{name.trim()}

469 |

({quantity}) left

470 |
471 |
472 |

473 | {CurrencySymbol.NGN} 474 | 475 | {forRent ? rentPrice : price} 476 | 477 |

478 |
479 | 485 | {cartQty} 486 | 492 |
493 |
494 |
495 |
496 | ); 497 | } 498 | 499 | const ProgressProvider = ({ 500 | valueStart, 501 | valueEnd, 502 | children, 503 | }: { 504 | valueStart: number; 505 | valueEnd: number; 506 | children: Function; 507 | }) => { 508 | const [value, setValue] = React.useState(valueStart); 509 | React.useEffect(() => { 510 | setValue(valueEnd); 511 | }, [valueEnd]); 512 | 513 | return children(value); 514 | }; 515 | -------------------------------------------------------------------------------- /packages/app/pages/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import BlurBgRadial from "@/components/BlurBgRadial"; 2 | import BottomNav from "@/components/BottomNav"; 3 | import Layout, { MobileLayout } from "@/components/Layout"; 4 | import { ChildBlurModal } from "@/components/Modal"; 5 | import withAuth from "@/helpers/withAuth"; 6 | import React from "react"; 7 | import { BiCurrentLocation } from "react-icons/bi"; 8 | import { BsBank2, BsFillCloudLightningRainFill } from "react-icons/bs"; 9 | import { FaTemperatureHigh } from "react-icons/fa"; 10 | import { IoAddOutline, IoCashOutline } from "react-icons/io5"; 11 | import { MdDoubleArrow, MdVerified } from "react-icons/md"; 12 | import { formatCurrency, formatNumLocale } from "./api/helper"; 13 | import { useMutation, useQuery } from "@apollo/client"; 14 | import { CreateNewUser, FundWallet, GetUserInfo } from "../http"; 15 | import toast from "react-hot-toast"; 16 | import { Spinner } from "@/components/Spinner"; 17 | import { UserType } from "../@types"; 18 | import handleApolloHttpErrors from "../http/error"; 19 | import useIsRendered from "@/helpers/useIsRendered"; 20 | import Assistant from "@/components/Assistant"; 21 | import useWeather from "@/helpers/useWeather"; 22 | import moment from "moment"; 23 | import ImageTag from "@/components/Image"; 24 | import useAuth from "@/helpers/useIsAuth"; 25 | import Link from "next/link"; 26 | import { twMerge } from "tailwind-merge"; 27 | import { Passage } from "@passageidentity/passage-js"; 28 | import { IoIosLogOut } from "react-icons/io"; 29 | import ENV from "./api/config/env"; 30 | 31 | const passage = new Passage(ENV.passageAppId); 32 | const passageUser = passage.getCurrentUser(); 33 | 34 | function Dashboard() { 35 | const seedzUseAuth = useAuth(); 36 | const hasRendered = useIsRendered(5); 37 | const [walletTopup, setWalletTopup] = React.useState(false); 38 | const [topUpAmount, setTopUpAmount] = React.useState(0); 39 | const [userCreateModal, setUserCreateModal] = React.useState(false); 40 | const [fundWallet, { loading, error, data, reset }] = useMutation(FundWallet); 41 | const userQuery = useQuery(GetUserInfo); 42 | const [createUsetMut, createUserMutProps] = useMutation(CreateNewUser); 43 | const [userInfo, setUserInfo] = React.useState({} as UserType); 44 | const [assistantModal, setAssistantModal] = React.useState(false); 45 | const { 46 | getGeolocation, 47 | getWeatherByLocation, 48 | loading: weatherLoading, 49 | weatherInfo, 50 | } = useWeather(); 51 | const [passageUserLoading, setPassageUserLoading] = React.useState(false); 52 | const [logoutModal, setLogoutModal] = React.useState(false); 53 | 54 | const MIN_FUND_AMOUNT = 500; 55 | 56 | const creditWallet = () => { 57 | if (topUpAmount < MIN_FUND_AMOUNT) { 58 | toast.error(`amount can't be less than ${MIN_FUND_AMOUNT}`); 59 | return; 60 | } 61 | fundWallet({ 62 | variables: { 63 | amount: topUpAmount, 64 | currency: "NGN", 65 | }, 66 | }); 67 | }; 68 | 69 | const rolebadgeColor = (role: string) => { 70 | if (role === "SUPPLIER") return "#15eb80"; 71 | if (role === "BUYER") return "#ff8500"; 72 | if (role === "MERCHANT") return "#4055e4"; 73 | }; 74 | 75 | React.useEffect(() => { 76 | if (hasRendered) { 77 | // const hasRoleUpdated = 78 | // localStorage.getItem("@hasRoleUpdated") === null 79 | // ? null 80 | // : JSON.parse(localStorage.getItem("@hasRoleUpdated") as string); 81 | // if (hasRoleUpdated === null) { 82 | // // updateRole(); 83 | // } else { 84 | // // setHasRoleUpdated(hasRoleUpdated); 85 | // } 86 | } 87 | // eslint-disable-next-line react-hooks/exhaustive-deps 88 | }, [hasRendered]); 89 | 90 | // fundwallet mutation 91 | React.useEffect(() => { 92 | reset(); 93 | if (error) { 94 | // console.log(data); 95 | handleApolloHttpErrors(error); 96 | } else if (typeof data?.fundWallet.authorization_url !== "undefined") { 97 | toast.success("Payment Link Created"); 98 | window.open(data.fundWallet.authorization_url, "_blank"); 99 | setWalletTopup(false); 100 | setTopUpAmount(0); 101 | } 102 | // eslint-disable-next-line react-hooks/exhaustive-deps 103 | }, [data, error]); 104 | 105 | // fetch user info 106 | React.useEffect(() => { 107 | if (userQuery.error) { 108 | // console.log(data); 109 | const networkError = userQuery.error.networkError as any; 110 | const errorObj = 111 | networkError?.result?.errors[0] ?? userQuery.error.graphQLErrors[0]; 112 | if (errorObj.code === "NO_USER_FOUND") { 113 | setUserCreateModal(true); 114 | } else { 115 | handleApolloHttpErrors(userQuery.error); 116 | } 117 | } else if (typeof userQuery.data?.getUser?.email !== "undefined") { 118 | const info = userQuery.data?.getUser; 119 | localStorage.setItem( 120 | "@userInfo", 121 | JSON.stringify({ 122 | id: info?.id, 123 | email: info.email, 124 | username: info.username, 125 | fullname: info.fullname, 126 | role: info.role, 127 | image: info.image, 128 | }) 129 | ); 130 | setUserInfo(info); 131 | setUserCreateModal(false); 132 | } else { 133 | // noi user data available 134 | const info = userQuery.data?.getUser; 135 | if (info === null) { 136 | setUserCreateModal(true); 137 | } 138 | } 139 | // eslint-disable-next-line react-hooks/exhaustive-deps 140 | }, [userQuery.data, userQuery.error]); 141 | 142 | // creatingf of user 143 | React.useEffect(() => { 144 | createUserMutProps.reset(); 145 | if (createUserMutProps.error) { 146 | handleApolloHttpErrors(createUserMutProps?.error); 147 | } else if (createUserMutProps.data?.createUser?.success) { 148 | console.log(createUserMutProps.data); 149 | toast.success("Changes Saved"); 150 | window.location.reload(); 151 | } 152 | // eslint-disable-next-line react-hooks/exhaustive-deps 153 | }, [createUserMutProps.data, createUserMutProps.error]); 154 | 155 | async function createUser(role: string) { 156 | setPassageUserLoading(true); 157 | const userInfo = await passageUser.userInfo(); 158 | if (userInfo) { 159 | setPassageUserLoading(false); 160 | const { email, user_metadata } = userInfo; 161 | const payload = { 162 | email, 163 | username: user_metadata?.username, 164 | fullname: user_metadata?.fullname, 165 | role, 166 | }; 167 | 168 | if (role.length === 0) { 169 | toast.error("Please select a role."); 170 | return; 171 | } 172 | 173 | setUserCreateModal(false); 174 | createUsetMut({ 175 | variables: { payload }, 176 | }); 177 | } 178 | } 179 | 180 | const logout = () => { 181 | localStorage.clear(); 182 | window.location.href = "/auth"; 183 | }; 184 | 185 | return ( 186 | 187 | 188 | {!hasRendered || 189 | userQuery.loading === true || 190 | createUserMutProps.loading ? ( 191 | 192 |
193 | 194 |
195 |
196 | ) : null} 197 | 198 | {/* Modal meant t o create user first time before refetching */} 199 | {userCreateModal && ( 200 | 204 | )} 205 | 206 |
207 |
208 |
209 |
210 |

211 | Welcome,{" "} 212 | 213 | {userInfo.fullname ?? ""} 214 | 215 |

216 |

217 | Empowering Farmers, Enhancing Productivity 218 |

219 |
220 |
221 | 227 |
228 | 229 | 230 | {userInfo.role} 231 | 232 |
233 | 234 | {/* Logout modal */} 235 |
240 |
241 | 248 |
249 |

250 | {userInfo?.fullname} 251 |

252 |

253 | {userInfo?.email} 254 |

255 |
256 |
257 |
258 |
259 | 266 |
267 |
268 |
269 |
270 |
271 | 272 | {/* Wallet Section */} 273 |
274 | {/* style needed to add a mini box uder the div */} 275 | {/* before:content-[''] before:w-[70%] before:h-[100px] before:z-[-5] before:absolute before:bottom-[-.7em] before:mx-auto before:bg-green-300 before:shadow-sm before:rounded-[20px] */} 276 |
277 |

278 | Available Balance 279 |

280 |

281 | {formatCurrency(+userInfo?.wallet?.balance ?? 0, "NGN")} 282 |

283 |
284 | 291 | 307 |
308 |
309 |
310 | 311 |
312 | {/* some other component here */} 313 |
314 |
315 |

316 | Today :- {weatherInfo?.description} 317 |

318 |

319 | {moment(Date.now()).format("MMM Do YY")} 320 |

321 |
322 |
323 |

324 | {weatherInfo?.temperature ?? 0}{" "} 325 | 326 |

327 |
328 | 336 |
337 |
338 | 339 | {weatherLoading && ( 340 |
341 | )} 342 |
343 |
344 |
345 | 346 | {/* Top up modal */} 347 | setWalletTopup(false)} 352 | className="bg-white-600" 353 | > 354 |
355 |
356 |

Amount

357 |

358 | {formatCurrency(+topUpAmount, "NGN")} 359 |

360 |
361 |
362 |
363 |
364 | setTopUpAmount(e.target.value)} 368 | disabled={loading} 369 | /> 370 | 373 |
374 | 375 |
376 | 389 |
390 |
391 |
392 | 393 | {/* Logout Modal */} 394 | 395 | {/* Assistance */} 396 | setAssistantModal(true)} 399 | closeAssistantModal={() => setAssistantModal(false)} 400 | /> 401 |
402 |
403 | ); 404 | } 405 | 406 | export default withAuth(Dashboard); 407 | 408 | interface CreateNewUserProps { 409 | createUser: (role: string) => void; 410 | psgUserLoading: boolean; 411 | } 412 | 413 | function CreateNewUserModal({ 414 | createUser, 415 | psgUserLoading, 416 | }: CreateNewUserProps) { 417 | const [role, setRole] = React.useState(""); 418 | 419 | const validRoles = [ 420 | { name: "Merchant", icon: "💼", role: "MERCHANT" }, 421 | { name: "Buyer", icon: "🛍️", role: "BUYER" }, 422 | // { name: "Supplier", icon: "📦", role: "SUPPLIER" }, 423 | ]; 424 | 425 | const selectedRoles = (name: string) => { 426 | const filteredRole = validRoles.filter((role) => role.name === name)[0]; 427 | if (filteredRole.role === role) setRole(""); 428 | if (filteredRole.role !== role) setRole(filteredRole.role); 429 | }; 430 | 431 | return ( 432 | 433 |
434 |
435 |
436 | 437 | 442 | 443 | Seedz 444 |
445 |
446 |
447 |
448 |
449 |
450 |

What your Role?

451 |
452 | {validRoles.map((d) => ( 453 | 464 | ))} 465 |
466 | 474 |
475 |
476 |
477 |
478 | ); 479 | } 480 | -------------------------------------------------------------------------------- /packages/app/public/assets/img/logo/leaf-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | --------------------------------------------------------------------------------