├── .gitignore
├── public
├── logo-32.png
├── logo-64.png
├── logo-128.png
├── logo-256.png
├── logo-512.png
├── images
│ ├── home
│ │ └── hero.jpg
│ └── wallet.svg
└── error.svg
├── flows
├── User Payments Flow.jpg
├── Dashout - Order Flow.jpg
└── Billing Flow.md
├── API
├── ServerSideAPIs
│ ├── transactions.ts
│ ├── merchants.ts
│ └── orders.ts
├── transactions.ts
├── repayments.ts
├── auth.ts
└── merchants.ts
├── .env.example
├── types
├── merchant.ts
├── storeState.ts
├── order.ts
├── transaction.ts
└── user.ts
├── next-env.d.ts
├── helpers
├── razorpay.ts
├── verifyIDToken.ts
├── copyText.ts
├── toasts.ts
├── setupProtectedRoute.ts
└── request.ts
├── firebase
├── firestore.ts
├── index.ts
├── admin.ts
├── authentication.ts
└── firestore-security-rules
├── hooks
├── useUser.ts
└── useFirestore.ts
├── components
├── Layout
│ ├── ContentWrapper.tsx
│ ├── Error.tsx
│ ├── NoneFound.tsx
│ ├── AppContentContainer.tsx
│ ├── FullPageLoader.tsx
│ ├── index.tsx
│ ├── GlobalStyles.tsx
│ └── Header.tsx
├── Modal.tsx
├── Authentication
│ ├── LoginModal.tsx
│ └── index.tsx
├── FormControl.tsx
├── Button.tsx
├── Merchants
│ ├── MerchantCard.tsx
│ └── MerchantCreatorModal.tsx
└── TransactionTile.tsx
├── store
├── useStore.ts
└── index.ts
├── README.md
├── tsconfig.json
├── package.json
└── pages
├── api
├── getOrderDetails.ts
├── createOrder.ts
├── declineOrder.ts
├── confirmOrder.ts
├── createBillSettlementTransaction.ts
└── settleUserBill.ts
├── profile
└── [userId].tsx
├── _app.tsx
├── index.tsx
├── repayment
└── [orderId].tsx
├── user
├── merchants.tsx
└── tab.tsx
└── pay
└── [orderId].tsx
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 | .next
4 | keys/*
--------------------------------------------------------------------------------
/public/logo-32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-32.png
--------------------------------------------------------------------------------
/public/logo-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-64.png
--------------------------------------------------------------------------------
/public/logo-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-128.png
--------------------------------------------------------------------------------
/public/logo-256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-256.png
--------------------------------------------------------------------------------
/public/logo-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-512.png
--------------------------------------------------------------------------------
/flows/User Payments Flow.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/flows/User Payments Flow.jpg
--------------------------------------------------------------------------------
/public/images/home/hero.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/images/home/hero.jpg
--------------------------------------------------------------------------------
/flows/Dashout - Order Flow.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/flows/Dashout - Order Flow.jpg
--------------------------------------------------------------------------------
/API/ServerSideAPIs/transactions.ts:
--------------------------------------------------------------------------------
1 | import admin from "../../firebase/admin";
2 |
3 | // Create and manipulate user transactions here.
4 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | FIREBASE_SERVICE_ACCOUNT= # Service Account JSON
2 | NEXT_PUBLIC_FIREBASE_CONFIG= # Firebase Config JSON
3 | NEXT_PUBLIC_RAZORPAY_KEY_ID=
4 | RAZORPAY_KEY_SECRET=
--------------------------------------------------------------------------------
/types/merchant.ts:
--------------------------------------------------------------------------------
1 | export default interface Merchant {
2 | merchantName: string;
3 | photoURL: string;
4 | merchantEmail: string;
5 | webhookURL: string;
6 | successRedirect: string;
7 | errorRedirect: string;
8 | }
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/helpers/razorpay.ts:
--------------------------------------------------------------------------------
1 | import razorpaySDK from "razorpay";
2 |
3 | const razorpay = new razorpaySDK({
4 | key_id: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
5 | key_secret: process.env.RAZORPAY_KEY_SECRET,
6 | });
7 |
8 | export default razorpay;
9 |
--------------------------------------------------------------------------------
/firebase/firestore.ts:
--------------------------------------------------------------------------------
1 | import firebase from "./index";
2 | import mainFirebase from "firebase/app";
3 | import "firebase/firestore";
4 |
5 | const db = firebase.firestore();
6 |
7 | export default db;
8 | export const firestore = mainFirebase.firestore;
9 |
--------------------------------------------------------------------------------
/hooks/useUser.ts:
--------------------------------------------------------------------------------
1 | import useStore from "../store/useStore";
2 | import User from "../types/user";
3 |
4 | const useUser = () => {
5 | const user: User = useStore((state) => state.user);
6 | return user;
7 | };
8 |
9 | export default useUser;
10 |
--------------------------------------------------------------------------------
/types/storeState.ts:
--------------------------------------------------------------------------------
1 | import User from "./user";
2 |
3 | export default interface State {
4 | user: null | undefined | User;
5 | setUser: (user: any) => any;
6 | isDarkModeActive: boolean;
7 | toggleDarkMode: () => any;
8 | isLoading: boolean;
9 | loaderType: string;
10 | }
11 |
--------------------------------------------------------------------------------
/flows/Billing Flow.md:
--------------------------------------------------------------------------------
1 | ## User Billing Flow
2 |
3 | We'll be using a monthly billing cycle for the user.
4 |
5 | - No matter the user's sign up date, the bill comes due on the last day of the month.
6 | - Any funds not paid for by the user will be added/rolled over to the next month's bill.
7 |
--------------------------------------------------------------------------------
/types/order.ts:
--------------------------------------------------------------------------------
1 | export interface OrderItem {
2 | name: string;
3 | desc?: string;
4 | externalId: string;
5 | pricePerUnit: number;
6 | quantity: number;
7 | currency?: string;
8 | }
9 |
10 | export interface OrderDetails extends OrderItem {
11 | merchant: string;
12 | status: string;
13 | }
14 |
--------------------------------------------------------------------------------
/components/Layout/ContentWrapper.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { Container } from "@chakra-ui/react";
3 |
4 | const ContentWrapper = styled(Container)`
5 | max-width: 1100px;
6 | margin: 0 auto;
7 | padding: var(--standard-spacing);
8 | `;
9 |
10 | export default ContentWrapper;
11 |
--------------------------------------------------------------------------------
/types/transaction.ts:
--------------------------------------------------------------------------------
1 | import type { OrderDetails } from "./order";
2 |
3 | export default interface Transaction {
4 | id?: string;
5 | order: string;
6 | orderDetails: OrderDetails;
7 | merchant: string;
8 | amount: string | number;
9 | user: string;
10 | createdAt: any;
11 | updatedAt: any;
12 | }
13 |
--------------------------------------------------------------------------------
/helpers/verifyIDToken.ts:
--------------------------------------------------------------------------------
1 | import admin from "../firebase/admin";
2 |
3 | const verifyIDToken = async (token: string) => {
4 | try {
5 | const decodedToken = await admin.auth().verifyIdToken(token);
6 | return decodedToken;
7 | } catch (err) {
8 | return null;
9 | }
10 | };
11 |
12 | export default verifyIDToken;
13 |
--------------------------------------------------------------------------------
/helpers/copyText.ts:
--------------------------------------------------------------------------------
1 | const copyText = (text: string) => {
2 | let tempInput = document.createElement("input");
3 | tempInput.value = text;
4 | document.body.appendChild(tempInput);
5 | tempInput.select();
6 | document.execCommand("copy");
7 | document.body.removeChild(tempInput);
8 | };
9 |
10 | export default copyText;
11 |
--------------------------------------------------------------------------------
/store/useStore.ts:
--------------------------------------------------------------------------------
1 | /** Hook to use zustand store in reactive React Components. **/
2 |
3 | import create from "zustand";
4 | import store from "./index";
5 |
6 | import State from "../types/storeState";
7 |
8 | const useStore = create(store);
9 |
10 | export default useStore as (selector: (state: State) => any) => any;
11 |
--------------------------------------------------------------------------------
/firebase/index.ts:
--------------------------------------------------------------------------------
1 | /*
2 | The main firebase configuration file.
3 | */
4 |
5 | import firebase from "firebase/app";
6 |
7 | const config = JSON.parse(process.env.NEXT_PUBLIC_FIREBASE_CONFIG);
8 |
9 | const firebasePrimaryApp = !firebase.apps.length
10 | ? firebase.initializeApp(config)
11 | : firebase.apps[0];
12 |
13 | export default firebasePrimaryApp;
14 |
--------------------------------------------------------------------------------
/firebase/admin.ts:
--------------------------------------------------------------------------------
1 | import admin from "firebase-admin";
2 |
3 | const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT);
4 |
5 | serviceAccount.private_key = serviceAccount.private_key.replace(/\\n/g, "\n");
6 |
7 | if (!admin.apps.length) {
8 | admin.initializeApp({
9 | credential: admin.credential.cert(serviceAccount),
10 | });
11 | }
12 |
13 | export default admin;
14 |
--------------------------------------------------------------------------------
/types/user.ts:
--------------------------------------------------------------------------------
1 | interface User {
2 | uid: string;
3 | email: string;
4 | displayName: string;
5 | providerInfo?: any;
6 | emailVerified?: boolean;
7 | phoneNumber?: string;
8 | photoURL?: string;
9 | disabled?: boolean;
10 | nTransactions?: number;
11 | totalTransactionAmount?: number;
12 | totalAmountRepaid?: number;
13 | createdAt?: string;
14 | lastSignIn?: string;
15 | canCreateMerchants?: boolean;
16 | }
17 |
18 | export default User;
19 |
--------------------------------------------------------------------------------
/components/Layout/Error.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Image, Text } from "@chakra-ui/react";
2 | import styled from "@emotion/styled";
3 |
4 | const ErrorImage = styled(Image)`
5 | max-width: 45vw;
6 | `;
7 |
8 | export default function Error({ errorMessage }) {
9 | return (
10 |
11 |
16 |
17 | {errorMessage}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Dashout
2 |
3 |
4 |
5 |
6 |
7 | A simple project that acts as a template for a buy now pay later service.
8 |
9 | [Read Related Blog post Here](https://blog.devesh.tech/post/lets-build-a-buy-now-pay-later-service).
10 |
11 | **Disclaimer**: Obviously this is a sample app and by no means an attempt to start a business that would require a lot of regulatory checks, this entire project is sandboxed and is just a template, no real money will be involved in this project (Obviously).
12 |
--------------------------------------------------------------------------------
/components/Layout/NoneFound.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Text, Icon } from "@chakra-ui/react";
2 | import { MdFilterList } from "react-icons/md";
3 |
4 | const NoneFound = ({ label = "", icon = undefined }) => (
5 |
15 | )} />
16 |
17 | {label}
18 |
19 |
20 | );
21 |
22 | export default NoneFound;
23 |
--------------------------------------------------------------------------------
/API/transactions.ts:
--------------------------------------------------------------------------------
1 | import db from "../firebase/firestore";
2 |
3 | export const getUserTransactions = async (
4 | userId: string,
5 | startAfter: any,
6 | callback: (errorMessage: string, transactionList: any[]) => any
7 | ) => {
8 | try {
9 | let transactionsRef = db
10 | .collection("transactions")
11 | .where("user", "==", userId);
12 | if (startAfter) transactionsRef = transactionsRef.startAfter(startAfter);
13 | transactionsRef = transactionsRef.orderBy("createdAt", "desc").limit(10);
14 | return callback(null, (await transactionsRef.get()).docs);
15 | } catch (err) {
16 | console.log(err);
17 | return callback(err.message, []);
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/components/Layout/AppContentContainer.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import styled from "@emotion/styled";
3 |
4 | const AppContent = styled.div`
5 | padding-top: 15rem;
6 | `;
7 |
8 | const AppContentContainer = ({ children }) => {
9 | const contentContainerRef = useRef(null);
10 |
11 | useEffect(() => {
12 | const headerElement = document.getElementById("app-header");
13 | if (contentContainerRef.current && headerElement)
14 | contentContainerRef.current.style.paddingTop =
15 | headerElement.offsetHeight + "px";
16 | }, []);
17 |
18 | return {children};
19 | };
20 |
21 | export default AppContentContainer;
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "incremental": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve"
21 | },
22 | "include": [
23 | "next-env.d.ts",
24 | "**/*.ts",
25 | "**/*.tsx"
26 | ],
27 | "exclude": [
28 | "node_modules"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/firebase/authentication.ts:
--------------------------------------------------------------------------------
1 | import firebase from "./index";
2 | import mainFirebase from "firebase/app";
3 | import "firebase/auth";
4 | import Cookie from "js-cookie";
5 |
6 | const auth = firebase.auth();
7 |
8 | // Providers
9 | const googleProvider = new mainFirebase.auth.GoogleAuthProvider();
10 | const githubProvider = new mainFirebase.auth.GithubAuthProvider();
11 |
12 | export default auth;
13 | export const providers = {
14 | googleProvider,
15 | githubProvider,
16 | };
17 |
18 | export const getToken = async (refresh = false) => {
19 | let cookie = Cookie.get("accessToken") || null;
20 | if (!cookie) cookie = (await auth.currentUser?.getIdToken?.(refresh)) || null;
21 | return cookie;
22 | };
23 |
--------------------------------------------------------------------------------
/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalFooter,
8 | ModalBody,
9 | ModalCloseButton,
10 | } from "@chakra-ui/react";
11 |
12 | const ReusableModal = ({
13 | children,
14 | title = "",
15 | isOpen = false,
16 | onClose = () => null,
17 | actionButton = undefined,
18 | }) => (
19 |
20 |
21 |
22 | {title}
23 |
24 | {children}
25 |
26 |
27 |
30 | {actionButton || ""}
31 |
32 |
33 |
34 | );
35 |
36 | export default ReusableModal;
37 |
--------------------------------------------------------------------------------
/store/index.ts:
--------------------------------------------------------------------------------
1 | import create from "zustand/vanilla";
2 | import { persist } from "zustand/middleware";
3 |
4 | import State from "../types/storeState";
5 |
6 | const store = create(
7 | persist(
8 | (set) => ({
9 | // User Auth
10 | user: null,
11 | setUser: (user = null) => set((state) => ({ ...state, user })),
12 | // Loader
13 | isLoading: false,
14 | loaderType: "loader",
15 | setLoading: (isLoading = false, loaderType = "loader") =>
16 | set((state: State) => ({ ...state, isLoading, loaderType })),
17 | // Dark Mode
18 | isDarkModeActive: false,
19 | toggleDarkMode: () =>
20 | set((state: State) => ({
21 | ...state,
22 | isDarkModeActive: !state.isDarkModeActive,
23 | })),
24 | }),
25 | {
26 | name: "dashout-storage",
27 | }
28 | )
29 | );
30 |
31 | export default store;
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dashout",
3 | "private": true,
4 | "scripts": {
5 | "dev": "next dev -p 3004",
6 | "build": "next build",
7 | "start": "next start",
8 | "lint": "next lint"
9 | },
10 | "dependencies": {
11 | "@chakra-ui/react": "^1.7.3",
12 | "@emotion/react": "^11.7.1",
13 | "@emotion/styled": "^11.6.0",
14 | "axios": "^0.24.0",
15 | "firebase": "^8.10.0",
16 | "firebase-admin": "^10.0.2",
17 | "framer-motion": "^5.5.5",
18 | "js-cookie": "^3.0.1",
19 | "next": "^12.0.10",
20 | "postcss": "^8.4.6",
21 | "razorpay": "^2.8.0",
22 | "react": "17.0.2",
23 | "react-dom": "17.0.2",
24 | "react-icons": "^4.3.1",
25 | "uuid": "^8.3.2",
26 | "zustand": "^3.6.8"
27 | },
28 | "devDependencies": {
29 | "@types/node": "^17.0.4",
30 | "@types/react": "^17.0.38",
31 | "typescript": "^4.5.4"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/components/Layout/FullPageLoader.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { Spinner, Skeleton } from "@chakra-ui/react";
3 |
4 | const FullPageLoaderContainer = styled.div`
5 | position: fixed;
6 | display: flex;
7 | align-items: center;
8 | justify-content: center;
9 | text-align: center;
10 | z-index: 1000;
11 | background: var(--white);
12 | bottom: 0;
13 | left: 0;
14 | top: 0;
15 | right: 0;
16 | overflow: hidden;
17 | `;
18 |
19 | const StyledLoaderSkeleton = styled(Skeleton)`
20 | min-height: 100vh;
21 | width: 100vw;
22 | `;
23 |
24 | const FullPageLoader = ({ type = "loader" }) => (
25 |
26 | {type === "loader" ? (
27 |
28 | ) : (
29 |
30 | )}
31 |
32 | );
33 |
34 | export default FullPageLoader;
35 |
--------------------------------------------------------------------------------
/API/ServerSideAPIs/merchants.ts:
--------------------------------------------------------------------------------
1 | import admin from "../../firebase/admin";
2 |
3 | // Create, fetch and manipulate merchants here.
4 | export const getMerchant = async (clientId: string, clientSecret: string) => {
5 | try {
6 | const merchant = (
7 | await admin
8 | .firestore()
9 | .collection("merchants")
10 | .where("clientId", "==", clientId)
11 | .where("clientSecret", "==", clientSecret)
12 | .limit(1)
13 | .get()
14 | ).docs[0];
15 | return merchant || null;
16 | } catch (err) {
17 | console.log(err);
18 | return null;
19 | }
20 | };
21 |
22 | export const getMerchantById = async (merchantId: string) => {
23 | try {
24 | const merchant = (
25 | await admin.firestore().collection("merchants").doc(merchantId).get()
26 | ).data();
27 | return merchant || null;
28 | } catch (err) {
29 | console.log(err);
30 | return null;
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/hooks/useFirestore.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import db from "../firebase/firestore";
3 |
4 | const useFirestore = (docPath) => {
5 | let [docData, setDocData] = useState({
6 | fetching: true,
7 | error: null,
8 | data: null,
9 | });
10 | let subscriptionRef = useRef(null);
11 |
12 | useEffect(() => {
13 | if (!docPath) return;
14 |
15 | let ref = db.doc(docPath);
16 | subscriptionRef.current = ref.onSnapshot(
17 | (doc) => {
18 | const newDocData = { fetching: false, error: null, data: doc.data() };
19 | setDocData(newDocData);
20 | },
21 | (error) => setDocData({ fetching: false, error, data: null })
22 | );
23 |
24 | return () => {
25 | if (subscriptionRef.current instanceof Function)
26 | subscriptionRef.current(); // Unsubscribe from document's real-time updates.
27 | };
28 | }, [docPath]);
29 |
30 | return docData;
31 | };
32 |
33 | export default useFirestore;
34 |
--------------------------------------------------------------------------------
/components/Authentication/LoginModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Modal,
3 | ModalOverlay,
4 | ModalContent,
5 | ModalHeader,
6 | ModalFooter,
7 | ModalBody,
8 | Button,
9 | } from "@chakra-ui/react";
10 | import styled from "@emotion/styled";
11 |
12 | import Authentication from "./index";
13 |
14 | const LoginModalBody = styled(ModalBody)`
15 | text-align: center;
16 | padding: calc(2 * var(--standard-spacing));
17 | padding-bottom: var(--standard-spacing);
18 | `;
19 |
20 | const LoginModal = ({ isOpen, closeModal }) => {
21 | return (
22 |
23 |
24 |
25 |
26 | Login
27 |
28 |
29 |
30 |
31 |
32 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | export default LoginModal;
42 |
--------------------------------------------------------------------------------
/helpers/toasts.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createStandaloneToast,
3 | ToastPositionWithLogical,
4 | } from "@chakra-ui/react";
5 |
6 | const toastOps = (
7 | title: string,
8 | desc?: string,
9 | status: "info" | "warning" | "success" | "error" = "info"
10 | ) => ({
11 | title,
12 | description: desc || "",
13 | status: status || "info",
14 | duration: 4500,
15 | isClosable: true,
16 | position: "bottom-left" as ToastPositionWithLogical,
17 | });
18 |
19 | const toasts = {
20 | info: (message: string, desc?: string): any => {
21 | const toast = createStandaloneToast();
22 | return toast(toastOps(message, desc));
23 | },
24 | success: (successMessage: string = "", desc?: string): any => {
25 | const toast = createStandaloneToast();
26 | return toast(toastOps(successMessage, desc, "success"));
27 | },
28 | error: (errorMessage: string = "", desc?: string): any => {
29 | const toast = createStandaloneToast();
30 | return toast(toastOps(errorMessage, desc, "error"));
31 | },
32 | warn: (warningMessage: string = "", desc?: string): any => {
33 | const toast = createStandaloneToast();
34 | return toast(toastOps(warningMessage, desc, "warning"));
35 | },
36 | };
37 |
38 | export default toasts;
39 |
--------------------------------------------------------------------------------
/components/Layout/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Common App Layout
3 | */
4 |
5 | import dynamic from "next/dynamic";
6 | import { ChakraProvider } from "@chakra-ui/react";
7 |
8 | import GlobalStyles from "./GlobalStyles";
9 | import Header from "./Header";
10 | import AppContentContainer from "./AppContentContainer";
11 |
12 | import useStore from "../../store/useStore";
13 |
14 | const LoginModal = dynamic(() => import("../Authentication/LoginModal"));
15 |
16 | const AppLayout = ({
17 | children,
18 | logoutUser = () => null,
19 | showLoginModal = false,
20 | openLoginModal = () => null,
21 | closeLoginModal = () => null,
22 | }) => {
23 | const isDarkModeActive = useStore((store) => store.isDarkModeActive);
24 |
25 | // User Authentication
26 | const stateUser = useStore((store) => store.user);
27 |
28 | return (
29 |
30 |
31 |
32 | {!stateUser && (
33 |
34 | )}
35 | {children}
36 |
37 | );
38 | };
39 |
40 | export default AppLayout;
41 |
--------------------------------------------------------------------------------
/components/FormControl.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | FormControl as ChakraFormControl,
3 | FormLabel,
4 | FormErrorMessage,
5 | FormHelperText,
6 | Input,
7 | } from "@chakra-ui/react";
8 | import { FormEvent } from "react";
9 |
10 | interface FormControlProps {
11 | id: string;
12 | type?: string;
13 | helperText?: string;
14 | label: string;
15 | isRequired?: boolean;
16 | isInvalid?: boolean;
17 | isLoading?: boolean;
18 | name: string;
19 | placeholder?: string;
20 | // For controlled form-control inputs
21 | onChange?: (e: FormEvent) => any;
22 | value?: string;
23 | }
24 |
25 | const FormControl = ({
26 | id,
27 | type = "text",
28 | label,
29 | placeholder = "",
30 | helperText,
31 | isRequired = false,
32 | isInvalid = false,
33 | isLoading = false,
34 | onChange = undefined,
35 | value = undefined,
36 | name,
37 | }: FormControlProps) => (
38 |
39 | {label}
40 |
49 | {helperText && {helperText}}
50 |
51 | );
52 |
53 | export default FormControl;
54 |
--------------------------------------------------------------------------------
/helpers/setupProtectedRoute.ts:
--------------------------------------------------------------------------------
1 | import Router from "next/router";
2 | import Cookie from "js-cookie";
3 |
4 | export default function setupProtectedRoute(callback?: (ctx: any) => any) {
5 | return async function getInitialProps(ctx) {
6 | const { req, res, asPath } = ctx;
7 | const isServer = typeof window === "undefined";
8 |
9 | const redirectTo = `/`; // Use this to log user in later using a login page redirect.
10 |
11 | if (isServer) {
12 | if (req?.cookies?.accessToken) {
13 | // Allowed
14 | if (callback) {
15 | const valuesToReturnAsInitialProps = await callback(ctx);
16 | return valuesToReturnAsInitialProps;
17 | }
18 | return { protected: true };
19 | }
20 | // Redirect to home page.
21 | res?.writeHead?.(302, {
22 | Location: redirectTo,
23 | });
24 | res?.end?.();
25 | } else {
26 | if (Cookie.get("accessToken")) {
27 | // Allowed
28 | if (callback) {
29 | const valuesToReturnAsInitialProps = await callback(ctx);
30 | return valuesToReturnAsInitialProps;
31 | }
32 | return { protected: true };
33 | }
34 | Router.push(redirectTo);
35 | return { protected: true };
36 | }
37 | return { protected: true };
38 | };
39 | }
40 |
41 | /**
42 | * Usage:
43 | *
44 | * PageComponent.getInitialProps = setupProtectedRoute();
45 | */
46 |
--------------------------------------------------------------------------------
/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { Button as ChakraButton } from "@chakra-ui/react";
2 | import styled from "@emotion/styled";
3 |
4 | const Button = styled(ChakraButton)`
5 | padding: calc(
6 | ${({ $paddingMultiplier }) => $paddingMultiplier || "1.25"} *
7 | var(--standard-spacing)
8 | );
9 | font-size: var(--standard-spacing);
10 | color: var(--white);
11 | width: fit-content;
12 | min-width: ${({ $noMinWidth }) =>
13 | !$noMinWidth ? "calc(12.5 * var(--standard-spacing))" : ""};
14 | background: var(--primary);
15 | text-transform: capitalize;
16 | font-weight: 500;
17 | letter-spacing: calc(0.075 * var(--standard-spacing));
18 |
19 | ${({ $variant }) =>
20 | $variant === "hollow" &&
21 | "border: calc(0.075 * var(--standard-spacing)) solid var(--primary);"}
22 | ${({ $variant }) => $variant === "hollow" && "background: var(--white);"}
23 | ${({ $variant }) => $variant === "hollow" && "color: var(--primary);"}
24 | ${({ $variant }) =>
25 | $variant === "hollow"
26 | ? `
27 | &:hover,
28 | &:focus,
29 | &:active {
30 | background: var(--white);
31 | color: var(--primary);
32 | border: calc(0.075 * var(--standard-spacing)) solid var(--primary);
33 | }`
34 | : `
35 | &:hover,
36 | &:focus,
37 | &:active {
38 | background: var(--primary);
39 | }`}
40 | `;
41 |
42 | export default Button;
43 |
--------------------------------------------------------------------------------
/API/repayments.ts:
--------------------------------------------------------------------------------
1 | import { getToken } from "../firebase/authentication";
2 | import request from "../helpers/request";
3 |
4 | export const createRepaymentTransaction = async (
5 | callback: (errorMessage: string | null, orderInfo?: any) => any
6 | ) => {
7 | try {
8 | request({
9 | endpoint: "/api/createBillSettlementTransaction",
10 | options: { headers: { authorization: await getToken() } },
11 | requestType: "post",
12 | callback: (error, response) => {
13 | if (error) return callback(error);
14 | return callback(null, response);
15 | },
16 | });
17 | } catch (err) {
18 | console.log(err);
19 | return callback(err.message);
20 | }
21 | };
22 |
23 | interface RazorpayPaymentResponse {
24 | razorpay_payment_id: string;
25 | razorpay_order_id: string;
26 | razorpay_signature: string;
27 | }
28 |
29 | export const verifyRepayment = async (
30 | razorpayResponse: RazorpayPaymentResponse,
31 | callback: (error?: string) => any
32 | ) => {
33 | request({
34 | endpoint: "/api/settleUserBill",
35 | data: {
36 | razorpay_payment_id: razorpayResponse.razorpay_payment_id,
37 | razorpay_order_id: razorpayResponse.razorpay_order_id,
38 | razorpay_signature: razorpayResponse.razorpay_signature,
39 | status: "successful",
40 | },
41 | options: { headers: { authorization: await getToken() } },
42 | requestType: "post",
43 | callback,
44 | });
45 | };
46 |
--------------------------------------------------------------------------------
/API/auth.ts:
--------------------------------------------------------------------------------
1 | import auth, { providers } from "../firebase/authentication";
2 | import db, { firestore } from "../firebase/firestore";
3 | import User from "../types/user";
4 |
5 | export const loginWithGoogle = async (
6 | callback: (errorMessage: string | null) => any
7 | ) => {
8 | try {
9 | if (!auth.currentUser) {
10 | await auth.signInWithPopup(providers.googleProvider);
11 | return callback(null);
12 | } else return callback("User already signed in.");
13 | } catch (err) {
14 | console.log(err);
15 | return callback(err.message);
16 | }
17 | };
18 |
19 | export const loginWithGithub = async (
20 | callback: (errorMessage: string | null) => any
21 | ) => {
22 | try {
23 | if (!auth.currentUser) {
24 | await auth.signInWithPopup(providers.githubProvider);
25 | return callback(null);
26 | } else return callback("User already signed in.");
27 | } catch (err) {
28 | console.log(err);
29 | return callback(err.message);
30 | }
31 | };
32 |
33 | export const saveUserProfileToFirestore = async (
34 | userId: string,
35 | userInfo: User,
36 | callback: (errorMessage: string | null, userDataFromFirestore?: any) => any
37 | ) => {
38 | try {
39 | const userRef = db.collection("users").doc(userId);
40 | await userRef.set(
41 | { ...userInfo, updatedAt: firestore.FieldValue.serverTimestamp() },
42 | { merge: true }
43 | );
44 | return callback(null, (await userRef.get()).data());
45 | } catch (err) {
46 | console.log(err);
47 | return callback(err.message);
48 | }
49 | };
50 |
--------------------------------------------------------------------------------
/components/Merchants/MerchantCard.tsx:
--------------------------------------------------------------------------------
1 | import { Box, HStack, IconButton, Text } from "@chakra-ui/react";
2 | import { MdOutlineContentCopy } from "react-icons/md";
3 |
4 | import copyText from "../../helpers/copyText";
5 | import toasts from "../../helpers/toasts";
6 |
7 | const MerchantCard = ({ merchant }) => {
8 | return (
9 |
16 |
17 |
18 | {merchant.merchantName}
19 |
20 | ₹{Number((merchant?.amountProcessed || 0) / 100).toFixed(2) || 0}
21 |
22 |
23 | {
29 | copyText(
30 | JSON.stringify(
31 | {
32 | clientId: merchant.clientId,
33 | clientSecret: merchant.clientSecret,
34 | },
35 | null,
36 | 4
37 | )
38 | );
39 | toasts.info("Copied Client Id and Secret to Clipboard");
40 | }}
41 | >
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | export default MerchantCard;
50 |
--------------------------------------------------------------------------------
/components/Authentication/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "@chakra-ui/react";
3 |
4 | import { FcGoogle } from "react-icons/fc";
5 | import { FaGithub } from "react-icons/fa";
6 |
7 | import { loginWithGoogle, loginWithGithub } from "../../API/auth";
8 | import toasts from "../../helpers/toasts";
9 |
10 | const Authentication = () => {
11 | const [isLoggingIn, setIsLoggingIn] = useState(false);
12 |
13 | const signInUser = (mode = "google") => {
14 | // Use 'mode' in the future to distinguish between multiple OAuth Based login modes.
15 | setIsLoggingIn(true);
16 | const callback = (err) => {
17 | setIsLoggingIn(false);
18 | if (err) return toasts.error(err);
19 | };
20 | if (mode === "google") loginWithGoogle(callback);
21 | else if (mode === "google") loginWithGithub(callback);
22 | };
23 |
24 | return (
25 | <>
26 | }
32 | onClick={() => signInUser("google")}
33 | size="lg"
34 | >
35 | Sign In With Google
36 |
37 | }
43 | onClick={() => signInUser("github")}
44 | size="lg"
45 | marginTop="15px"
46 | >
47 | Sign In With Github
48 |
49 | >
50 | );
51 | };
52 |
53 | export default Authentication;
54 |
--------------------------------------------------------------------------------
/pages/api/getOrderDetails.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import { getMerchantById } from "../../API/ServerSideAPIs/merchants";
4 | import { getOrder } from "../../API/ServerSideAPIs/orders";
5 |
6 | import verifyIDToken from "../../helpers/verifyIDToken";
7 |
8 | export default async function getOrderAndMerchant(
9 | req: NextApiRequest,
10 | res: NextApiResponse
11 | ) {
12 | const error = (status: number, message: string) =>
13 | res.status(status).json({
14 | error: message,
15 | message,
16 | });
17 |
18 | try {
19 | const { authorization } = req.headers;
20 | const { orderId } = req.body as { orderId: string };
21 |
22 | if (!orderId) return error(403, "Invalid credentials.");
23 |
24 | const decodedToken = await verifyIDToken(authorization);
25 | if (!decodedToken || !decodedToken.uid)
26 | return error(403, "Invalid credentials");
27 |
28 | const order = await getOrder(orderId);
29 | if (!order || !order.merchant) return error(404, "Order Not Found");
30 |
31 | const merchant = await getMerchantById(order.merchant);
32 | if (!merchant) return error(404, "Merchant Not Found");
33 |
34 | return res.status(200).json({
35 | message: "Fetched Order details successfully",
36 | order,
37 | merchant: {
38 | merchantName: merchant.merchantName,
39 | photoURL: merchant.photoURL,
40 | errorRedirect: merchant.errorRedirect,
41 | successRedirect: merchant.successRedirect,
42 | },
43 | });
44 | } catch (err) {
45 | return error(500, err.message);
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/pages/api/createOrder.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import type { OrderItem } from "../../types/order";
3 |
4 | import { getMerchant } from "../../API/ServerSideAPIs/merchants";
5 | import { createNewOrder } from "../../API/ServerSideAPIs/orders";
6 |
7 | export default async function createOrder(
8 | req: NextApiRequest,
9 | res: NextApiResponse
10 | ) {
11 | const error = (status: number, message: string) =>
12 | res.status(status).json({
13 | error: message,
14 | message,
15 | });
16 |
17 | try {
18 | const { authorization } = req.headers;
19 | const { item } = req.body as { item: OrderItem };
20 |
21 | const clientId = authorization.split(":")[0];
22 | const clientSecret = authorization.split(":")[1];
23 |
24 | if (!clientId || !clientSecret) return error(403, "Invalid credentials.");
25 | if (!item || !item.name || !item.quantity || !item.pricePerUnit)
26 | return error(400, "Incomplete Information");
27 |
28 | const merchant = await getMerchant(clientId, clientSecret);
29 | if (!merchant) return error(403, "Invalid credentials");
30 |
31 | await createNewOrder(merchant.id, item, (err, createdOrder) => {
32 | if (err) return error(500, "Failed to create order.");
33 | else {
34 | createdOrder.createdAt = createdOrder.createdAt.toDate();
35 | createdOrder.updatedAt = createdOrder.updatedAt.toDate();
36 | return res.status(201).json({
37 | message: "Created order successfully.",
38 | order: createdOrder,
39 | });
40 | }
41 | });
42 | } catch (err) {
43 | return error(500, err.message);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/components/Layout/GlobalStyles.tsx:
--------------------------------------------------------------------------------
1 | import { Global, css } from "@emotion/react";
2 |
3 | const GlobalStyles = ({ darkMode = false }) => (
4 |
79 | );
80 |
81 | export default GlobalStyles;
82 |
--------------------------------------------------------------------------------
/components/TransactionTile.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Box, Stat, StatNumber, Text, VStack, Icon } from "@chakra-ui/react";
3 | import type Transaction from "../types/transaction";
4 |
5 | import { FaClock } from "react-icons/fa";
6 |
7 | interface TransactionTileProps {
8 | transaction: Transaction;
9 | }
10 |
11 | const TransactionTile = ({ transaction }: TransactionTileProps) => {
12 | return transaction ? (
13 |
59 | ) : (
60 | <>>
61 | );
62 | };
63 |
64 | export default TransactionTile;
65 |
--------------------------------------------------------------------------------
/helpers/request.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | interface RequestParams {
4 | endpoint: string;
5 | data?: any;
6 | options?: any;
7 | requestType?: "get" | "post" | "patch" | "delete" | "put";
8 | callback?: (error?: any, response?: any) => any;
9 | tries?: number;
10 | prevError?: any;
11 | }
12 |
13 | const request = async ({
14 | endpoint,
15 | data,
16 | options,
17 | requestType,
18 | callback,
19 | // For retry scenarios
20 | tries = 3,
21 | prevError = null,
22 | }: RequestParams) => {
23 | try {
24 | if (!tries) throw new Error(prevError);
25 | const response = await axios[requestType](endpoint, data, options);
26 | if (callback && typeof callback === "function")
27 | return callback(null, response.data);
28 | else return response.data;
29 | } catch (err) {
30 | console.log(err);
31 |
32 | // Request failed, check if retry has been enabled and if tries are left.
33 | const triesLeft = tries - 1;
34 | if (
35 | ((!err?.request && !err?.response) || // Couldn't make a request in the first place.
36 | (err?.request && !err?.response) || // Made request. Didn't get any response, i.e: something like CORS
37 | (err?.response?.status && Number(err?.response?.status) >= 500)) && // All other errors mean the user is doing something wrong, hence no retries
38 | Number(triesLeft) > 0
39 | ) {
40 | console.log(`Retrying ${endpoint}, tries left: `, triesLeft);
41 | return request({
42 | endpoint,
43 | data,
44 | options,
45 | requestType,
46 | callback,
47 | tries: triesLeft,
48 | prevError: err,
49 | });
50 | } else {
51 | if (callback && typeof callback === "function") {
52 | return callback(
53 | err?.response?.data?.error ||
54 | err?.message ||
55 | "Something went wrong. Please try again later.",
56 | null
57 | );
58 | } else return null;
59 | }
60 | }
61 | };
62 |
63 | export default request;
64 |
--------------------------------------------------------------------------------
/pages/api/declineOrder.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import type { NextApiRequest, NextApiResponse } from "next";
3 |
4 | import { getMerchantById } from "../../API/ServerSideAPIs/merchants";
5 | import { declineOrderForUser, getOrder } from "../../API/ServerSideAPIs/orders";
6 |
7 | import verifyIDToken from "../../helpers/verifyIDToken";
8 |
9 | export default async function declineOrder(
10 | req: NextApiRequest,
11 | res: NextApiResponse
12 | ) {
13 | const error = (status: number, message: string) =>
14 | res.status(status).json({
15 | error: message,
16 | message,
17 | });
18 |
19 | try {
20 | const { authorization } = req.headers;
21 | const { orderId } = req.body as { orderId: string };
22 |
23 | if (!orderId) return error(403, "Invalid credentials.");
24 |
25 | const decodedToken = await verifyIDToken(authorization);
26 | if (!decodedToken || !decodedToken.uid)
27 | return error(403, "Invalid credentials");
28 |
29 | const order = await getOrder(orderId);
30 | if (!order || !order.merchant) return error(404, "Order Not Found");
31 |
32 | const merchant = await getMerchantById(order.merchant);
33 | if (!merchant) return error(404, "Merchant Not Found");
34 |
35 | if (order.status === "fulfilled")
36 | return error(400, "Order already fulfilled");
37 | if (order.status === "declined")
38 | return error(400, "Order already declined");
39 |
40 | await declineOrderForUser(orderId, decodedToken.uid, (err) => {
41 | if (err) return error(500, "Order could not be declined");
42 | if (merchant.webhookURL)
43 | axios.post(merchant.webhookURL, {
44 | orderId,
45 | status: "declined",
46 | type: "order_declined",
47 | });
48 | return res.status(200).json({
49 | message: "Declined Order Successfully",
50 | redirectTo: merchant.errorRedirect,
51 | });
52 | });
53 | } catch (err) {
54 | return error(500, err.message);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/firebase/firestore-security-rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service cloud.firestore {
3 | match /databases/{database}/documents {
4 | function isSignedIn(){
5 | return request.auth != null &&
6 | request.auth.uid != null &&
7 | request.auth.token != null;
8 | }
9 |
10 | function getUnchangedKeys(){
11 | return request.resource.data.diff(resource.data).unchangedKeys();
12 | }
13 |
14 | function getChangedKeys(){
15 | return request.resource.data.diff(resource.data).changedKeys();
16 | }
17 |
18 | match /users/{userId} {
19 | allow read: if isSignedIn();
20 | allow create: if isSignedIn() &&
21 | request.resource.data.uid == userId &&
22 | request.auth.uid == userId;
23 | allow update: if isSignedIn() &&
24 | userId == request.auth.uid &&
25 | !getChangedKeys().hasAny([
26 | "nTransactions",
27 | "totalTransactionAmount",
28 | "totalAmountRepaid",
29 | "disabled"
30 | ])
31 | }
32 |
33 | match /transactions/{transactionId} {
34 | allow read: if isSignedIn() && resource.data.user == request.auth.uid;
35 | // User can't create or update transactions.
36 | }
37 |
38 | match /merchants/{merchantId} {
39 | allow get: if isSignedIn();
40 | allow read: if isSignedIn() &&
41 | (request.auth.uid in resource.data.members ||
42 | resource.data.createdBy == request.auth.uid);
43 | allow create: if isSignedIn() &&
44 | request.resource.data.members.toSet().hasAll([request.auth.uid]) &&
45 | request.resource.data.createdBy == request.auth.uid;
46 | allow update: if isSignedIn() &&
47 | (resource.data.members.toSet().hasAll([request.auth.uid]) ||
48 | resource.data.createdBy == request.auth.uid) &&
49 | getUnchangedKeys().hasAll(["clientId", "clientSecret"]);
50 | }
51 |
52 | match /userbillsettlements/{settlementOrderId} {
53 | allow get: if true;
54 | }
55 | }
56 | }
--------------------------------------------------------------------------------
/pages/api/confirmOrder.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import type { NextApiRequest, NextApiResponse } from "next";
3 |
4 | import { getMerchantById } from "../../API/ServerSideAPIs/merchants";
5 | import { getOrder, confirmOrderForUser } from "../../API/ServerSideAPIs/orders";
6 |
7 | import verifyIDToken from "../../helpers/verifyIDToken";
8 |
9 | import { OrderDetails } from "../../types/order";
10 |
11 | export default async function confirmOrder(
12 | req: NextApiRequest,
13 | res: NextApiResponse
14 | ) {
15 | const error = (status: number, message: string) =>
16 | res.status(status).json({
17 | error: message,
18 | message,
19 | });
20 |
21 | try {
22 | const { authorization } = req.headers;
23 | const { orderId } = req.body as { orderId: string };
24 |
25 | if (!orderId) return error(403, "Invalid credentials.");
26 |
27 | const decodedToken = await verifyIDToken(authorization);
28 | if (!decodedToken || !decodedToken.uid)
29 | return error(403, "Invalid credentials");
30 |
31 | const order = (await getOrder(orderId)) as OrderDetails;
32 | if (!order || !order.merchant) return error(404, "Order Not Found");
33 |
34 | const merchant = await getMerchantById(order.merchant);
35 | if (!merchant) return error(404, "Merchant Not Found");
36 |
37 | if (order.status === "fulfilled")
38 | return error(400, "Order already fulfilled");
39 | if (order.status === "declined")
40 | return error(400, "Order already declined");
41 |
42 | // Do transaction related processing here.
43 | await confirmOrderForUser(orderId, order, decodedToken.uid, (err) => {
44 | if (err) return error(500, "Order could not be confirmed");
45 | // Notify the merchant
46 | if (merchant.webhookURL)
47 | axios.post(merchant.webhookURL, {
48 | orderId,
49 | status: "confirmed",
50 | type: "order_confirmed",
51 | });
52 | return res.status(200).json({
53 | message: "Confirmed Order Successfully",
54 | redirectTo: merchant.errorRedirect,
55 | });
56 | });
57 | } catch (err) {
58 | return error(500, err.message);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/pages/api/createBillSettlementTransaction.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import User from "../../types/user";
4 |
5 | import razorpay from "../../helpers/razorpay";
6 | import admin from "../../firebase/admin";
7 | import verifyIDToken from "../../helpers/verifyIDToken";
8 |
9 | export default async function createWalletAddMoneyTransaction(
10 | req: NextApiRequest,
11 | res: NextApiResponse
12 | ) {
13 | const error = (status: number, message: string) =>
14 | res.status(status).json({
15 | error: message,
16 | message,
17 | });
18 |
19 | try {
20 | const { authorization } = req.headers;
21 |
22 | if (!authorization) return error(400, "Invalid credentials.");
23 |
24 | // Get user details from token.
25 | const decodedToken = await verifyIDToken(authorization);
26 | if (!decodedToken) return error(401, "Unauthorized");
27 |
28 | const batch = admin.firestore().batch();
29 | const userRef = admin.firestore().collection("users").doc(decodedToken.uid);
30 |
31 | const userData = (await userRef.get()).data() as User;
32 | if (!userData) return error(404, "User info not found.");
33 |
34 | const amount =
35 | Number(userData.totalTransactionAmount || 0) -
36 | Number(userData.totalAmountRepaid || 0);
37 |
38 | if (!amount) return error(400, "No billable amount pending");
39 |
40 | const order = await razorpay.orders.create({
41 | amount,
42 | currency: "INR",
43 | notes: { user: decodedToken.uid },
44 | });
45 |
46 | if (order) {
47 | const userBillSettlementRef = admin
48 | .firestore()
49 | .collection("userbillsettlements")
50 | .doc(order.id);
51 | batch.set(userBillSettlementRef, {
52 | ...order,
53 | user: decodedToken.uid,
54 | amount,
55 | createdAt: new Date(),
56 | updatedAt: new Date(),
57 | });
58 | await batch.commit();
59 | return res
60 | .status(201)
61 | .json({ message: "Created Settlement Order Successfully", order });
62 | }
63 | return error(500, "Payment could not be created.");
64 | } catch (err) {
65 | console.log(err);
66 | return error(500, err.message);
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/pages/profile/[userId].tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import styled from "@emotion/styled";
3 |
4 | import {
5 | Avatar,
6 | Heading,
7 | Text,
8 | Stat,
9 | StatNumber,
10 | StatHelpText,
11 | } from "@chakra-ui/react";
12 | import { MdEmail, MdPhone } from "react-icons/md";
13 |
14 | import setupProtectedRoute from "../../helpers/setupProtectedRoute";
15 | import ContentWrapper from "../../components/Layout/ContentWrapper";
16 |
17 | import useUser from "../../hooks/useUser";
18 |
19 | const ProfileContentWrapper = styled(ContentWrapper)`
20 | padding: var(--standard-spacing);
21 | padding-top: calc(6 * var(--standard-spacing));
22 | text-align: center;
23 | `;
24 |
25 | const UserProfile = () => {
26 | const user = useUser();
27 |
28 | return (
29 | <>
30 |
31 | Dashout - User Profile
32 |
33 |
34 |
40 |
41 | {user?.displayName || "Unnamed"}
42 |
43 |
44 | {user?.nTransactions || 0}
45 | Number Of Transactions
46 |
47 |
54 |
55 | {user?.phoneNumber || "-"}
56 |
57 |
64 |
65 | {user?.email || "-"}
66 |
67 |
68 |
69 | User Since {new Date(user?.createdAt || new Date()).toDateString()}
70 |
71 |
72 | >
73 | );
74 | };
75 |
76 | UserProfile.getInitialProps = setupProtectedRoute();
77 |
78 | export default UserProfile;
79 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import { useEffect } from "react";
3 | import { useDisclosure } from "@chakra-ui/react";
4 | import Cookie from "js-cookie";
5 |
6 | import useStore from "../store/useStore";
7 |
8 | import type User from "../types/user";
9 | import auth, { getToken } from "../firebase/authentication";
10 | import { saveUserProfileToFirestore } from "../API/auth";
11 |
12 | import AppLayout from "../components/Layout";
13 |
14 | const AppWrapper = ({ Component, pageProps }) => {
15 | const user = useStore((store) => store.user);
16 | const setUserInState = useStore((store) => store.setUser);
17 | const {
18 | isOpen: showLoginModal,
19 | onOpen: openLoginModal,
20 | onClose: closeLoginModal,
21 | } = useDisclosure();
22 |
23 | const logoutUser = () => {
24 | Cookie.remove("accessToken");
25 | setUserInState(null);
26 | if (auth.currentUser) auth.signOut();
27 | };
28 |
29 | useEffect(() => {
30 | auth.onAuthStateChanged(async (user) => {
31 | if (user) {
32 | // User is signed in.
33 | let userToSave: User = {
34 | displayName: user.displayName,
35 | email: user.email,
36 | emailVerified: user.emailVerified,
37 | photoURL: user.photoURL,
38 | phoneNumber: user.phoneNumber,
39 | uid: user.uid,
40 | providerInfo: JSON.parse(JSON.stringify(user.providerData)),
41 | createdAt: user.metadata.creationTime,
42 | lastSignIn: user.metadata.lastSignInTime,
43 | };
44 | const accessToken = await getToken(false);
45 | saveUserProfileToFirestore(
46 | user.uid,
47 | userToSave,
48 | (_, dataFromFirestore) => {
49 | if (dataFromFirestore) {
50 | Cookie.set("accessToken", accessToken, { expires: 365 });
51 | setUserInState(dataFromFirestore);
52 | } else logoutUser();
53 | }
54 | );
55 | } else logoutUser();
56 | });
57 | }, []);
58 |
59 | return (
60 | <>
61 |
62 |
63 |
64 |
70 |
77 |
78 | >
79 | );
80 | };
81 |
82 | export default AppWrapper;
83 |
--------------------------------------------------------------------------------
/API/merchants.ts:
--------------------------------------------------------------------------------
1 | import db, { firestore } from "../firebase/firestore";
2 | import { v4 as uuid } from "uuid";
3 |
4 | export const getMerchantsForUser = async (
5 | userId: string,
6 | callback: (errorMessage: string, merchantsList: any[]) => any
7 | ) => {
8 | try {
9 | let merchantsRef = db
10 | .collection("merchants")
11 | .where("members", "array-contains", userId)
12 | .orderBy("createdAt", "desc");
13 | return callback(null, (await merchantsRef.get()).docs);
14 | } catch (err) {
15 | console.log(err);
16 | return callback(err.message, []);
17 | }
18 | };
19 |
20 | export const createMerchantForUser = async (
21 | userId: string,
22 | merchantDetails: any,
23 | callback: (errorMessage: string, merchantInfo?: any) => any
24 | ) => {
25 | try {
26 | const merchantRef = db.collection("merchants").doc();
27 | const userRef = db.collection("users").doc(userId);
28 | const batch = db.batch();
29 |
30 | batch.set(merchantRef, {
31 | ...merchantDetails,
32 | createdAt: firestore.FieldValue.serverTimestamp(),
33 | updatedAt: firestore.FieldValue.serverTimestamp(),
34 | clientId: uuid(),
35 | clientSecret: uuid(),
36 | nMembers: 1,
37 | members: [userId],
38 | createdBy: userId,
39 | });
40 | batch.update(userRef, {
41 | nMerchants: firestore.FieldValue.increment(1),
42 | updatedAt: firestore.FieldValue.serverTimestamp(),
43 | merchants: firestore.FieldValue.arrayUnion(merchantRef.id),
44 | });
45 |
46 | await batch.commit();
47 |
48 | return callback(null, (await merchantRef.get()).data());
49 | } catch (err) {
50 | console.log(err);
51 | return callback(err.message, null);
52 | }
53 | };
54 |
55 | export const addUserToMerchant = async (
56 | userId: string,
57 | merchantId: string,
58 | callback: (errorMessage: string, updatedMerchantInfo?: any) => any
59 | ) => {
60 | try {
61 | const merchantRef = db.collection("merchants").doc(merchantId);
62 | const userRef = db.collection("users").doc(userId);
63 | const batch = db.batch();
64 |
65 | batch.set(merchantRef, {
66 | updatedAt: firestore.FieldValue.serverTimestamp(),
67 | nMembers: firestore.FieldValue.increment(1),
68 | members: firestore.FieldValue.arrayUnion(userId),
69 | });
70 | batch.update(userRef, {
71 | nMerchants: firestore.FieldValue.increment(1),
72 | updatedAt: firestore.FieldValue.serverTimestamp(),
73 | merchants: firestore.FieldValue.arrayUnion(merchantRef.id),
74 | });
75 |
76 | await batch.commit();
77 |
78 | return callback(null, (await merchantRef.get()).data());
79 | } catch (err) {
80 | console.log(err);
81 | return callback(err.message, null);
82 | }
83 | };
84 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Link from "next/link";
3 | import { Box, Image, Stack, Text, Heading, HStack } from "@chakra-ui/react";
4 | import styled from "@emotion/styled";
5 |
6 | import { MdOutlineArrowForward, MdOutlineShowChart } from "react-icons/md";
7 | import { FaUserCircle } from "react-icons/fa";
8 |
9 | import Button from "../components/Button";
10 |
11 | const HomePageHero = styled(Stack)`
12 | @media (min-width: 769px) {
13 | flex-flow: row;
14 | max-height: 90vh;
15 | overflow: hidden;
16 | }
17 |
18 | .content {
19 | width: 80%;
20 | display: flex;
21 | justify-content: center;
22 | flex-flow: column;
23 | padding: var(--standard-spacing);
24 | max-width: 500px;
25 | margin: 0 auto;
26 |
27 | @media (max-width: 768px) {
28 | padding: calc(4.5 * var(--standard-spacing)) var(--standard-spacing);
29 | }
30 | }
31 |
32 | .heroimage {
33 | object-fit: cover;
34 | max-height: 100%;
35 | max-width: 100%;
36 | min-width: 100%;
37 | object-position: bottom;
38 |
39 | &-container {
40 | max-height: 50vh;
41 | overflow: hidden;
42 |
43 | @media (min-width: 769px) {
44 | width: 45%;
45 | min-height: 135vh;
46 | margin: 0;
47 | transform: rotate(15deg) translateX(5%) translateY(-10%);
48 | }
49 | }
50 | }
51 | `;
52 |
53 | const HomePage = ({ openLoginModal, isLoggedIn, user }) => (
54 | <>
55 |
56 | Dashout - Buy Now Pay Later
57 |
58 |
59 |
60 |
61 | Dash Out.
62 |
63 | At Checkout!
64 |
65 |
66 | With 0 Fees. 0 Interest.
67 |
68 | It truly is the next gen of shopping experience.
69 |
70 |
71 | {isLoggedIn ? (
72 | <>
73 |
74 |
75 | }>
76 | Dashboard
77 |
78 |
79 |
80 |
81 |
82 | }
85 | onClick={openLoginModal}
86 | >
87 | Profile
88 |
89 |
90 |
91 | >
92 | ) : (
93 | <>
94 | }
96 | onClick={openLoginModal}
97 | >
98 | Check It Out
99 |
100 | }
103 | onClick={openLoginModal}
104 | >
105 | Become A Merchant
106 |
107 | >
108 | )}
109 |
110 |
111 |
112 |
118 |
119 |
120 | >
121 | );
122 |
123 | export default HomePage;
124 |
--------------------------------------------------------------------------------
/pages/api/settleUserBill.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import { createHmac } from "crypto";
3 |
4 | import razorpay from "../../helpers/razorpay";
5 | import admin from "../../firebase/admin";
6 | import verifyIDToken from "../../helpers/verifyIDToken";
7 |
8 | export default async function settleUserBillAPI(
9 | req: NextApiRequest,
10 | res: NextApiResponse
11 | ) {
12 | const error = (status: number, message: string) =>
13 | res.status(status).json({
14 | error: message,
15 | message,
16 | });
17 |
18 | try {
19 | const {
20 | status = "successful",
21 | razorpay_payment_id,
22 | razorpay_order_id,
23 | razorpay_signature,
24 | razorpay_error,
25 | } = req.body;
26 | const { authorization } = req.headers;
27 |
28 | if (!razorpay_order_id || !authorization)
29 | return error(400, "Incomplete Information");
30 |
31 | // Get user details from token.
32 | const decodedToken = await verifyIDToken(authorization);
33 | if (!decodedToken) return error(401, "Unauthorized");
34 |
35 | const userBillSettlementRef = admin
36 | .firestore()
37 | .collection("userbillsettlements")
38 | .doc(razorpay_order_id);
39 |
40 | const userBillSettlementData = (await userBillSettlementRef.get()).data();
41 |
42 | if (
43 | !userBillSettlementData ||
44 | userBillSettlementData.user !== decodedToken.uid
45 | )
46 | return error(404, "Bill Settment Record not found.");
47 | else if (userBillSettlementData.status !== "created")
48 | return error(400, "Payment has already been processed.");
49 |
50 | const batch = admin.firestore().batch();
51 |
52 | const userRef = admin.firestore().collection("users").doc(decodedToken.uid);
53 |
54 | const userData = (await userRef.get()).data();
55 | if (!userData) return error(404, "User info not found.");
56 |
57 | const razorpayOrder = await razorpay.orders.fetch(razorpay_order_id);
58 | if (!razorpayOrder) return error(404, "Razorpay order info not found.");
59 |
60 | if (status === "successful") {
61 | if (!razorpay_payment_id || !razorpay_signature)
62 | return error(400, "Incomplete Information");
63 |
64 | // Verifying signature
65 | const generatedSignature = createHmac(
66 | "sha256",
67 | process.env.RAZORPAY_KEY_SECRET
68 | )
69 | .update(razorpay_order_id + "|" + razorpay_payment_id)
70 | .digest("hex");
71 |
72 | if (generatedSignature != razorpay_signature)
73 | return error(403, "Unauthorized");
74 |
75 | const billSettlementPayments = await razorpay.orders.fetchPayments(
76 | razorpay_order_id
77 | );
78 | batch.update(userBillSettlementRef, {
79 | ...razorpayOrder,
80 | status: "paid",
81 | amount_due: 0,
82 | payments: billSettlementPayments || [],
83 | updatedAt: new Date(),
84 | });
85 | batch.update(userRef, {
86 | totalAmountRepaid: userData.totalTransactionAmount,
87 | updatedAt: new Date(),
88 | });
89 | } else {
90 | // Payment failure setting.
91 | if (!razorpay_error) return error(400, "Incomplete Information");
92 | batch.update(userBillSettlementRef, {
93 | ...razorpayOrder,
94 | payments: [],
95 | updatedAt: new Date(),
96 | });
97 | }
98 |
99 | await batch.commit();
100 | return res.status(200).json({ message: "Updated user bill successfully." });
101 | } catch (err) {
102 | return error(500, err.message);
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/components/Merchants/MerchantCreatorModal.tsx:
--------------------------------------------------------------------------------
1 | import { SyntheticEvent, useRef } from "react";
2 | import {
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalFooter,
8 | ModalBody,
9 | ModalCloseButton,
10 | Button,
11 | } from "@chakra-ui/react";
12 | import FormControl from "../FormControl";
13 | import Merchant from "../../types/merchant";
14 |
15 | interface MerchantCreatorModalProps {
16 | isOpen: boolean;
17 | onClose: () => any;
18 | onSubmit: (formData: Merchant) => any;
19 | isLoading?: boolean;
20 | }
21 |
22 | const MerchantCreatorModal = ({
23 | isOpen = false,
24 | isLoading = false,
25 | onClose,
26 | onSubmit,
27 | }: MerchantCreatorModalProps) => {
28 | const formRef = useRef(null);
29 |
30 | const submitForm = (e: SyntheticEvent) => {
31 | e.preventDefault();
32 | const formData = Object.fromEntries(
33 | new FormData(formRef.current)
34 | ) as unknown as Merchant;
35 | onSubmit(formData);
36 | };
37 |
38 | return (
39 |
40 |
41 |
122 |
123 | );
124 | };
125 |
126 | export default MerchantCreatorModal;
127 |
--------------------------------------------------------------------------------
/components/Layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import {
3 | Box,
4 | Stack,
5 | Image,
6 | IconButton,
7 | ButtonGroup,
8 | HStack,
9 | } from "@chakra-ui/react";
10 | import { useColorMode } from "@chakra-ui/color-mode";
11 | import styled from "@emotion/styled";
12 | import {
13 | MdAccountCircle as LoginIcon,
14 | MdLogout as LogoutIcon,
15 | } from "react-icons/md";
16 | import { FaMoon, FaSun, FaUserCircle, FaWallet } from "react-icons/fa";
17 |
18 | import Button from "../Button";
19 |
20 | import useStore from "../../store/useStore";
21 |
22 | const AppHeader = styled(Box)`
23 | position: fixed;
24 | border-bottom: 0.075rem solid var(--backgroundgrey);
25 | padding: var(--mini-spacing);
26 | z-index: 101;
27 | background: var(--white);
28 | `;
29 |
30 | const Logo = styled(Image)`
31 | max-height: 32px;
32 | `;
33 |
34 | const Container = styled(Stack)`
35 | max-width: 1100px;
36 | margin: 0 auto;
37 | `;
38 |
39 | const Left = styled.div`
40 | width: 30%;
41 | `;
42 |
43 | const Right = styled(Left)`
44 | width: 70%;
45 | text-align: right;
46 | `;
47 |
48 | const Header = ({ openLoginModal = () => null, logoutUser = () => null }) => {
49 | const { toggleColorMode } = useColorMode();
50 |
51 | const stateUser = useStore((state) => state.user);
52 | const isDarkModeActive = useStore((store) => store.isDarkModeActive);
53 | const toggleDarkMode = useStore((store) => store.toggleDarkMode);
54 |
55 | const toggleDarkModeForApp = () => {
56 | toggleColorMode();
57 | toggleDarkMode(); // Store dark mode in global state as well.
58 | };
59 |
60 | return (
61 |
141 | );
142 | };
143 |
144 | export default Header;
145 |
--------------------------------------------------------------------------------
/pages/repayment/[orderId].tsx:
--------------------------------------------------------------------------------
1 | /* Dedicated Page to make repayments for users. */
2 | import { useRouter } from "next/router";
3 | import { useState } from "react";
4 | import Script from "next/script";
5 | import styled from "@emotion/styled";
6 | import { Container, Text, Image } from "@chakra-ui/react";
7 |
8 | import Error from "../../components/Layout/Error";
9 |
10 | import db from "../../firebase/firestore";
11 |
12 | import useStore from "../../store/useStore";
13 |
14 | import setupProtectedRoute from "../../helpers/setupProtectedRoute";
15 | import toasts from "../../helpers/toasts";
16 | import { verifyRepayment } from "../../API/repayments";
17 | import Head from "next/head";
18 |
19 | const WalletImage = styled(Image)`
20 | max-width: 45vw;
21 | `;
22 |
23 | const MakeWalletPayment = ({ error, orderInfo, transactionInfo }) => {
24 | const router = useRouter();
25 |
26 | const user = useStore((state) => state.user);
27 |
28 | const [errorMessage, setErrorMessage] = useState(error);
29 | const [transactionState, setTransactionState] = useState("not-started");
30 |
31 | function initializePayment() {
32 | if (
33 | orderInfo?.status !== "created" ||
34 | transactionInfo?.status === "paid" ||
35 | transactionInfo?.status === "failed"
36 | )
37 | return setErrorMessage(
38 | "Payment process has already taken place. Please check back in some time."
39 | );
40 | if (user.uid !== orderInfo.user) return setErrorMessage("Unauthorized");
41 |
42 | const options = {
43 | key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
44 | amount: orderInfo.amount,
45 | currency: orderInfo.currency,
46 | name: "Dashout",
47 | description: "Dashout Repayment Transaction",
48 | image: "/logo-512.png",
49 | order_id: orderInfo.id,
50 | handler: (response) =>
51 | verifyRepayment(response, (error) => {
52 | if (error) {
53 | setTransactionState("failed");
54 | setErrorMessage(error);
55 | return toasts.error(error);
56 | }
57 | setTransactionState("successful");
58 | toasts.success("Repayment Successful");
59 | window.location.replace("/user/tab");
60 | }),
61 | prefill: {
62 | name: user?.displayName || "",
63 | email: user?.email || "",
64 | contact: user?.phoneNumber || "",
65 | },
66 | notes: {},
67 | theme: {
68 | color: "#008080",
69 | },
70 | };
71 | const razorpayPaymentInstance = new globalThis.Razorpay(options);
72 | razorpayPaymentInstance.open();
73 | setTransactionState("started");
74 | }
75 |
76 | return (
77 | <>
78 |
79 | Dashout - Repay Bill
80 |
81 | {errorMessage ? (
82 |
83 | ) : (
84 |
85 |
90 |
91 |
92 | {transactionState === "not-started"
93 | ? "Transaction starting"
94 | : transactionState === "started"
95 | ? "Transaction In Progress"
96 | : transactionState === "successful"
97 | ? "Transaction Successful. Your Balance will reflect in your wallet soon."
98 | : "Transaction Failed"}
99 |
100 |
104 |
105 | )}
106 | >
107 | );
108 | };
109 |
110 | MakeWalletPayment.getInitialProps = setupProtectedRoute(async (context) => {
111 | try {
112 | const { query } = context;
113 |
114 | if (!query.orderId) return { error: "Invalid Payment Session" };
115 |
116 | // Fetch settlement order info from firestore.
117 | const orderInfo = (
118 | await db.collection("userbillsettlements").doc(query.orderId).get()
119 | ).data();
120 |
121 | if (!orderInfo) return { error: "Payment Information Not Found" };
122 |
123 | return { orderInfo, user: orderInfo.user };
124 | } catch (err) {
125 | console.log(err);
126 | return { error: err.message };
127 | }
128 | });
129 |
130 | export default MakeWalletPayment;
131 |
--------------------------------------------------------------------------------
/API/ServerSideAPIs/orders.ts:
--------------------------------------------------------------------------------
1 | import admin from "../../firebase/admin";
2 | import type { OrderItem, OrderDetails } from "../../types/order";
3 | import type Transaction from "../../types/transaction";
4 |
5 | export const createNewOrder = async (
6 | merchant: string,
7 | orderDetails: OrderItem,
8 | callback: (errorMessage: string, createdOrder?: any) => any
9 | ) => {
10 | try {
11 | const batch = admin.firestore().batch();
12 | const orderRef = admin.firestore().collection("orders").doc();
13 | const merchantRef = admin.firestore().collection("merchants").doc(merchant);
14 |
15 | const fieldValues = admin.firestore.FieldValue;
16 |
17 | batch.update(merchantRef, {
18 | nOrdersCreated: fieldValues.increment(1),
19 | updatedAt: fieldValues.serverTimestamp(),
20 | });
21 | batch.set(orderRef, {
22 | ...orderDetails,
23 | status: "pending",
24 | merchant,
25 | currency: orderDetails?.currency || "INR",
26 | desc: orderDetails?.desc || "",
27 | updatedAt: fieldValues.serverTimestamp(),
28 | createdAt: fieldValues.serverTimestamp(),
29 | });
30 |
31 | await batch.commit();
32 |
33 | return callback(null, (await orderRef.get()).data());
34 | } catch (err) {
35 | console.log(err);
36 | return callback(err.message);
37 | }
38 | };
39 |
40 | export const getOrder = async (orderId: string) => {
41 | try {
42 | return (
43 | await admin.firestore().collection("orders").doc(orderId).get()
44 | ).data();
45 | } catch (err) {
46 | console.log(err);
47 | return null;
48 | }
49 | };
50 |
51 | export const declineOrderForUser = async (
52 | orderId: string,
53 | userId: string,
54 | callback: (errorMessage: string, createdOrder?: any) => any
55 | ) => {
56 | try {
57 | const orderRef = admin.firestore().collection("orders").doc(orderId);
58 |
59 | const fieldValues = admin.firestore.FieldValue;
60 |
61 | await orderRef.update({
62 | status: "declined",
63 | declinedBy: userId,
64 | declinedAt: fieldValues.serverTimestamp(),
65 | updatedAt: fieldValues.serverTimestamp(),
66 | });
67 |
68 | return callback(null);
69 | } catch (err) {
70 | console.log(err);
71 | return callback(err.message);
72 | }
73 | };
74 |
75 | export const confirmOrderForUser = async (
76 | orderId: string,
77 | orderDetails: OrderDetails,
78 | userId: string,
79 | callback: (errorMessage: string, createdOrder?: any) => any
80 | ) => {
81 | try {
82 | const orderRef = admin.firestore().collection("orders").doc(orderId);
83 | const userRef = admin.firestore().collection("users").doc(userId);
84 | const merchantRef = admin
85 | .firestore()
86 | .collection("merchants")
87 | .doc(orderDetails.merchant);
88 | const transactionRef = admin.firestore().collection("transactions").doc();
89 |
90 | const batch = admin.firestore().batch();
91 |
92 | const fieldValues = admin.firestore.FieldValue;
93 |
94 | const transaction: Transaction = {
95 | amount: Number(orderDetails.pricePerUnit * orderDetails.quantity),
96 | order: orderId,
97 | orderDetails, // Non-relational storage since order isn't going to be updated any time soon.
98 | merchant: orderDetails.merchant,
99 | user: userId,
100 | updatedAt: fieldValues.serverTimestamp(),
101 | createdAt: fieldValues.serverTimestamp(),
102 | };
103 |
104 | batch.update(orderRef, {
105 | status: "fulfilled",
106 | confirmedBy: userId,
107 | confirmedAt: fieldValues.serverTimestamp(),
108 | updatedAt: fieldValues.serverTimestamp(),
109 | transaction: transactionRef.id,
110 | });
111 | batch.set(transactionRef, transaction);
112 | batch.update(userRef, {
113 | nTransactions: fieldValues.increment(1),
114 | totalTransactionAmount: fieldValues.increment(
115 | Number(orderDetails.pricePerUnit * orderDetails.quantity)
116 | ),
117 | updatedAt: fieldValues.serverTimestamp(),
118 | });
119 | batch.update(merchantRef, {
120 | nTransactions: fieldValues.increment(1),
121 | amountProcessed: fieldValues.increment(
122 | Number(orderDetails.pricePerUnit * orderDetails.quantity)
123 | ),
124 | updatedAt: fieldValues.serverTimestamp(),
125 | });
126 |
127 | await batch.commit();
128 |
129 | return callback(null);
130 | } catch (err) {
131 | console.log(err);
132 | return callback(err.message);
133 | }
134 | };
135 |
--------------------------------------------------------------------------------
/pages/user/merchants.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, useEffect, useState } from "react";
2 | import Router from "next/router";
3 | import Head from "next/head";
4 |
5 | import {
6 | Stat,
7 | StatLabel,
8 | StatNumber,
9 | StatGroup,
10 | useDisclosure,
11 | Text,
12 | HStack,
13 | Box,
14 | } from "@chakra-ui/react";
15 | import { FaPlus } from "react-icons/fa";
16 |
17 | import setupProtectedRoute from "../../helpers/setupProtectedRoute";
18 | import ContentWrapper from "../../components/Layout/ContentWrapper";
19 | import MerchantCreatorModal from "../../components/Merchants/MerchantCreatorModal";
20 |
21 | import {
22 | getMerchantsForUser,
23 | createMerchantForUser,
24 | } from "../../API/merchants";
25 | import useUser from "../../hooks/useUser";
26 | import User from "../../types/user";
27 | import toasts from "../../helpers/toasts";
28 |
29 | import NoneFound from "../../components/Layout/NoneFound";
30 | import Button from "../../components/Button";
31 | import MerchantCard from "../../components/Merchants/MerchantCard";
32 |
33 | const UserMerchants = () => {
34 | const user = useUser() || ({} as User);
35 | const {
36 | isOpen: isMerchantCreatorOpen,
37 | onOpen: openMerchantCreator,
38 | onClose: closeMerchantCreator,
39 | } = useDisclosure();
40 | const [isMerchantCreating, setIsMerchantCreating] = useState(false);
41 |
42 | const [userMerchants, setUserMerchants] = useState([]);
43 |
44 | const fetchUserMerchants = async () => {
45 | if (!user || !user.uid) return;
46 | getMerchantsForUser(user.uid, (err, merchantsList) => {
47 | if (err) return toasts.error(err);
48 | setUserMerchants(
49 | merchantsList.map((merchantsDoc) => merchantsDoc.data())
50 | );
51 | });
52 | };
53 |
54 | useEffect(() => {
55 | if (!user?.canCreateMerchants) Router.push("/user/tab");
56 | else fetchUserMerchants();
57 | }, [user.uid]);
58 |
59 | const createMerchant = (merchantData) => {
60 | setIsMerchantCreating(true);
61 | if (userMerchants.length >= 10)
62 | return toasts.error("You cannot be part of more than 10 merchants.");
63 | createMerchantForUser(
64 | user.uid,
65 | merchantData,
66 | (err, createdMerchantInDatabase) => {
67 | setIsMerchantCreating(false);
68 | closeMerchantCreator();
69 | if (err) return toasts.error(err);
70 | setUserMerchants((merchants) => [
71 | createdMerchantInDatabase,
72 | ...merchants,
73 | ]);
74 | toasts.success("Created Merchants successfully");
75 | }
76 | );
77 | };
78 |
79 | return (
80 | <>
81 |
82 | Dashout - Merchants
83 |
84 |
85 |
91 |
97 |
98 | Merchants
99 | {userMerchants?.length || 0}
100 |
101 |
102 | Total Processed Amount
103 |
104 | ₹
105 | {Number(
106 | userMerchants?.reduce?.((acc, merchant) => {
107 | return acc + Number(merchant?.amountProcessed || 0);
108 | }, 0) / 100
109 | ).toFixed(2) || 0}
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | MERCHANTS
118 |
119 |
120 |
121 | }>
122 | Create Merchant
123 |
124 |
125 |
126 | {!userMerchants?.length ? (
127 |
128 | ) : (
129 | userMerchants.map((merchant, index) => (
130 |
131 | ))
132 | )}
133 |
134 | >
135 | );
136 | };
137 |
138 | UserMerchants.getInitialProps = setupProtectedRoute();
139 |
140 | export default UserMerchants;
141 |
--------------------------------------------------------------------------------
/pages/user/tab.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import Link from "next/link";
3 | import Head from "next/head";
4 |
5 | import {
6 | Stat,
7 | StatLabel,
8 | StatNumber,
9 | StatGroup,
10 | HStack,
11 | Box,
12 | Text,
13 | } from "@chakra-ui/react";
14 | import { FaMoneyCheck } from "react-icons/fa";
15 |
16 | import setupProtectedRoute from "../../helpers/setupProtectedRoute";
17 | import ContentWrapper from "../../components/Layout/ContentWrapper";
18 | import Button from "../../components/Button";
19 | import TransactionTile from "../../components/TransactionTile";
20 | import NoneFound from "../../components/Layout/NoneFound";
21 |
22 | import useUser from "../../hooks/useUser";
23 | import User from "../../types/user";
24 | import Transaction from "../../types/transaction";
25 | import { getUserTransactions } from "../../API/transactions";
26 | import toasts from "../../helpers/toasts";
27 | import { createRepaymentTransaction } from "../../API/repayments";
28 |
29 | const UserTab = () => {
30 | const user = useUser() || ({} as User);
31 |
32 | const [isProcessing, setIsProcessing] = useState(false);
33 |
34 | const [userTransactions, setUserTransactions] = useState([]);
35 | const [hasMoreTransactions, setHasMoreTransactions] = useState(true);
36 | const userTransactionsStartAfter = useRef(null);
37 |
38 | const fetchUserTransactions = async () => {
39 | if (!user || !user.uid || !hasMoreTransactions) return;
40 | getUserTransactions(
41 | user.uid,
42 | userTransactionsStartAfter.current,
43 | (err, transactionsList) => {
44 | if (err) return toasts.error(err);
45 | setUserTransactions(
46 | transactionsList.map((transactionDoc) => transactionDoc.data())
47 | );
48 | userTransactionsStartAfter.current =
49 | transactionsList[transactionsList.length - 1];
50 | setHasMoreTransactions(transactionsList.length >= 10);
51 | }
52 | );
53 | };
54 |
55 | useEffect(() => {
56 | fetchUserTransactions();
57 | }, [user.uid]);
58 |
59 | const startRepayment = () => {
60 | setIsProcessing(true);
61 | createRepaymentTransaction((error, response) => {
62 | setIsProcessing(false);
63 | if (error) return toasts.error(error);
64 | if (response?.order?.id)
65 | return window.location.replace(`/repayment/${response.order.id}`);
66 | else return toasts.error("Something went wrong, please try again later.");
67 | });
68 | };
69 |
70 | return (
71 | <>
72 |
73 | Dashout - {user.displayName || "User"}'s Tab
74 |
75 |
76 |
82 |
83 | Transactions
84 | {user.nTransactions || 0}
85 |
86 |
87 | Transaction Amount
88 |
89 | ₹{Number((user.totalTransactionAmount || 0) / 100).toFixed(2)}
90 |
91 |
92 |
93 | Amount Repaid
94 |
95 | ₹{Number((user.totalAmountRepaid || 0) / 100).toFixed(2)}
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | TRANSACTIONS
104 |
105 |
106 | {user?.canCreateMerchants && (
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | )}
115 |
116 |
117 | {Number(user?.totalTransactionAmount || 0) -
118 | Number(user.totalAmountRepaid || 0) >
119 | 0 && (
120 |
121 |
134 |
135 | )}
136 |
137 | {userTransactions.length ? (
138 | <>
139 | {userTransactions.map((transaction: Transaction) => (
140 |
141 | ))}
142 | {hasMoreTransactions && (
143 |
144 |
147 |
148 | )}
149 | >
150 | ) : (
151 | }
154 | />
155 | )}
156 |
157 | >
158 | );
159 | };
160 |
161 | UserTab.getInitialProps = setupProtectedRoute();
162 |
163 | export default UserTab;
164 |
--------------------------------------------------------------------------------
/pages/pay/[orderId].tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Router from "next/router";
3 | import { useEffect, useState } from "react";
4 |
5 | import { Avatar, HStack, Text } from "@chakra-ui/react";
6 | import {
7 | MdAccountCircle as LoginIcon,
8 | MdCancel as CancelIcon,
9 | MdCheck as ConfirmIcon,
10 | } from "react-icons/md";
11 |
12 | import Button from "../../components/Button";
13 | import ContentWrapper from "../../components/Layout/ContentWrapper";
14 |
15 | import useUser from "../../hooks/useUser";
16 |
17 | import auth from "../../firebase/authentication";
18 | import request from "../../helpers/request";
19 | import toasts from "../../helpers/toasts";
20 |
21 | const PayForOrder = ({ openLoginModal, orderId }) => {
22 | const user = useUser();
23 | const isLoggedIn = !!user;
24 |
25 | const [orderDetails, setOrderDetails] = useState(null);
26 | const [merchantDetails, setMerchantDetails] = useState(null);
27 |
28 | useEffect(() => {
29 | if (!orderId) window.location.replace(document.referrer || "about:blank");
30 | if (!user?.uid) {
31 | // Show the user the login modal.
32 | openLoginModal();
33 | }
34 | }, []);
35 |
36 | const getOrderInfo = async () => {
37 | request({
38 | endpoint: "/api/getOrderDetails",
39 | requestType: "post",
40 | options: {
41 | headers: { authorization: await auth.currentUser.getIdToken() },
42 | },
43 | data: { orderId },
44 | callback: (error, response) => {
45 | if (error) {
46 | toasts.error(error);
47 | return Router.push("/");
48 | }
49 | setOrderDetails(response.order);
50 | setMerchantDetails(response.merchant);
51 | },
52 | });
53 | };
54 |
55 | useEffect(() => {
56 | if (isLoggedIn && auth.currentUser) {
57 | // Make a call for getting order and merchant details here.
58 | getOrderInfo();
59 | }
60 | }, [isLoggedIn, auth.currentUser]);
61 |
62 | const confirmOrder = async () => {
63 | request({
64 | endpoint: "/api/confirmOrder",
65 | requestType: "post",
66 | options: {
67 | headers: { authorization: await auth.currentUser.getIdToken() },
68 | },
69 | data: { orderId },
70 | callback: (error) => {
71 | if (error) {
72 | toasts.error(error);
73 | setTimeout(() =>
74 | window.location.replace(
75 | `${merchantDetails.errorRedirect}?orderId=${orderId}`
76 | )
77 | );
78 | }
79 | toasts.success("Transaction was successfully completed.");
80 | setTimeout(() =>
81 | window.location.replace(
82 | `${merchantDetails.successRedirect}?orderId=${orderId}&status=confirmed`
83 | )
84 | );
85 | },
86 | });
87 | };
88 |
89 | const declineOrder = async () => {
90 | request({
91 | endpoint: "/api/declineOrder",
92 | requestType: "post",
93 | options: {
94 | headers: { authorization: await auth.currentUser.getIdToken() },
95 | },
96 | data: { orderId },
97 | callback: (error) => {
98 | if (error) toasts.error(error);
99 | else toasts.success("Transaction was successfully declined.");
100 | setTimeout(() =>
101 | window.location.replace(
102 | `${merchantDetails.errorRedirect}?orderId=${orderId}&status=declined`
103 | )
104 | );
105 | },
106 | });
107 | };
108 |
109 | const orderDescText =
110 | orderDetails?.status === "fulfilled"
111 | ? "The order has been confirmed and marked on the user's tab."
112 | : orderDetails?.status === "declined"
113 | ? "The order has been declined."
114 | : "On Confirming, the transaction amount will be added to your next billing cycle.";
115 |
116 | return (
117 |
124 |
125 | Dashout - {orderDetails?.name || "Purchase Permission"}
126 |
127 | {user?.uid ? (
128 | <>
129 |
136 |
137 | {orderDetails?.name || "Purchase Order"}
138 |
139 |
140 | ₹
141 | {Number(
142 | (orderDetails?.pricePerUnit || 0) * (orderDetails?.quantity || 0)
143 | ).toFixed(2)}
144 |
145 | {orderDetails?.desc && (
146 |
147 | {orderDetails.desc}
148 |
149 | )}
150 |
151 | {orderDescText}
152 |
153 | {!["fulfilled", "declined"].includes(orderDetails?.status) && (
154 |
159 | }
163 | onClick={confirmOrder}
164 | $paddingMultiplier="1.25"
165 | >
166 | Confirm Order
167 |
168 | }
172 | onClick={declineOrder}
173 | $variant="hollow"
174 | $paddingMultiplier="1.125"
175 | >
176 | Decline
177 |
178 |
179 | )}
180 | >
181 | ) : (
182 | <>
183 |
184 | Please Login To Proceed
185 |
186 |
187 | }
191 | onClick={openLoginModal}
192 | $variant="hollow"
193 | $noMinWidth
194 | $paddingMultiplier="1.25"
195 | >
196 | Login
197 |
198 | >
199 | )}
200 |
201 | );
202 | };
203 |
204 | PayForOrder.getInitialProps = async (context) => {
205 | // Add getInitialProps so this page is not statically optimized by Next.js
206 | const { query } = context;
207 | return query;
208 | };
209 |
210 | export default PayForOrder;
211 |
--------------------------------------------------------------------------------
/public/error.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/images/wallet.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------