├── 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 |
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 |
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 |
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 |
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 |
74 |
75 |
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 |
147 |
148 |
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 |
238 |
239 |
240 |
244 |
245 |
246 |
250 |
251 |
252 | Back
253 |
254 |
255 |
setSettingsModal(true)}
258 | >
259 |
260 |
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 |
315 | {/* */}
316 |
317 | {isSpeaking ? "⏸" : "▶️"}
318 |
319 |
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 |
setSelectedLang(e.target.value)}
372 | >
373 | select language
374 | {chatLanguages.map((d) => (
375 |
376 | {d.name}
377 |
378 | ))}
379 |
380 |
381 |
382 |
383 | setSettingsModal(false)}
386 | >
387 | Continue
388 |
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 |
417 |
418 |
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 | {
468 | setMessage((prev) => [
469 | ...prev,
470 | { message: d, lang: "en", type: "user", isError: false },
471 | ]);
472 | sendInitialAiMsg(d);
473 | }}
474 | >
475 | {d}
476 |
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 |
router.push("/store")}
198 | >
199 |
200 |
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 |
244 | {true ? (
245 | <>
246 |
250 |
251 |
252 | 9 Okey Eze street
253 |
254 |
255 | 10001, Lagos
256 |
257 |
258 |
259 | >
260 | ) : (
261 |
262 | Add delivery address.
263 |
264 | )}
265 |
266 |
267 |
268 |
269 |
Order Info
270 |
271 |
Total
272 |
273 | {totalCheckout}
274 |
275 |
276 |
277 |
278 |
282 | Checkout ({totalCheckout})
283 |
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 |
404 | Continue
405 |
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 |
456 | {hash?.length > 0 && (
457 |
463 | )}
464 |
465 | {/* */}
466 |
467 |
468 |
{name.trim()}
469 |
({quantity}) left
470 |
471 |
472 |
473 | {CurrencySymbol.NGN}
474 |
475 | {forRent ? rentPrice : price}
476 |
477 |
478 |
479 | updateQuantity(id, quantity, cartQty, "-")}
482 | >
483 | -
484 |
485 | {cartQty}
486 | updateQuantity(id, quantity, cartQty, "+")}
489 | >
490 | +
491 |
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 |
setLogoutModal(!logoutModal)}
224 | >
225 |
226 |
227 |
228 |
229 |
230 | {userInfo.role}
231 |
232 |
233 |
234 | {/* Logout modal */}
235 |
240 |
241 |
242 |
247 |
248 |
249 |
250 | {userInfo?.fullname}
251 |
252 |
253 | {userInfo?.email}
254 |
255 |
256 |
257 |
258 |
259 |
263 |
264 | Logout
265 |
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 | setWalletTopup(!walletTopup)}
287 | >
288 | Add Money {" "}
289 |
290 |
291 | {
294 | toast("Coming Soon!", {
295 | icon: "🚀",
296 | style: {
297 | borderRadius: "10px",
298 | background: "#333",
299 | color: "#fff",
300 | },
301 | });
302 | }}
303 | >
304 | Withdraw {" "}
305 |
306 |
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 |
371 |
372 |
373 |
374 |
375 |
376 |
380 | {loading ? (
381 |
382 | ) : (
383 | <>
384 | Continue {" "}
385 |
386 | >
387 | )}
388 |
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 |
selectedRoles(d.name)}
455 | key={d.name}
456 | className={twMerge(
457 | "w-[120px] bg-white-100 scale-[.70] shadow-2xl px-5 py-5 rounded-lg flex flex-col items-center justify-center border-[5px] border-transparent ",
458 | d.role === role && "border-solid border-[5px] border-dark-100 "
459 | )}
460 | >
461 | {d.icon}
462 | {d.name}
463 |
464 | ))}
465 |
466 | {
469 | createUser(role);
470 | }}
471 | >
472 | {psgUserLoading ? : "Save Changes"}
473 |
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 |
--------------------------------------------------------------------------------