= () => {
31 | const [name, setName] = useState('Dilantha');
32 | const [age, setAge] = useState(28);
33 | const [renderCount, setRenderCount] = useState(0);
34 | const isSubscribed = useService(serviceA);
35 |
36 | return (
37 | <>
38 | setName(e.target.value)} />
39 | Name : {name}
40 | Age : {age}
41 | Render Count : {renderCount}
42 | isSubscribed : {isSubscribed}
43 | >
44 | );
45 | };
46 |
47 | export default Companies;
48 |
--------------------------------------------------------------------------------
/web-app/components/Material/SuccessMessage.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Snackbar from '@material-ui/core/Snackbar';
3 | import MuiAlert from '@material-ui/lab/Alert';
4 | import { useApolloClient, useQuery, useMutation } from '@apollo/react-hooks';
5 | import { SNACKBAR_STATE_QUERY } from '../../graphql/queries';
6 | // import { TOGGLE_SNACKBAR_MUTATION } from '../../graphql/mutations';
7 | import gql from 'graphql-tag';
8 |
9 | function Alert(props) {
10 | return ;
11 | }
12 |
13 | const Message = () => {
14 | const { data, error, loading } = useQuery(SNACKBAR_STATE_QUERY);
15 | const [toggleSnackBar] = useMutation(TOGGLE_SNACKBAR_MUTATION);
16 | if (!loading && data) {
17 | return (
18 | toggleSnackBar({ variables: { msg: '', type: 'success' } })}
26 | > toggleSnackBar({ variables: { msg: '', type: 'success' } })}
28 | severity={data.snackType}>
29 | {data.snackMsg}
30 |
31 | )
32 | } else {
33 | return <>>
34 | }
35 | };
36 |
37 | const TOGGLE_SNACKBAR_MUTATION = gql`
38 | mutation toggleSnackBar{
39 | toggleSnackBar(msg: $msg, type: $type) @client
40 | }
41 | `;
42 | export default Message;
43 | export { SNACKBAR_STATE_QUERY, TOGGLE_SNACKBAR_MUTATION };
--------------------------------------------------------------------------------
/web-app/graphql/generated/products.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { ProductOrderByInput } from "./graphql-global-types";
7 |
8 | // ====================================================
9 | // GraphQL query operation: products
10 | // ====================================================
11 |
12 | export interface products_products_nodes_Category {
13 | __typename: "Category";
14 | id: string;
15 | name: string;
16 | parent: string;
17 | }
18 |
19 | export interface products_products_nodes_ProductImages {
20 | __typename: "ProductImage";
21 | image: string;
22 | }
23 |
24 | export interface products_products_nodes {
25 | __typename: "Product";
26 | id: string;
27 | name: string;
28 | price: number;
29 | discount: number;
30 | salePrice: number;
31 | sku: string;
32 | unit: string;
33 | description: string;
34 | Category: products_products_nodes_Category;
35 | ProductImages: products_products_nodes_ProductImages[];
36 | }
37 |
38 | export interface products_products {
39 | __typename: "QueryProducts_Connection";
40 | /**
41 | * Flattened list of Product type
42 | */
43 | nodes: products_products_nodes[];
44 | totalCount: number;
45 | }
46 |
47 | export interface products {
48 | products: products_products;
49 | }
50 |
51 | export interface productsVariables {
52 | orderBy?: ProductOrderByInput | null;
53 | first?: number | null;
54 | skip?: number | null;
55 | nameQuery?: string | null;
56 | discount?: string | null;
57 | }
58 |
--------------------------------------------------------------------------------
/web-app/graphql/generated/UPDATE_PRODUCT_MUTATION.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | // @generated
4 | // This file was automatically generated and should not be edited.
5 |
6 | import { ProductImageCreateWithoutProductInput } from "./graphql-global-types";
7 |
8 | // ====================================================
9 | // GraphQL mutation operation: UPDATE_PRODUCT_MUTATION
10 | // ====================================================
11 |
12 | export interface UPDATE_PRODUCT_MUTATION_updateProduct_Category {
13 | __typename: "Category";
14 | name: string;
15 | parent: string;
16 | }
17 |
18 | export interface UPDATE_PRODUCT_MUTATION_updateProduct_ProductImages {
19 | __typename: "ProductImage";
20 | image: string;
21 | }
22 |
23 | export interface UPDATE_PRODUCT_MUTATION_updateProduct {
24 | __typename: "Product";
25 | id: string;
26 | name: string;
27 | description: string;
28 | price: number;
29 | discount: number;
30 | salePrice: number;
31 | sku: string;
32 | unit: string;
33 | Category: UPDATE_PRODUCT_MUTATION_updateProduct_Category;
34 | ProductImages: UPDATE_PRODUCT_MUTATION_updateProduct_ProductImages[];
35 | }
36 |
37 | export interface UPDATE_PRODUCT_MUTATION {
38 | updateProduct: UPDATE_PRODUCT_MUTATION_updateProduct;
39 | }
40 |
41 | export interface UPDATE_PRODUCT_MUTATIONVariables {
42 | id: string;
43 | name: string;
44 | description: string;
45 | price: number;
46 | discount: number;
47 | salePrice: number;
48 | sku: string;
49 | unit: string;
50 | categoryId: string;
51 | images: ProductImageCreateWithoutProductInput[];
52 | }
53 |
--------------------------------------------------------------------------------
/api/src/schema/mutations/login.ts:
--------------------------------------------------------------------------------
1 | import { stringArg, mutationField } from '@nexus/schema';
2 | import bcrypt from 'bcrypt';
3 | import jwt from 'jsonwebtoken';
4 | import { cookieDuration } from '../../utils/constants';
5 | import analytics from '../../utils/analytics';
6 | import { verifyEnvironmentVariables } from '../../utils/verifyEnvironmentVariables';
7 |
8 | export const loginMutationField = mutationField('login', {
9 | type: 'User',
10 | args: { email: stringArg({ required: true }), password: stringArg({ required: true }) },
11 | resolve: async (_, { email, password }, ctx) => {
12 | email = email.toLowerCase();
13 | const user = await ctx.prisma.user.findOne({ where: { email } });
14 | if (!user) {
15 | throw new Error(`No such user found for email ${email}`);
16 | }
17 | if (!user.password) {
18 | throw new Error(`No password set for that email. Sign in with Google instead.`);
19 | }
20 |
21 | const isValid = await bcrypt.compare(password, user.password);
22 | if (!isValid) {
23 | throw new Error('Invalid Password!');
24 | }
25 | verifyEnvironmentVariables(process.env.API_APP_SECRET, 'API_APP_SECRET');
26 | const token = jwt.sign({ userId: user.id }, process.env.API_APP_SECRET);
27 |
28 | ctx.response.cookie('token', token, {
29 | httpOnly: true,
30 | maxAge: cookieDuration,
31 | sameSite: 'none',
32 | secure: true,
33 | // domain: process.env.API_COOKIE_DOMAIN,
34 | });
35 |
36 | analytics.track({ eventType: 'Login', userId: user.id, eventProperties: { method: 'Password' } });
37 |
38 | return user;
39 | },
40 | });
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NextJS/ GraphQL Admin Panel inspired by Material UI
2 |
3 | #### Tech Stack Frontend
4 | * React(Next JS)
5 | * Typescript
6 | * Apollo Client
7 | * GraphQL
8 | * Material UI
9 |
10 | #### Tech Stack Backend
11 | * GraphQL
12 | * Prisma 2
13 | * Nexus
14 | * Typescript
15 | * Mysql
16 |
17 | ## Install VS Code extensions 🚀
18 |
19 | Please installed these VS Code extensions for syntax highlighting and auto-formatting - [Prisma](https://marketplace.visualstudio.com/items?itemName=Prisma.prisma) and [GraphQL](https://marketplace.visualstudio.com/items?itemName=Prisma.vscode-graphql).
20 |
21 | Prisma Extension for VS Code
22 |
23 | GraphQL Extension for VS Code
24 |
25 | ## Install Frontend
26 | [Check Readme file for install instructions](https://github.com/dvikas/next-graphql-admin/tree/main/web-app)
27 |
28 | ## Install Backend
29 | [Check Readme file for install instructions](https://github.com/dvikas/next-graphql-admin/tree/main/api)
30 |
31 | ## Screenshots
32 | Dashboard
33 |
34 | Products
35 |
36 | Edit Product
37 |
38 | Categories
39 |
40 | ## Contributing
41 | Please share your opinion/ feedback for improvements! As you know it's open source project, don't hesitate to contribute your thoughts. Also, you can send email on vikas.nice@gmail.com.
42 |
43 | Feel free to create a PR for your changes :)
44 |
--------------------------------------------------------------------------------
/api/local.admin-panel.key:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | MIIEpQIBAAKCAQEA67Q0b/YVsVAtank0ETE7+sOm7s1PcPALuIZFJSUv6PsPU0vx
3 | kTS+qrrV1UQUIyxW+sCZALQqhxRkicAPwLxZCPtqD9dc8z/TBQTix7O9VDBL3HwX
4 | iKL5HCYV4yzBR+P4iLgc2C0k0twP9zuNm4CJjD0aJezsCmnkC6gpXJSCL/LrPYlr
5 | C1u/4m6nUTriV7ZX/q12Cwwvxhy6RlIe899ojHLm8IIJh8CODwNJKFHuHDxUn5Wl
6 | aEyDgSUENDQb6d3s9W91UXnjX8IMVpqIYIi6fvEQRD/LrZD8VT+CQ78edeyBcSQU
7 | mYCPhBP6Ck7ZAjR+JPZDbhFRizBo6HS9Sx/r0QIDAQABAoIBAQDjyNBQTyqhpBFv
8 | 71gRMVp8ui4OZB3c0C8Tkbcq8ag+aLpjzmXS5X1J86uJIfSwFT6tsAltM7BRwLR0
9 | p0bSBXOqCYZzbrbmYYzmMdWUFzDmNpJprwbzRkSmHmxSkkLr3fWm8v71L5OBr6hC
10 | TqxIVk0XWUl202M9oR4A4e+vB9pUyHwipwmiQZaQ+skk3VqxUhwc5Smx4CUsWyE9
11 | mpKohTJTkEAh2e+ubNyVeGb05cSFfMBAVeayr42fOwwj1YSvHX1rgrK5Wo8dqCoD
12 | +1K6JOmFFSB7gBfKUUxKv0NNEKL3axnX5MXtTOf3WnYbjJ3jOvfL7/hLWwsfPvui
13 | rGAEsCA9AoGBAPaQ8sl/zbvGpKjGNRtaLM7ign1vJGyb7R596kqeKYywS7FO+RRH
14 | eBM7PeFfxJeQwAD4T5UDCOy94N/lflpOd/2A6UYFl5ygZuZ+UfxJNgIoldWg3Pg9
15 | HLnEqHUrLVY/3AmFaIKvtxpo/GYZqWmcsJQqdym6ROAZfT2a2Jt/g3qnAoGBAPS4
16 | 3P407w6chW/9wsI6755ZyHDkAdQ6Y51Q2iLXmLh+UuvAPNx2z8DagEgNcOWZJ5M/
17 | 97jNlkVTT+5LcgfOT3zs1XKI7M3KL/4jxC9gvUnp+sv7X72feW9zdg5eLU5SCOFr
18 | yviAvoCnG/g89t4WWWqSL4EhuTOIG7lZNdvKrkzHAoGBALVhrekDRoJTQAAURy8G
19 | 6B2NTbcekqn/DrE2qasYrLIdYqFd2ifL544mL4Bi5gklZ8mO4WRaJi+aAxpSBeBD
20 | B0wKkBB9vqlu6iO3W3J/HOb7mjXcL5HByybxf4cqKyDeu2yZomc5AjbAcqRdTl4t
21 | 8Uwd7SlaKJ6+wX4XMi8536vTAoGBAOAvPdvumBT1lFQczr7qCLsymqm4ZmiKONlT
22 | yRFkGjbhGot3pwl8GiQcxqm7DnJ21EdTsVbtlzzY7n9pRAQcnrrdp0fuYajAESkq
23 | kL2qTJ2aqDMXjASFRFSyHDNbWPvHsPT4r47pOhtXewr0pl6bcLxtQPF1+FhZ1rP8
24 | Ipe/297fAoGAMrHsoAGQbdvq7/DDP1VeeCKOBRJRQE60pBzNFsaJ2ae27n5nbOW2
25 | c4Vra90Pb6UVp1MHLK6Pw7j7SqjatdkYJDUs7uXhHzj+Dc3NpNi7aiFSKqaluPZr
26 | 3rLMEOzmYFvZAxL31MAa4xYyrDhjcmIsea+p7YMz9Ls4onN02T/UQD8=
27 | -----END RSA PRIVATE KEY-----
28 |
--------------------------------------------------------------------------------
/api/src/schema/objectTypes.ts:
--------------------------------------------------------------------------------
1 | import { objectType } from '@nexus/schema';
2 |
3 | export const Category = objectType({
4 | name: 'Category',
5 | definition(t) {
6 | t.model.id();
7 | t.model.createdAt();
8 | t.model.name();
9 | t.model.parent();
10 | t.model.slug();
11 | t.model.updatedAt();
12 | },
13 | });
14 |
15 | export const ProductImage = objectType({
16 | name: 'ProductImage',
17 | definition(t) {
18 | t.model.id();
19 | t.model.createdAt();
20 | t.model.updatedAt();
21 | t.model.image();
22 | t.model.Product();
23 | t.model.productId();
24 | },
25 | });
26 |
27 | export const Product = objectType({
28 | name: 'Product',
29 | definition(t) {
30 | t.model.id();
31 | t.model.name();
32 | t.model.price();
33 | t.model.salePrice();
34 | t.model.sku();
35 | t.model.unit();
36 | t.model.User();
37 | t.model.Category();
38 | t.model.ProductImages({ ordering: { createdAt: true } });
39 | t.model.description();
40 | t.model.discount();
41 | t.model.createdAt();
42 | t.model.updatedAt();
43 | },
44 | });
45 |
46 | export const User = objectType({
47 | name: 'User',
48 | definition(t) {
49 | t.model.id();
50 | t.model.email();
51 | t.model.name();
52 | t.model.role();
53 | t.model.status();
54 | t.model.hasCompletedOnboarding();
55 | t.model.hasVerifiedEmail();
56 | },
57 | });
58 |
59 | export const GoogleMapsLocation = objectType({
60 | name: 'GoogleMapsLocation',
61 | definition(t) {
62 |
63 | t.model.id();
64 | t.model.name();
65 | t.model.googlePlacesId();
66 | },
67 | });
68 |
--------------------------------------------------------------------------------
/api/src/utils/mail.ts:
--------------------------------------------------------------------------------
1 | // // Load the AWS SDK for Node.js
2 | // import AWS from 'aws-sdk';
3 | // import { credentials } from './fileUpload';
4 |
5 | // const ses = new AWS.SES({ apiVersion: '2010-12-01', credentials, region: 'us-east-1' });
6 |
7 | // export const sendEmail = async ({
8 | // toAddress,
9 | // subject,
10 | // text,
11 | // }: {
12 | // toAddress: string[];
13 | // subject: string;
14 | // text: string;
15 | // }): Promise => {
16 | // const params = {
17 | // Destination: {
18 | // CcAddresses: [],
19 | // ToAddresses: toAddress,
20 | // },
21 | // Message: {
22 | // Body: {
23 | // Html: {
24 | // Charset: 'UTF-8',
25 | // Data: `
26 | //
33 | //
Hello There!
34 | //
${text}
35 |
36 | //
Admin
37 | //
38 | // `,
39 | // },
40 | // },
41 | // Subject: {
42 | // Charset: 'UTF-8',
43 | // Data: subject,
44 | // },
45 | // },
46 | // Source: 'info@nextgraphqladmin.com',
47 | // ReplyToAddresses: ['info@nextgraphqladmin.com'],
48 | // };
49 |
50 | // try {
51 | // await ses.sendEmail(params).promise();
52 | // } catch (e) {
53 | // throw e;
54 | // }
55 | // };
56 |
--------------------------------------------------------------------------------
/web-app/components/Product/AddProductStyle.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | makeStyles,
3 | createStyles,
4 | Theme,
5 | } from '@material-ui/core'
6 |
7 | export const useStyles = makeStyles((theme: Theme) =>
8 | createStyles({
9 | root: {
10 | display: 'block',
11 | margin: '0 auto',
12 | },
13 | uploadButton: {
14 | paddingLeft: '20px',
15 | paddingRight: '20px'
16 | },
17 | textField: {
18 | '& > *': {
19 | width: '100%',
20 | },
21 | },
22 | submitButton: {
23 | marginTop: '24px',
24 | textAlign: 'right'
25 | },
26 | FileContainer: {
27 | display: 'flex',
28 | flexDirection: 'column',
29 | fontFamily: 'sans-serif'
30 | },
31 | thumbsContainer: {
32 | display: 'flex',
33 | flexDirection: 'row',
34 | flexWrap: 'wrap',
35 | marginTop: 16
36 | },
37 | thumb: {
38 | display: 'inline-flex',
39 | borderRadius: 2,
40 | border: '1px solid #eaeaea',
41 | marginBottom: 8,
42 | marginRight: 8,
43 | width: 100,
44 | height: 100,
45 | padding: 4,
46 | boxSizing: 'border-box'
47 | },
48 | thumbInner: {
49 | display: 'flex',
50 | minWidth: 0,
51 | overflow: 'hidden'
52 | },
53 | img: {
54 | display: 'block',
55 | width: 'auto',
56 | height: '100%'
57 | },
58 | media: {
59 | padding: '0 5px',
60 | height: '100px',
61 | textAlign: 'center',
62 | marginTop: '10px',
63 | margin: '0 auto',
64 | },
65 | icon: {
66 | color: 'rgba(255, 255, 255, 0.54)',
67 | },
68 | gridList: {
69 | width: 350,
70 | },
71 | titleBar: {
72 | background:
73 | 'linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 70%, rgba(0,0,0,0) 100%)',
74 | },
75 | })
76 | )
--------------------------------------------------------------------------------
/web-app/graphql/mutations/index.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 |
3 | export const deleteAccountMutation = gql`
4 | mutation DeleteAccountMutation($id: ID!) {
5 | deleteAccount(id: $id) {
6 | id
7 | }
8 | }
9 | `;
10 |
11 | export const loginMutation = gql`
12 | mutation LoginMutation($email: String!, $password: String!) {
13 | login(email: $email, password: $password) {
14 | email
15 | }
16 | }
17 | `;
18 |
19 | export const logoutMutation = gql`
20 | mutation LogoutMutation {
21 | logout
22 | }
23 | `;
24 |
25 | export const signupMutation = gql`
26 | mutation SignupMutation($name: String!, $email: String!, $password: String!, $confirmPassword: String!) {
27 | signup(name: $name, email: $email, password: $password, confirmPassword: $confirmPassword) {
28 | email
29 | }
30 | }
31 | `;
32 |
33 | export const requestResetPasswordMutation = gql`
34 | mutation RequestResetPasswordMutation($email: String!) {
35 | requestPasswordReset(email: $email)
36 | }
37 | `;
38 |
39 | export const resetPasswordMutation = gql`
40 | mutation ResetPasswordMutation($resetToken: String!, $password: String!, $confirmPassword: String!) {
41 | resetPassword(resetToken: $resetToken, password: $password, confirmPassword: $confirmPassword) {
42 | email
43 | }
44 | }
45 | `;
46 |
47 | export const completeOnboardingMutation = gql`
48 | mutation CompleteOnboardingMutation {
49 | completeOnboarding
50 | }
51 | `;
52 |
53 | // export const TOGGLE_SNACKBAR_MUTATION = gql`
54 | // mutation toggleSnackBar{
55 | // toggleSnackBar(msg: $msg, type: $type) @client
56 | // }
57 | // `;
58 |
59 | // export const TOGGLE_LEFT_DRAWER_MUTATION = gql`
60 | // mutation toggleLeftDrawer{
61 | // toggleLeftDrawer(status: "") @client
62 | // }
63 | // `;
--------------------------------------------------------------------------------
/api/seeds/index.ts:
--------------------------------------------------------------------------------
1 | if (require.main === module) {
2 | // Only import the environment variables if executing this file directly: https://stackoverflow.com/a/6090287/8360496
3 | // The schema file gets executed directly when running the generate command: yarn generate
4 | // Without this check, we would be trying to load the environment variables twice and that causes warnings to be thrown in the console
5 | require('dotenv-flow').config();
6 | }
7 |
8 | import cuid from 'cuid';
9 | import bcrypt from 'bcrypt';
10 | import faker from 'faker';
11 | import knex from 'knex';
12 | import subDays from 'date-fns/subDays';
13 | import format from 'date-fns/format';
14 | import {
15 | PrismaClient,
16 | } from '@prisma/client';
17 |
18 | const prisma = new PrismaClient();
19 |
20 | const knexInstance = knex({
21 | client: 'mysql',
22 | connection: {
23 | host: '127.0.0.1',
24 | user: 'root',
25 | password: 'secret',
26 | database: 'next_graphql_admin@local',
27 | },
28 | });
29 |
30 | const userEmail = `admin@example.com`;
31 | const userPassword = 'admin';
32 |
33 | // eslint-disable-next-line no-console
34 | console.log(`Creating user '${userEmail}' with password '${userPassword}'`);
35 |
36 | async function main(): Promise {
37 | const hashedPassword = await bcrypt.hash(userPassword, 10);
38 | const userCuid = cuid();
39 |
40 |
41 | // Create user
42 | await prisma.user.create({
43 | data: {
44 | email: userEmail,
45 | password: hashedPassword,
46 | id: userCuid,
47 | hasVerifiedEmail: true,
48 | name: 'Vikas Dwivedi',
49 | role: 'ADMIN'
50 | },
51 | });
52 | // eslint-disable-next-line no-console
53 | console.log(`Seeded user: ${userEmail}`);
54 |
55 | process.exit(0);
56 | }
57 |
58 | // eslint-disable-next-line no-console
59 | main().catch((e) => console.error(e));
60 |
--------------------------------------------------------------------------------
/web-app/assets/icons/info.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/web-app/assets/images/image.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/web-app/public/images/image.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/web-app/utils/hooks/useGooglePlacesScript.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | const cachedScripts: string[] = [];
4 | let service: google.maps.places.AutocompleteService;
5 | const src = `https://maps.googleapis.com/maps/api/js?key=${process.env.WEB_APP_GOOGLE_API_KEY}&libraries=places`;
6 |
7 | export function useGooglePlacesScript(): google.maps.places.AutocompleteService | undefined {
8 | useEffect(
9 | () => {
10 | if (!cachedScripts.includes(src)) {
11 | cachedScripts.push(src);
12 |
13 | // Create script
14 | const script = document.createElement('script');
15 | script.src = src;
16 | script.async = true;
17 |
18 | // Script event listener callbacks for load and error
19 | const onScriptLoad = (): void => {
20 | service = new window.google.maps.places.AutocompleteService();
21 | };
22 |
23 | const onScriptError = (): void => {
24 | // Remove from cachedScripts we can try loading again
25 | const index = cachedScripts.indexOf(src);
26 | if (index >= 0) {
27 | cachedScripts.splice(index, 1);
28 | }
29 | script.remove();
30 | };
31 |
32 | script.addEventListener('load', onScriptLoad);
33 | script.addEventListener('error', onScriptError);
34 |
35 | // Add script to document body
36 | document.body.appendChild(script);
37 |
38 | // Remove event listeners on cleanup
39 | return (): void => {
40 | script.removeEventListener('load', onScriptLoad);
41 | script.removeEventListener('error', onScriptError);
42 | };
43 | }
44 | },
45 | [] // Only re-run effect if script src changes
46 | );
47 |
48 | return service;
49 | }
50 |
--------------------------------------------------------------------------------
/web-app/README.md:
--------------------------------------------------------------------------------
1 | # Web App
2 |
3 | A Next.js application for the web application of Admin Panel .
4 |
5 | ## Getting started
6 |
7 | ### Pre-requisites
8 |
9 | The following must be installed locally in order to run the web application:
10 |
11 | - yarn (https://classic.yarnpkg.com/en/docs/install/#mac-stable)
12 | - node (https://nodejs.org/en/download/)
13 |
14 | ### Host file
15 |
16 | Add the following line to your `/etc/hosts` file in order to alias your localhost to local.app.nextgraphqladmin.com:
17 |
18 | ```text
19 | 127.0.0.1 local.app.nextgraphqladmin.com
20 | ```
21 | ### Cloudinary setup and adding upload preset
22 |
23 | - Cloudinary is a cloud-based image and video hosting service
24 | - Signup for cloudinary using this [link](https://cloudinary.com/signup)
25 | - Watch this [video about setting up upload preset in cloudinary](https://vimeo.com/454599719)
26 |
27 |
28 | ### Environment variables
29 |
30 | ```bash
31 | IMAGE_UPLOAD_URL=https://api.cloudinary.com/v1_1/REPLACE_USER_NAME/image/upload
32 | IMAGE_UPLOAD_PRESET=YOUR_PROJECT_NAME
33 | ```
34 |
35 | Local environment variables are configured in the `.env` file. Variables set for the `dev` and `prod` environment are configured using the NOW CLI in the `now.json` and `now.prod.json` file. Environment variables are injected into the Next.js app through the `nex.config.js` file.
36 |
37 | ### Starting the server
38 | (Don't delete `yarn.lock` file. Install with `yarn`)
39 | ```bash
40 | yarn # Install all dependencies
41 | > If you aree getting error The engine "node" is incompatible with this module. Expected version "12". Got "14.17.3"
42 | > nvm use --delete-prefix v12.0
43 | > nvm install v12.0
44 | yarn dev # Starts the development server at http://local.app.nextgraphqladmin.com:3000
45 | ```
46 |
47 | ## Local deployments to Zeit Now
48 |
49 | In order to deploy to Zeit Now with the `yarn deploy:dev` or `yarn deploy:prod` commands, it's required to have a `now.env.dev` or `now.env.prod` file with the correct environment variables for `NOW_ORG_ID` and `NOW_PROJECT_ID`.
50 |
--------------------------------------------------------------------------------
/api/src/schema/mutations/resetPassword.ts:
--------------------------------------------------------------------------------
1 | import { mutationField, stringArg } from '@nexus/schema';
2 | import bcrypt from 'bcrypt';
3 | import jwt from 'jsonwebtoken';
4 | import analytics from '../../utils/analytics';
5 | import { verifyEnvironmentVariables } from '../../utils/verifyEnvironmentVariables';
6 |
7 | export const resetPasswordMutationField = mutationField('resetPassword', {
8 | type: 'User',
9 | args: {
10 | password: stringArg(),
11 | confirmPassword: stringArg(),
12 | resetToken: stringArg(),
13 | },
14 | resolve: async (_, { password, confirmPassword, resetToken }, ctx) => {
15 | if (password !== confirmPassword) {
16 | throw new Error("Passwords don't match!");
17 | }
18 | const [user] = await ctx.prisma.user.findMany({
19 | where: {
20 | resetToken: resetToken,
21 | resetTokenExpiry: { gte: Date.now() - 3600000 },
22 | },
23 | });
24 | if (!user) {
25 | throw new Error('This token is either invalid or expired!');
26 | }
27 |
28 | const hashedPassword = await bcrypt.hash(password, 10);
29 |
30 | const updatedUser = await ctx.prisma.user.update({
31 | where: { email: user.email },
32 | data: {
33 | password: hashedPassword,
34 | resetToken: null,
35 | resetTokenExpiry: null,
36 | },
37 | });
38 | verifyEnvironmentVariables(process.env.API_APP_SECRET, 'API_APP_SECRET');
39 | const token = jwt.sign({ userId: updatedUser.id }, process.env.API_APP_SECRET);
40 |
41 | ctx.response.cookie('token', token, {
42 | httpOnly: true,
43 | maxAge: 1000 * 60 * 60 * 24 * 365,
44 | sameSite: 'none',
45 | secure: true,
46 | // domain: process.env.API_COOKIE_DOMAIN,
47 | });
48 |
49 | analytics.track({ eventType: 'Reset password success' });
50 |
51 | return updatedUser;
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/web-app/components/Product/ProductMutation.ts:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | export const CREATE_PRODUCT = gql`
3 | mutation createProduct(
4 | $name: String!
5 | $description: String!
6 | $price: Int!
7 | $discount: Int!
8 | $salePrice: Int!
9 | $sku: String!
10 | $unit: String!
11 | $categoryId: ID!
12 | $images: [ProductImageCreateWithoutProductInput!]!
13 |
14 | ) {
15 | createProduct(
16 | name: $name
17 | description: $description
18 | price: $price
19 | discount: $discount
20 | salePrice: $salePrice
21 | sku: $sku
22 | unit: $unit
23 | categoryId: $categoryId
24 | images: $images
25 | ) {
26 | id
27 | name
28 | description
29 | price
30 | discount
31 | salePrice
32 | sku
33 | unit
34 | Category {
35 | name
36 | parent
37 | }
38 | ProductImages {
39 | image
40 | }
41 | }
42 | }
43 | `;
44 |
45 | export const UPDATE_PRODUCT_MUTATION = gql`
46 | mutation UPDATE_PRODUCT_MUTATION (
47 | $id: ID!
48 | $name: String!
49 | $description: String!
50 | $price: Int!
51 | $discount: Int!
52 | $salePrice: Int!
53 | $sku: String!
54 | $unit: String!
55 | $categoryId: ID!
56 | $images: [ProductImageCreateWithoutProductInput!]!
57 | $alreadyUploadedImages: [ProductImageCreateWithoutProductInput!]!
58 | ) {
59 | updateProduct(
60 | id: $id
61 | name: $name
62 | description: $description
63 | discount: $discount
64 | salePrice: $salePrice
65 | sku: $sku
66 | price: $price
67 | unit: $unit
68 | categoryId: $categoryId
69 | images: $images
70 | alreadyUploadedImages: $alreadyUploadedImages
71 | ) {
72 | id
73 | name
74 | description
75 | price
76 | discount
77 | salePrice
78 | sku
79 | unit
80 | Category {
81 | name
82 | parent
83 | }
84 | ProductImages {
85 | id
86 | image
87 | }
88 | }
89 | }
90 | `;
--------------------------------------------------------------------------------
/api/src/schema/mutations/requestPasswordReset.ts:
--------------------------------------------------------------------------------
1 | import { promisify } from 'util';
2 | import { randomBytes } from 'crypto';
3 | import { mutationField, stringArg } from '@nexus/schema';
4 | // import { sendEmail } from '../../utils/mail';
5 | import analytics from '../../utils/analytics';
6 | const { transport, makeANiceEmail } = require('../../mail');
7 |
8 | export const requestPasswordResetMutationField = mutationField('requestPasswordReset', {
9 | type: 'Boolean',
10 | args: {
11 | email: stringArg(),
12 | },
13 | resolve: async (_, { email }, ctx) => {
14 | const user = await ctx.prisma.user.findOne({ where: { email } });
15 | if (!user) {
16 | throw new Error(`No user found for ${email}`);
17 | }
18 |
19 | const randomBytesPromisified = promisify(randomBytes);
20 | const resetToken = (await randomBytesPromisified(20)).toString('hex');
21 | const resetTokenExpiry = Date.now() + 3600000; // 1 hour from now
22 | await ctx.prisma.user.update({
23 | where: { email },
24 | data: { resetToken, resetTokenExpiry },
25 | });
26 |
27 | // 3. Email them that reset token
28 | const mailRes = await transport.sendMail({
29 | from: process.env.FROM_EMAIL,
30 | to: user.email,
31 | subject: 'Your Password Reset Token',
32 | html: makeANiceEmail(`Your Password Reset Token is here!
33 | \n\n
34 | Click Here to Reset`),
35 | });
36 |
37 | // await sendEmail({
38 | // subject: 'Your password reset token',
39 | // toAddress: [user.email],
40 | // text: `Your Password Reset Token is here!
41 | // \n\n
42 | // Click Here to Reset`,
43 | // });
44 |
45 | analytics.track({ eventType: 'Reset password request' });
46 |
47 | return true;
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/web-app/utils/hooks/usePaginationQuery.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { QueryParamKeys, defaultNumberOfTableRows } from '../constants';
3 | import {
4 | QueryCategoriesOrderByInput,
5 | QueryUsersOrderByInput,
6 | OrderByArg,
7 | } from '../../graphql/generated/graphql-global-types';
8 |
9 | export type TableOrderBy = QueryCategoriesOrderByInput | QueryUsersOrderByInput;
10 |
11 | // Converts a union type to an intersectino type: https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type
12 | type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
13 |
14 | export type OrderByQueryParamKeys = keyof UnionToIntersection;
15 |
16 | export type PaginationQuery = {
17 | page: number;
18 | pageSize: number;
19 | orderBy: OrderByQueryParamKeys;
20 | direction: OrderByArg;
21 | };
22 |
23 | /**
24 | * Hook to be used to manage pagination related URL queries
25 | */
26 | export const usePaginationQuery = ({
27 | page: defaultPage = 1,
28 | pageSize: defaultPageSize = defaultNumberOfTableRows,
29 | orderBy: defaultOrderBy = 'updatedAt',
30 | direction: defaultDirection = OrderByArg.desc,
31 | }: Partial): PaginationQuery & {
32 | setQuery: ({ page, pageSize, orderBy }: Partial) => void;
33 | } => {
34 | const router = useRouter();
35 | const page = parseInt(router.query[QueryParamKeys.PAGE] as string, 10) || defaultPage;
36 | const pageSize = parseInt(router.query[QueryParamKeys.PAGE_SIZE] as string, 10) || defaultPageSize;
37 | const orderBy = (router.query[QueryParamKeys.ORDER_BY] as OrderByQueryParamKeys) || defaultOrderBy;
38 | const direction = (router.query[QueryParamKeys.DIRECTION] as OrderByArg) || defaultDirection;
39 |
40 | const setQuery = (newQuery: Partial): void => {
41 | router.push({
42 | pathname: router.pathname,
43 | query: {
44 | ...router.query,
45 | ...newQuery,
46 | },
47 | });
48 | };
49 |
50 | return {
51 | page,
52 | orderBy,
53 | direction,
54 | pageSize,
55 | setQuery,
56 | };
57 | };
58 |
--------------------------------------------------------------------------------
/web-app/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | webpack: (config, { isServer }) => {
3 | config.module.rules.push({
4 | test: /\.svg$/,
5 | use: ['@svgr/webpack'],
6 | });
7 |
8 | config.module.rules.push({
9 | test: /\.(jpe?g|png|gif|ico|webp)$/,
10 | use: [
11 | {
12 | loader: require.resolve('url-loader'),
13 | options: {
14 | fallback: require.resolve('file-loader'),
15 | outputPath: `${isServer ? '../' : ''}static/images/`,
16 | // limit: config.inlineImageLimit,
17 | // publicPath: `${config.assetPrefix}/_next/static/images/`,
18 | // esModule: config.esModule || false,
19 | name: '[name]-[hash].[ext]',
20 | },
21 | },
22 | ],
23 | });
24 |
25 | // Fixes npm packages that depend on `fs` module
26 | // https://github.com/webpack-contrib/css-loader/issues/447
27 | // https://github.com/zeit/next.js/issues/7755
28 | if (!isServer) {
29 | config.node = {
30 | fs: 'empty',
31 | };
32 | }
33 |
34 | return config;
35 | },
36 | // This makes the environment variables available at runtime
37 | // Make sure to set the environment variables in now.json in order to have the environment variables in a deployed environment
38 | env: {
39 | COMMON_BACKEND_URL: process.env.COMMON_BACKEND_URL,
40 | COMMON_FRONTEND_URL: process.env.COMMON_FRONTEND_URL,
41 | COMMON_STRIPE_YEARLY_PLAN_ID: process.env.COMMON_STRIPE_YEARLY_PLAN_ID,
42 | COMMON_STRIPE_MONTHLY_PLAN_ID: process.env.COMMON_STRIPE_MONTHLY_PLAN_ID,
43 | WEB_APP_STRIPE_PUBLISHABLE_KEY: process.env.WEB_APP_STRIPE_PUBLISHABLE_KEY,
44 | WEB_APP_GOOGLE_API_KEY: process.env.WEB_APP_GOOGLE_API_KEY,
45 | WEB_APP_MARKETING_SITE: process.env.WEB_APP_MARKETING_SITE,
46 | WEB_APP_SENTRY_DSN: process.env.WEB_APP_SENTRY_DSN,
47 | IMAGE_UPLOAD_URL: process.env.IMAGE_UPLOAD_URL,
48 | IMAGE_UPLOAD_PRESET: process.env.IMAGE_UPLOAD_PRESET,
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/api/src/schema/mutations/createProduct.ts:
--------------------------------------------------------------------------------
1 | import { mutationField, idArg, stringArg, intArg, arg, booleanArg } from '@nexus/schema';
2 | import analytics from '../../utils/analytics';
3 | import { verifyUserIsAuthenticated } from '../../utils/verifyUserIsAuthenticated';
4 |
5 | export const createProductMutationField = mutationField('createProduct', {
6 | type: 'Product',
7 | args: {
8 | name: stringArg({ required: true }),
9 | description: stringArg({ required: true }),
10 | price: intArg({ required: true }),
11 | discount: intArg({ required: true }),
12 | salePrice: intArg({ required: true }),
13 | sku: stringArg({ required: true }),
14 | unit: stringArg({ required: true }),
15 | categoryId: idArg({ required: true }),
16 | images: arg({
17 | type: 'ProductImageCreateWithoutProductInput',
18 | list: true,
19 | required: true,
20 | }),
21 | },
22 | // name description price discount salePrice sku unit category* user*
23 | resolve: async (_, { name, description, price, discount, salePrice, sku, unit, categoryId, images }, ctx) => {
24 | verifyUserIsAuthenticated(ctx.user);
25 | if (process.env.IS_DEMO_ACCOUNT === 'true') {
26 | throw Error('Sorry, you can\'t do update or delete in DEMO account');
27 | }
28 | const user = await ctx.prisma.user.findOne({
29 | where: { id: ctx.user.id }
30 | });
31 |
32 | const newProduct = await ctx.prisma.product.create({
33 | data: {
34 | User: { connect: { id: ctx.user.id } },
35 | Category: { connect: { id: categoryId } },
36 | name,
37 | description,
38 | price,
39 | discount,
40 | salePrice,
41 | sku,
42 | unit,
43 | ProductImages: {
44 | create: images
45 | }
46 | },
47 | });
48 |
49 | analytics.track({
50 | eventType: 'Product created',
51 | userId: ctx.user.id,
52 | eventProperties: {
53 | id: newProduct.id,
54 | },
55 | });
56 | return newProduct;
57 | },
58 | });
59 |
--------------------------------------------------------------------------------
/web-app/components/Dashboard/TrafficByDevice.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import PropTypes from 'prop-types';
4 | import loadable from '@loadable/component'
5 | const Chart = loadable(() => import('react-apexcharts'));
6 |
7 | import {
8 | Box,
9 | Card,
10 | CardContent,
11 | CardHeader,
12 | Divider,
13 | Typography,
14 | colors,
15 | makeStyles,
16 | useTheme
17 | } from '@material-ui/core';
18 | import LaptopMacIcon from '@material-ui/icons/LaptopMac';
19 | import PhoneIcon from '@material-ui/icons/Phone';
20 | import TabletIcon from '@material-ui/icons/Tablet';
21 |
22 | const useStyles = makeStyles(() => ({
23 | root: {
24 | height: '100%'
25 | },
26 | devicePercent: {
27 | fontWeight: 400,
28 | }
29 | }));
30 |
31 | type Props = {
32 | className: string
33 | };
34 |
35 | const TrafficByDevice: React.FC = ({ className, ...rest }) => {
36 | const classes = useStyles();
37 | const theme = useTheme();
38 |
39 | const series = [30, 10, 60]
40 | const label = ['Tablet', 'Mobile', 'Laptop']
41 | const options: any = {
42 | chart: {
43 | type: 'donut',
44 | },
45 | plotOptions: {
46 | pie: {
47 | startAngle: -90,
48 | endAngle: 270
49 | }
50 | },
51 | dataLabels: {
52 | enabled: true
53 | },
54 | fill: {
55 | type: 'gradient',
56 | },
57 | legend: {
58 | formatter: function (val: any, opts: any) {
59 | return label[opts.seriesIndex]
60 | }
61 | },
62 | title: {
63 | text: ''
64 | },
65 | responsive: [{
66 | breakpoint: 2480,
67 | options: {
68 | chart: {
69 | width: 370
70 | },
71 | legend: {
72 | position: 'bottom'
73 | }
74 | }
75 | }]
76 | }
77 |
78 | return (
79 |
83 |
84 |
85 |
86 |
87 |
92 |
93 |
94 |
95 |
96 | );
97 | };
98 |
99 | export default TrafficByDevice;
100 |
--------------------------------------------------------------------------------
/web-app/components/App/App.tsx:
--------------------------------------------------------------------------------
1 | import Router from 'next/router';
2 | import { NextComponentType, NextPageContext } from 'next';
3 | import { useQuery } from '@apollo/react-hooks';
4 | import { useEffect } from 'react';
5 | import { PageProps } from '../../pages/_app';
6 | import Layout from '../Layout/Layout';
7 | import Loader from '../Loader/Loader';
8 | import { CurrentUserQuery } from '../../graphql/generated/CurrentUserQuery';
9 | import { currentUserQuery } from '../../graphql/queries';
10 | import LoginLayout from '../LoginLayout/LoginLayout';
11 |
12 | type Props = {
13 | component: NextComponentType;
14 | query: PageProps['query'];
15 | pathname: PageProps['pathname'];
16 | req?: NextPageContext['req'];
17 | };
18 |
19 | export type ComponentPageProps = Pick & {
20 | user: CurrentUserQuery['me'];
21 | };
22 |
23 | const unauthenticatedPathnames = ['/login', '/reset-password', '/signup'];
24 |
25 | const App = ({ component: Component, query, pathname, ...props }: Props): JSX.Element | null => {
26 | const { data: currentUserData, loading: currentUserLoading } = useQuery(currentUserQuery);
27 |
28 | const isAnUnauthenticatedPage = pathname !== undefined && unauthenticatedPathnames.includes(pathname);
29 | // Need to wrap calls of `Router.replace` in a use effect to prevent it being called on the server side
30 | // https://github.com/zeit/next.js/issues/6713
31 | useEffect(() => {
32 | // Redirect the user to the login page if not authenticated
33 | if (!isAnUnauthenticatedPage && currentUserData?.me === null) {
34 | Router.replace('/login');
35 | }
36 | }, [currentUserData, isAnUnauthenticatedPage, pathname]);
37 |
38 | if (currentUserLoading) {
39 | return ;
40 | }
41 |
42 | if (!isAnUnauthenticatedPage && currentUserData?.me === null) {
43 | return null;
44 | }
45 |
46 | if (!currentUserData) {
47 | return null;
48 | }
49 |
50 | return pathname !== undefined && unauthenticatedPathnames.includes(pathname) ? (
51 |
52 |
53 |
54 | ) : (
55 |
56 |
57 |
58 | );
59 | };
60 |
61 | export default App;
62 |
--------------------------------------------------------------------------------
/web-app/components/Material/ErrorMessage.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 | import Button from '@material-ui/core/Button';
5 | import Snackbar from '@material-ui/core/Snackbar';
6 | import IconButton from '@material-ui/core/IconButton';
7 | import CloseIcon from '@material-ui/icons/Close';
8 |
9 | const ErrorStyles = styled.div`
10 | padding: 2rem;
11 | background: white;
12 | margin: 2rem 0;
13 | border: 1px solid rgba(0, 0, 0, 0.05);
14 | border-left: 5px solid red;
15 | p {
16 | margin: 0;
17 | font-weight: 100;
18 | }
19 | strong {
20 | margin-right: 1rem;
21 | }
22 | `;
23 |
24 | const DisplayError = ({ error }) => {
25 |
26 | const [open, setOpen] = React.useState(true);
27 |
28 | const handleClose = (event, reason) => {
29 | if (reason === 'clickaway') {
30 | return;
31 | }
32 | setOpen(false);
33 | };
34 |
35 | if (!error || !error.message) return null;
36 | if (error.networkError && error.networkError.result && error.networkError.result.errors.length) {
37 | return error.networkError.result.errors.map((error, i) => (
38 |
39 |
40 | Shoot!
41 | {error.message.replace('GraphQL error: ', '')}
42 |
43 |
44 | ));
45 | }
46 | return (
47 |
48 |
49 | Shoot!
50 | {error.message.replace('GraphQL error: ', '')}
51 |
52 |
63 |
66 |
67 |
68 |
69 |
70 | }
71 | />
72 |
73 | );
74 | };
75 |
76 | DisplayError.defaultProps = {
77 | error: {},
78 | };
79 |
80 | DisplayError.propTypes = {
81 | error: PropTypes.object,
82 | };
83 |
84 | export default DisplayError;
85 |
--------------------------------------------------------------------------------
/web-app/utils/hooks/useModalQuery.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useCallback } from 'react';
3 | import { QueryParamKeys } from '../constants';
4 |
5 | /**
6 | * Hook to be used for modals that need a query to persist in the URL
7 | */
8 | export const useModalQuery = (
9 | query: QueryParamKeys,
10 | id?: string
11 | ): {
12 | isOpen: boolean;
13 | onOpen: (options?: {
14 | queryToExclude?: QueryParamKeys;
15 | newlyCreatedId?: string;
16 | additionalQueries?: Partial<
17 | {
18 | [query in QueryParamKeys]: string;
19 | }
20 | >;
21 | }) => Promise;
22 | onClose: (additionalQuery?: { [key: string]: string }) => Promise;
23 | } => {
24 | const router = useRouter();
25 |
26 | const onOpen = useCallback(
27 | ({
28 | queryToExclude,
29 | newlyCreatedId,
30 | additionalQueries = {},
31 | }: {
32 | queryToExclude?: QueryParamKeys;
33 | newlyCreatedId?: string;
34 | additionalQueries?:
35 | | {
36 | [query in QueryParamKeys]: string;
37 | }
38 | | {};
39 | } = {}): Promise => {
40 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
41 | const { [queryToExclude ?? '']: excluded, ...existingQueries } = router.query;
42 |
43 | return router.push({
44 | pathname: router.pathname,
45 | query: {
46 | ...existingQueries,
47 | ...additionalQueries,
48 | [query]: id ?? newlyCreatedId ?? true,
49 | },
50 | });
51 | },
52 | [id, query, router]
53 | );
54 |
55 | const onClose = useCallback(
56 | (additionalQuery: { [key: string]: string } = {}): Promise => {
57 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
58 | const { [query]: queryToRemove, ...queryWithoutJob } = router.query;
59 |
60 | return router.push({
61 | pathname: router.pathname,
62 | query: {
63 | ...queryWithoutJob,
64 | ...additionalQuery,
65 | },
66 | });
67 | },
68 | [query, router]
69 | );
70 |
71 | return {
72 | isOpen: id ? router.query[query] === id : Boolean(router.query[query]),
73 | onOpen,
74 | onClose,
75 | };
76 | };
77 |
--------------------------------------------------------------------------------
/web-app/assets/icons/google.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/api/src/schema/mutations/signup.ts:
--------------------------------------------------------------------------------
1 | import { stringArg, mutationField } from '@nexus/schema';
2 | import bcrypt from 'bcrypt';
3 | import jwt from 'jsonwebtoken';
4 | import { generateToken } from '../../utils/generateToken';
5 | import { cookieDuration } from '../../utils/constants';
6 | import analytics from '../../utils/analytics';
7 | import { verifyEnvironmentVariables } from '../../utils/verifyEnvironmentVariables';
8 |
9 | export const signupMutationField = mutationField('signup', {
10 | type: 'User',
11 | args: {
12 | name: stringArg(),
13 | email: stringArg(),
14 | password: stringArg(),
15 | confirmPassword: stringArg(),
16 | },
17 | resolve: async (_, { name, email, password, confirmPassword }, ctx) => {
18 | if (password !== confirmPassword) {
19 | throw new Error("Passwords don't match!");
20 | }
21 | email = email?.toLowerCase();
22 | if (email === null || email === undefined) {
23 | throw Error('Email is not defined');
24 | }
25 | // Check if user already exists with that email
26 | const existingUser = await ctx.prisma.user.findOne({ where: { email } });
27 | if (existingUser) {
28 | if (existingUser.googleId) {
29 | throw new Error(`User with that email already exists. Sign in with Google.`);
30 | }
31 | throw new Error(`User with that email already exists.`);
32 | }
33 |
34 | const hashedPassword = await bcrypt.hash(password, 10);
35 |
36 | const emailConfirmationToken = await generateToken();
37 |
38 | const user = await ctx.prisma.user.create({
39 | data: {
40 | name: 'test',
41 | email,
42 | password: hashedPassword,
43 | emailConfirmationToken,
44 | },
45 | });
46 |
47 | verifyEnvironmentVariables(process.env.API_APP_SECRET, 'API_APP_SECRET');
48 | const token = jwt.sign({ userId: user.id }, process.env.API_APP_SECRET);
49 |
50 | // NOTE: Need to specify domain in order for front-end to see cookie: https://github.com/apollographql/apollo-client/issues/4193#issuecomment-573195699
51 | ctx.response.cookie('token', token, {
52 | httpOnly: true,
53 | maxAge: cookieDuration,
54 | sameSite: 'none',
55 | secure: true,
56 | // domain: process.env.API_COOKIE_DOMAIN,
57 | });
58 |
59 | analytics.track({ eventType: 'Signup', userId: user.id, eventProperties: { method: 'Password' } });
60 |
61 | return user;
62 | },
63 | });
64 |
--------------------------------------------------------------------------------
/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextgraphqladmin-backend",
3 | "description": "Back-end for Admin",
4 | "version": "0.0.0",
5 | "private": true,
6 | "scripts": {
7 | "postinstall": "yarn generate",
8 | "start": "ts-node src/index.ts",
9 | "dev": "ts-node-dev --no-notify --respawn --transpileOnly ./src/",
10 | "generate": "yarn generate:prisma && yarn generate:nexus",
11 | "generate:prisma": "prisma generate",
12 | "generate:nexus": "ts-node --transpile-only src/schema",
13 | "stripe": "stripe listen --forward-to local.api.nextgraphqladmin.com:4000/stripe",
14 | "seed": "ts-node ./seeds"
15 | },
16 | "dependencies": {
17 | "@nexus/schema": "^0.13.1",
18 | "@prisma/client": "^2.0.0-beta.1",
19 | "amplitude": "^4.0.1",
20 | "aws-sdk": "^2.649.0",
21 | "bcrypt": "^3.0.7",
22 | "body-parser": "^1.19.0",
23 | "cookie-parser": "^1.4.4",
24 | "cuid": "^2.1.8",
25 | "date-fns": "^2.9.0",
26 | "dotenv": "^8.2.0",
27 | "dotenv-flow": "^3.1.0",
28 | "express-session": "^1.17.0",
29 | "formidable": "^1.2.1",
30 | "graphql": "^14.5.8",
31 | "graphql-upload": "^8.1.0",
32 | "graphql-yoga": "^1.18.3",
33 | "jsonwebtoken": "^8.5.1",
34 | "nexus": "^0.12.0",
35 | "nexus-prisma": "^0.12.0",
36 | "node-fetch": "^2.6.0",
37 | "nodemailer": "^6.4.14",
38 | "passport": "^0.4.0",
39 | "passport-google-oauth20": "^2.0.0",
40 | "stripe": "^7.15.0",
41 | "ts-node": "^8.8.2",
42 | "uuid": "^3.3.3"
43 | },
44 | "devDependencies": {
45 | "fs": "0.0.1-security",
46 | "@prisma/cli": "2.0.0-beta.1",
47 | "@types/bcrypt": "^3.0.0",
48 | "@types/cookie-parser": "^1.4.2",
49 | "@types/express-session": "^1.17.0",
50 | "@types/faker": "^4.1.9",
51 | "@types/formidable": "^1.0.31",
52 | "@types/graphql-upload": "^8.0.3",
53 | "@types/jsonwebtoken": "^8.3.5",
54 | "@types/node": "^12.12.8",
55 | "@types/passport": "^1.0.1",
56 | "@types/passport-google-oauth20": "^2.0.3",
57 | "@types/stripe": "^7.13.19",
58 | "@types/uuid": "^3.4.6",
59 | "faker": "^4.1.0",
60 | "knex": "^0.20.10",
61 | "mysql": "^2.18.1",
62 | "now": "^16.7.0",
63 | "ts-node-dev": "^1.0.0-pre.44",
64 | "typescript": "3.8.3"
65 | },
66 | "engines": {
67 | "node": "12",
68 | "yarn": "^1.16.0"
69 | },
70 | "resolutions": {
71 | "graphql": "^14.5.8"
72 | }
73 | }
--------------------------------------------------------------------------------
/web-app/components/Dashboard/DailyVisitsInsight.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import loadable from '@loadable/component'
4 |
5 | const Chart = loadable(() => import('react-apexcharts'));
6 |
7 | import {
8 | Box,
9 | Button,
10 | Card,
11 | CardContent,
12 | CardHeader,
13 | Divider,
14 | useTheme,
15 | makeStyles,
16 | colors
17 | } from '@material-ui/core';
18 | import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
19 | import ArrowRightIcon from '@material-ui/icons/ArrowRight';
20 |
21 | const useStyles = makeStyles(() => ({
22 | root: {}
23 | }));
24 |
25 | type Props = {
26 | className: string
27 | };
28 |
29 | const DailyVisitsInsight: React.FC = ({ className, ...rest }) => {
30 | const classes = useStyles();
31 | const theme = useTheme();
32 |
33 | const options = {
34 | series: [{
35 | name: 'Day',
36 | data: [31, 40, 28, 51, 42, 109, 100]
37 | }, {
38 | name: 'Night',
39 | data: [11, 32, 45, 32, 34, 52, 41]
40 | }],
41 | options: {
42 | chart: {
43 | height: 350,
44 | type: 'area'
45 | },
46 | dataLabels: {
47 | enabled: false
48 | },
49 | stroke: {
50 | curve: 'smooth'
51 | },
52 | xaxis: {
53 | type: 'datetime',
54 | categories: ["2018-09-19T00:00:00.000Z", "2018-09-19T01:30:00.000Z", "2018-09-19T02:30:00.000Z", "2018-09-19T03:30:00.000Z", "2018-09-19T04:30:00.000Z", "2018-09-19T05:30:00.000Z", "2018-09-19T06:30:00.000Z"]
55 | },
56 | tooltip: {
57 | x: {
58 | format: 'dd/MM/yy HH:mm'
59 | },
60 | },
61 | },
62 | };
63 |
64 | return (
65 |
69 | }
73 | size="small"
74 | variant="text"
75 | >
76 | Last 7 days
77 |
78 | )}
79 | title="Daily Visits Insights"
80 | />
81 |
82 |
83 |
87 |
88 |
94 |
95 |
96 |
97 |
98 |
99 | );
100 | };
101 |
102 | export default DailyVisitsInsight;
--------------------------------------------------------------------------------
/web-app/public/images/discuss-issue.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
55 |
--------------------------------------------------------------------------------
/web-app/utils/hooks/useGooglePlacesService.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState, Dispatch, SetStateAction, ChangeEvent } from 'react';
2 | import debounce from 'lodash.debounce';
3 | import { useGooglePlacesScript } from './useGooglePlacesScript';
4 |
5 | type GooglePlacesOption = { googlePlacesId: string; name: string; id: string };
6 |
7 | export function useGooglePlacesService(): [
8 | (e: ChangeEvent) => void,
9 | GooglePlacesOption[],
10 | boolean,
11 | string,
12 | Dispatch>
13 | ] {
14 | const service = useGooglePlacesScript();
15 |
16 | const [isLoadingLocationResults, setIsLoadingLocationResults] = useState(false);
17 |
18 | const [googleMapsAutocompleteResults, setGoogleMapsAutocompleteResults] = useState<
19 | google.maps.places.AutocompletePrediction[]
20 | >();
21 |
22 | const [location, setLocation] = useState('');
23 |
24 | const debouncedGoogleMapsCall = useCallback(
25 | debounce<(searchQuery: string) => Promise>(async (searchQuery): Promise => {
26 | if (service && searchQuery) {
27 | await new Promise((resolve) => {
28 | service.getQueryPredictions(
29 | {
30 | input: searchQuery,
31 | },
32 | async (results, status) => {
33 | if (status === 'OK') {
34 | setGoogleMapsAutocompleteResults(
35 | results as google.maps.places.AutocompletePrediction[]
36 | );
37 | }
38 | resolve();
39 | }
40 | );
41 | });
42 | }
43 | setIsLoadingLocationResults(false);
44 | }, 500),
45 | [service, setGoogleMapsAutocompleteResults, setIsLoadingLocationResults]
46 | );
47 |
48 | const locationOptions: GooglePlacesOption[] = googleMapsAutocompleteResults
49 | ? googleMapsAutocompleteResults.map((location) => ({
50 | id: location.place_id,
51 | googlePlacesId: location.place_id,
52 | name: location.description,
53 | }))
54 | : [];
55 |
56 | const handleLocationSearchQueryChange = (e: ChangeEvent): void => {
57 | setLocation(e.target.value);
58 | setIsLoadingLocationResults(true);
59 | debouncedGoogleMapsCall(e.target.value);
60 | };
61 |
62 | return [handleLocationSearchQueryChange, locationOptions, isLoadingLocationResults, location, setLocation];
63 | }
64 |
--------------------------------------------------------------------------------
/web-app/components/Dashboard/TotalProfit.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import {
4 | Avatar,
5 | Box,
6 | Card,
7 | CardContent,
8 | CardActions,
9 | Grid,
10 | Typography,
11 | colors,
12 | makeStyles
13 | } from '@material-ui/core';
14 |
15 |
16 | const cardColor = colors.blue['500']
17 |
18 | const useStyles = makeStyles((theme) => ({
19 | root: {
20 | height: '100%'
21 | },
22 | avatar: {
23 | height: 56,
24 | width: 56
25 | },
26 | differenceIcon: {
27 | color: colors.green[900]
28 | },
29 | differenceValue: {
30 | color: colors.green[900],
31 | marginRight: theme.spacing(1)
32 | },
33 | cardHeader: {
34 | color: cardColor,
35 | fontWeight: 400,
36 | textTransform: 'uppercase'
37 | },
38 | amount: {
39 | fontWeight: 300
40 | },
41 | bgCover: {
42 | backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,0.8) 0%,rgba(255,255,255,0.8) 100%), url(/images/money-bag.svg)",
43 | backgroundAttachment: "static",
44 | backgroundPosition: "right",
45 | backgroundRepeat: "no-repeat",
46 | backgroundSize: "30%",
47 | borderLeft: `5px solid ${cardColor}`,
48 | },
49 | actionBar: {
50 | background: "linear-gradient(90deg, rgba(187,179,179,1) 0%, rgba(255,255,255,1) 100%)",
51 | borderLeft: `5px solid ${cardColor}`,
52 | padding: '10px'
53 | }
54 |
55 | }));
56 |
57 | type Props = {
58 | className: string
59 | };
60 |
61 | const Resolution: React.FC = ({ className, ...rest }) => {
62 |
63 | const classes = useStyles();
64 |
65 | return (
66 |
70 |
71 |
72 |
77 |
78 |
83 | PROFIT
84 |
85 |
90 | $128,678
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
102 | Total profit this year
103 |
104 |
105 |
106 | );
107 | };
108 | export default Resolution;
109 |
--------------------------------------------------------------------------------
/web-app/components/Dashboard/Resolution.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import {
4 | Avatar,
5 | Box,
6 | Card,
7 | CardContent,
8 | CardActions,
9 | Grid,
10 | Typography,
11 | colors,
12 | makeStyles
13 | } from '@material-ui/core';
14 |
15 | const cardColor = colors.orange['500']
16 |
17 | const useStyles = makeStyles((theme) => ({
18 | root: {
19 | height: '100%'
20 | },
21 | avatar: {
22 | height: 56,
23 | width: 56
24 | },
25 | differenceIcon: {
26 | color: colors.green[900]
27 | },
28 | differenceValue: {
29 | color: colors.green[900],
30 | marginRight: theme.spacing(1)
31 | },
32 | cardHeader: {
33 | color: cardColor,
34 | fontWeight: 400,
35 | textTransform: 'uppercase'
36 | },
37 | amount: {
38 | fontWeight: 300
39 | },
40 | bgCover: {
41 | backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,0.8) 0%,rgba(255,255,255,0.8) 100%), url(/images/conversation.svg)",
42 | backgroundAttachment: "static",
43 | backgroundPosition: "right",
44 | backgroundRepeat: "no-repeat",
45 | backgroundSize: "30%",
46 | borderLeft: `5px solid ${cardColor}`,
47 | },
48 | actionBar: {
49 | background: "linear-gradient(90deg, rgba(187,179,179,1) 0%, rgba(255,255,255,1) 100%)",
50 | borderLeft: `5px solid ${cardColor}`,
51 | padding: '10px'
52 | }
53 |
54 | }));
55 |
56 | type Props = {
57 | className: string
58 | };
59 |
60 | const Resolution: React.FC = ({ className, ...rest }) => {
61 |
62 | const classes = useStyles();
63 |
64 | return (
65 |
69 |
70 |
71 |
76 |
77 |
82 | RESOLUTION
83 |
84 |
89 | 50%
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
101 | Total customer resolution pending
102 |
103 |
104 |
105 | );
106 | };
107 | export default Resolution;
108 |
--------------------------------------------------------------------------------
/web-app/components/Dashboard/SaleCategoryAnalysis.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import loadable from '@loadable/component'
4 |
5 | const Chart = loadable(() => import('react-apexcharts'));
6 |
7 | import {
8 | Box,
9 | Button,
10 | Card,
11 | CardContent,
12 | CardHeader,
13 | Divider,
14 | useTheme,
15 | makeStyles,
16 | colors
17 | } from '@material-ui/core';
18 | import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
19 | import ArrowRightIcon from '@material-ui/icons/ArrowRight';
20 |
21 | const useStyles = makeStyles(() => ({
22 | root: {}
23 | }));
24 |
25 | type Props = {
26 | className: string
27 | };
28 |
29 | const SaleCategoryAnalysis: React.FC = ({ className, ...rest }) => {
30 | const classes = useStyles();
31 | const theme = useTheme();
32 |
33 | const options = {
34 | series: [{
35 | name: 'Grocery',
36 | data: [44, 55, 41, 67, 22, 43, 21, 49]
37 | }, {
38 | name: 'Clothing',
39 | data: [13, 23, 20, 8, 13, 27, 33, 12]
40 | }, {
41 | name: 'Furniture',
42 | data: [11, 17, 15, 15, 21, 14, 15, 13]
43 | }],
44 | options: {
45 | chart: {
46 | type: 'bar',
47 | height: 350,
48 | stacked: true,
49 | stackType: '100%'
50 | },
51 | responsive: [{
52 | breakpoint: 480,
53 | options: {
54 | legend: {
55 | position: 'bottom',
56 | offsetX: -10,
57 | offsetY: 0
58 | }
59 | }
60 | }],
61 | xaxis: {
62 | categories: ['2011 Q1', '2011 Q2', '2011 Q3', '2011 Q4', '2012 Q1', '2012 Q2',
63 | '2012 Q3', '2012 Q4'
64 | ],
65 | },
66 | fill: {
67 | opacity: 1
68 | },
69 | legend: {
70 | position: 'right',
71 | offsetX: 0,
72 | offsetY: 50
73 | },
74 | },
75 |
76 |
77 | };
78 |
79 | return (
80 |
84 | }
88 | size="small"
89 | variant="text"
90 | >
91 | Last 7 days
92 |
93 | )}
94 | title="Product Sales"
95 | />
96 |
97 |
98 |
102 |
103 |
109 |
110 |
111 |
112 |
113 |
114 | );
115 | };
116 |
117 | export default SaleCategoryAnalysis;
--------------------------------------------------------------------------------
/web-app/components/Dashboard/Customers.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | import {
5 | Card,
6 | CardContent,
7 | CardActions,
8 | Grid,
9 | Typography,
10 | colors,
11 | makeStyles
12 | } from '@material-ui/core';
13 | import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward';
14 |
15 | const cardColor = colors.green['500']
16 |
17 | const useStyles = makeStyles((theme) => ({
18 | root: {
19 | height: '100%'
20 | },
21 | avatar: {
22 | height: 56,
23 | width: 56
24 | },
25 | differenceIcon: {
26 | color: colors.green[900]
27 | },
28 | differenceValue: {
29 | color: colors.green[900],
30 | marginRight: theme.spacing(1)
31 | },
32 | cardHeader: {
33 | color: cardColor,
34 | fontWeight: 400,
35 | textTransform: 'uppercase'
36 | },
37 | amount: {
38 | fontWeight: 300
39 | },
40 | bgCover: {
41 | backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,0.8) 0%,rgba(255,255,255,0.8) 100%), url(/images/customers.svg)",
42 | backgroundAttachment: "static",
43 | backgroundPosition: "right",
44 | backgroundRepeat: "no-repeat",
45 | backgroundSize: "30%",
46 | borderLeft: `5px solid ${cardColor}`,
47 | },
48 | actionBar: {
49 | background: "linear-gradient(90deg, rgba(187,179,179,1) 0%, rgba(255,255,255,1) 100%)",
50 | borderLeft: `5px solid ${cardColor}`
51 | }
52 |
53 | }));
54 |
55 | type Props = {
56 | className: string
57 | };
58 |
59 | const Customers: React.FC = ({ className, ...rest }) => {
60 |
61 | const classes = useStyles();
62 |
63 | return (
64 |
68 |
69 |
70 |
75 |
76 |
81 | customers
82 |
83 |
88 | $24,000
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
100 | 122%
101 |
102 |
106 | Since last month
107 |
108 |
109 |
110 | );
111 | };
112 |
113 | export default Customers;
114 |
--------------------------------------------------------------------------------
/web-app/components/Dashboard/Sale.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import PropTypes from 'prop-types';
4 | import {
5 | Avatar,
6 | Box,
7 | Card,
8 | CardContent,
9 | CardActions,
10 | Grid,
11 | Typography,
12 | colors,
13 | makeStyles
14 | } from '@material-ui/core';
15 | import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward';
16 |
17 | const cardColor = colors.purple['500']
18 |
19 | const useStyles = makeStyles((theme) => ({
20 | root: {
21 | height: '100%'
22 | },
23 | avatar: {
24 | height: 56,
25 | width: 56
26 | },
27 | differenceIcon: {
28 | color: colors.red[900]
29 | },
30 | differenceValue: {
31 | color: colors.red[900],
32 | marginRight: theme.spacing(1)
33 | },
34 | cardHeader: {
35 | color: cardColor,
36 | fontWeight: 400
37 | },
38 | amount: {
39 | fontWeight: 300
40 | },
41 | bgCover: {
42 | backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,0.8) 0%,rgba(255,255,255,0.8) 100%), url(/images/sales.svg)",
43 | backgroundAttachment: "static",
44 | backgroundPosition: "right",
45 | backgroundRepeat: "no-repeat",
46 | backgroundSize: "30%",
47 | borderLeft: `5px solid ${cardColor}`,
48 | },
49 | actionBar: {
50 | background: "linear-gradient(90deg, rgba(187,179,179,1) 0%, rgba(255,255,255,1) 100%)",
51 | borderLeft: `5px solid ${cardColor}`
52 | }
53 |
54 | }));
55 |
56 | type Props = {
57 | className: string
58 | };
59 |
60 | const Sale: React.FC = ({ className, ...rest }) => {
61 |
62 | const classes = useStyles();
63 |
64 | return (
65 |
69 |
70 |
71 |
76 |
77 |
82 | SALE
83 |
84 |
89 | $24,000
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
101 | 122%
102 |
103 |
107 | Since last month
108 |
109 |
110 |
111 | );
112 | };
113 |
114 | export default Sale;
115 |
--------------------------------------------------------------------------------
/web-app/components/Dashboard/ProfitAnalysis.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import loadable from '@loadable/component'
4 |
5 | const Chart = loadable(() => import('react-apexcharts'));
6 |
7 | import {
8 | Box,
9 | Button,
10 | Card,
11 | CardContent,
12 | CardHeader,
13 | Divider,
14 | useTheme,
15 | makeStyles,
16 | colors
17 | } from '@material-ui/core';
18 | import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
19 | import ArrowRightIcon from '@material-ui/icons/ArrowRight';
20 |
21 | const useStyles = makeStyles(() => ({
22 | root: {}
23 | }));
24 |
25 | type Props = {
26 | className: string
27 | };
28 |
29 | const ProfitAnalysis: React.FC = ({ className, ...rest }) => {
30 | const classes = useStyles();
31 | const theme = useTheme();
32 |
33 | const options = {
34 | series: [{
35 | name: 'Net Profit',
36 | data: [44, 55, 57, 56, 61, 58, 63, 60, 66]
37 | }, {
38 | name: 'Revenue',
39 | data: [76, 85, 101, 98, 87, 105, 91, 114, 94]
40 | }, {
41 | name: 'Free Cash Flow',
42 | data: [35, 41, 36, 26, 45, 48, 52, 53, 41]
43 | }],
44 | options: {
45 | chart: {
46 | type: 'bar',
47 | height: 350
48 | },
49 | plotOptions: {
50 | bar: {
51 | horizontal: false,
52 | columnWidth: '55%',
53 | endingShape: 'rounded'
54 | },
55 | },
56 | dataLabels: {
57 | enabled: false
58 | },
59 | stroke: {
60 | show: true,
61 | width: 2,
62 | colors: ['transparent']
63 | },
64 | xaxis: {
65 | categories: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'],
66 | },
67 | yaxis: {
68 | title: {
69 | text: '$ (thousands)'
70 | }
71 | },
72 | fill: {
73 | opacity: 1
74 | },
75 | tooltip: {
76 | y: {
77 | formatter: function (val: any) {
78 | return "$ " + val + " thousands"
79 | }
80 | }
81 | }
82 | },
83 |
84 |
85 | };
86 |
87 | return (
88 |
92 | }
96 | size="small"
97 | variant="text"
98 | >
99 | Last 7 days
100 |
101 | )}
102 | title="Profit Analysis"
103 | />
104 |
105 |
106 |
110 |
111 |
117 |
118 |
119 |
120 |
121 |
122 | );
123 | };
124 |
125 | export default ProfitAnalysis;
--------------------------------------------------------------------------------
/web-app/utils/withApollo.ts:
--------------------------------------------------------------------------------
1 | import withApollo from 'next-with-apollo';
2 | import { ApolloClient } from 'apollo-client';
3 | import { InMemoryCache } from 'apollo-cache-inmemory';
4 | import { onError } from 'apollo-link-error';
5 | import { ApolloLink, Observable } from 'apollo-link';
6 | import { createUploadLink } from 'apollo-upload-client';
7 | import { resolvers, typeDefs } from './resolvers';
8 |
9 | export default withApollo(({ initialState, headers }) => {
10 | const request = (operation: any): void => {
11 | operation.setContext({
12 | fetchOptions: {
13 | credentials: 'include',
14 | },
15 | // https://github.com/apollographql/apollo-client/issues/4193#issuecomment-448682173
16 | headers: {
17 | cookie: headers && headers.cookie, // NOTE: client-side headers is undefined!
18 | },
19 | });
20 | };
21 |
22 | const requestLink = new ApolloLink(
23 | (operation, forward) =>
24 | new Observable((observer) => {
25 | let handle: any;
26 | Promise.resolve(operation)
27 | .then((oper) => request(oper))
28 | .then(() => {
29 | handle = forward(operation).subscribe({
30 | next: observer.next.bind(observer),
31 | error: observer.error.bind(observer),
32 | complete: observer.complete.bind(observer),
33 | });
34 | })
35 | .catch(observer.error.bind(observer));
36 |
37 | return (): void => {
38 | if (handle) {
39 | handle.unsubscribe();
40 | }
41 | };
42 | })
43 | );
44 |
45 | const cache = new InMemoryCache({ addTypename: false }).restore(initialState || {});
46 | cache.writeData({
47 | data: {
48 | isLeftDrawerOpen: false,
49 | leftDrawerWidth: 260,
50 | cartItems: [],
51 | snackMsg: 'default',
52 | snackType: 'success',
53 | snackBarOpen: false
54 | },
55 | });
56 |
57 |
58 | return new ApolloClient({
59 | link: ApolloLink.from([
60 | onError(({ graphQLErrors, networkError }) => {
61 | if (graphQLErrors) {
62 | graphQLErrors.forEach(({ message, locations, path }) =>
63 | // eslint-disable-next-line no-console
64 | console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`)
65 | );
66 | }
67 | if (networkError) {
68 | // eslint-disable-next-line no-console
69 | console.log(`[Network error]: ${networkError}`);
70 | }
71 | }),
72 | requestLink,
73 | createUploadLink({
74 | uri: process.env.COMMON_BACKEND_URL,
75 | }),
76 | ]),
77 | cache,
78 | resolvers,
79 | typeDefs,
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/api/README.md:
--------------------------------------------------------------------------------
1 | # API
2 |
3 | A Node.js GraphQL server built with [GraphQL Yoga](https://github.com/prisma/graphql-yoga).
4 |
5 | ## Getting started
6 |
7 | ### Pre-requisites
8 |
9 | The following must be installed locally in order to run the api and it's backing services:
10 |
11 | - yarn (https://classic.yarnpkg.com/en/docs/install/#mac-stable)
12 | - node (https://nodejs.org/en/download/)
13 |
14 | ### Host file
15 |
16 | Add the following line to your `/etc/hosts` file in order to alias your localhost to *local.app.nextgraphqladmin.com*
17 |
18 | ```text
19 | 127.0.0.1 local.api.nextgraphqladmin.com
20 | ```
21 | ### Install Instructions
22 | * Copy **.env.sample** and create **.env** file. Local environment variables are set in the .env file.
23 |
24 | Must use **yarn** in order to use *yarn.lock*. (Do not delete `yarn.lock`)
25 |
26 | * Open MYSQL and Create Database `next_graphql_admin`
27 | * Open Terminal in `api` Directory and use following commands
28 | * `yarn`
29 |
30 | > If you aree getting error `The engine "node" is incompatible with this module. Expected version "12". Got "14.17.3"`
31 |
32 | > `nvm use --delete-prefix v12.0`
33 |
34 | > `nvm install v12.0`
35 |
36 | * `npx prisma migrate save --experimental`
37 | * `npx prisma migrate up --experimental` (It will create DB Tables)
38 | * `yarn seed` (it will create test user **admin@example.com / admin** in DB user table)
39 |
40 | ### Starting the server
41 |
42 | Run the following commands to get started.
43 |
44 | ```bash
45 | yarn dev # Starts the local api server accessible at http://local.api.nextgraphqladmin.com:4000
46 | ```
47 | > **Note** Generate SSL certificate for your local https://local.api.nextgraphqladmin.com:4000/ to make it working.
48 | Please follow https://github.com/dvikas/nextjs-graphql-adminpanel/blob/main/api/GENERATE_SSL.md
49 |
50 | ### Dealing with Stripe subscriptions
51 |
52 | In order to handle Stripe webhooks that are triggered when a user upgrades to premium, or cancels their subscription, the Stripe CLI should be used to listen to incoming Stripe webhooks to the local server. Run `yarn stripe` to handle this. If upgrading a user to premium using a fake credit card (see the [Stripe docs](https://stripe.com/docs/billing/subscriptions/set-up-subscription#test-integration) for the card numbers that will work), it won't be possible to cancel the user subscription through the UI (it will have to be done through the Prisma GraphQL playground to access the DB directly).
53 |
54 | Make sure that if you're running stripe locally, that you get a webhook secret by executing `yarn stripe` and copy/pasting it in the `.env` file, otherwise the API won't be able to read the stripe event.
55 |
56 | ## GraphQL Playground
57 |
58 | Once the GraphQL API server is running, visit http://local.api.nextgraphqladmin.com:4000/ to view the GraphQL playground. The playground allows for calls to queries and mutations that have been defined in this repository.
59 |
60 | ## Prisma & Nexus
61 |
62 | This project uses [Prisma](https://www.prisma.io/) to generate a CRUD API to interact with the back-end database. Nexus is used to generate a fully typed back-end API.
63 |
64 | ```bash
65 | yarn generate # Generates the prisma API, API graphql schema, and API types
66 | yarn generate:prisma # Generates the prisma API
67 | yarn generate:nexus # Generates a graphql schema and types for the API
68 | ```
69 |
--------------------------------------------------------------------------------
/web-app/components/Dashboard/LatestProducts.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import clsx from 'clsx';
3 | import PropTypes from 'prop-types';
4 | import { v4 as uuid } from 'uuid';
5 | import moment from 'moment';
6 | import {
7 | Box,
8 | Button,
9 | Card,
10 | CardHeader,
11 | Divider,
12 | IconButton,
13 | List,
14 | ListItem,
15 | ListItemAvatar,
16 | ListItemText,
17 | makeStyles
18 | } from '@material-ui/core';
19 | import MoreVertIcon from '@material-ui/icons/MoreVert';
20 | import ArrowRightIcon from '@material-ui/icons/ArrowRight';
21 |
22 | const data = [
23 | {
24 | id: uuid(),
25 | name: 'India gate rice',
26 | imageUrl: '/images/product/9.jpg',
27 | updatedAt: moment().subtract(1, 'days')
28 | },
29 | {
30 | id: uuid(),
31 | name: 'Maggi 4 value pack',
32 | imageUrl: '/images/product/7.jpg',
33 | updatedAt: moment().subtract(5, 'hours')
34 | },
35 | {
36 | id: uuid(),
37 | name: 'Brown rice',
38 | imageUrl: '/images/product/10.jpg',
39 | updatedAt: moment().subtract(6, 'hours')
40 | },
41 | {
42 | id: uuid(),
43 | name: 'Mango',
44 | imageUrl: '/images/product/4.jpg',
45 | updatedAt: moment().subtract(8, 'hours')
46 | },
47 | {
48 | id: uuid(),
49 | name: 'Strawberry',
50 | imageUrl: '/images/product/2.jpg',
51 | updatedAt: moment().subtract(9, 'hours')
52 | }
53 | ];
54 |
55 | const useStyles = makeStyles(({
56 | root: {
57 | height: '100%'
58 | },
59 | image: {
60 | height: 48,
61 | width: 48
62 | }
63 | }));
64 |
65 | type Props = {
66 | className: string
67 | };
68 |
69 | const LatestProducts: React.FC = ({ className, ...rest }) => {
70 | const classes = useStyles();
71 | const [products] = useState(data);
72 |
73 | return (
74 |
78 |
82 |
83 |
84 | {products.map((product, i) => (
85 |
89 |
90 |
95 |
96 |
100 |
104 |
105 |
106 |
107 | ))}
108 |
109 |
110 |
115 | }
118 | size="small"
119 | variant="text"
120 | >
121 | View all
122 |
123 |
124 |
125 | );
126 | };
127 |
128 | export default LatestProducts;
129 |
--------------------------------------------------------------------------------
/web-app/assets/images/earning.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/web-app/public/images/earning.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/web-app/public/images/customers.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/web-app/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Document, { Html, Head, Main, NextScript } from 'next/document';
3 | import * as Sentry from '@sentry/browser';
4 | import { ServerStyleSheets } from '@material-ui/core/styles';
5 | import theme from '../theme';
6 |
7 | process.on('unhandledRejection', (err) => {
8 | Sentry.captureException(err);
9 | });
10 |
11 | process.on('uncaughtException', (err) => {
12 | Sentry.captureException(err);
13 | });
14 |
15 | export default class MyDocument extends Document {
16 | render(): JSX.Element {
17 | return (
18 |
19 |
20 | {/* PWA primary color */}
21 |
22 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | {/* */}
40 |
41 |
42 |
43 | );
44 | }
45 | }
46 |
47 | // `getInitialProps` belongs to `_document` (instead of `_app`),
48 | // it's compatible with server-side generation (SSG).
49 | MyDocument.getInitialProps = async (ctx) => {
50 | // Resolution order
51 | //
52 | // On the server:
53 | // 1. app.getInitialProps
54 | // 2. page.getInitialProps
55 | // 3. document.getInitialProps
56 | // 4. app.render
57 | // 5. page.render
58 | // 6. document.render
59 | //
60 | // On the server with error:
61 | // 1. document.getInitialProps
62 | // 2. app.render
63 | // 3. page.render
64 | // 4. document.render
65 | //
66 | // On the client
67 | // 1. app.getInitialProps
68 | // 2. page.getInitialProps
69 | // 3. app.render
70 | // 4. page.render
71 |
72 | // Render app and page and get the context of the page with collected side effects.
73 | const sheets = new ServerStyleSheets();
74 | const originalRenderPage = ctx.renderPage;
75 |
76 | ctx.renderPage = () =>
77 | originalRenderPage({
78 | enhanceApp: (App) => (props) => sheets.collect(),
79 | });
80 |
81 | const initialProps = await Document.getInitialProps(ctx);
82 |
83 | return {
84 | ...initialProps,
85 | // Styles fragment is rendered after the app and page rendering finish.
86 | styles: [...React.Children.toArray(initialProps.styles), sheets.getStyleElement()],
87 | };
88 | };
89 |
--------------------------------------------------------------------------------
/api/src/schema/mutations/updateProduct.ts:
--------------------------------------------------------------------------------
1 | import { mutationField, idArg, stringArg, intArg, arg, booleanArg } from '@nexus/schema';
2 | import analytics from '../../utils/analytics';
3 | import { verifyUserIsAuthenticated } from '../../utils/verifyUserIsAuthenticated';
4 |
5 | export const updateProductMutationField = mutationField('updateProduct', {
6 | type: 'Product',
7 | args: {
8 | id: idArg({ required: true }),
9 | name: stringArg({ required: true }),
10 | description: stringArg({ required: true }),
11 | price: intArg({ required: true }),
12 | discount: intArg({ required: true }),
13 | salePrice: intArg({ required: true }),
14 | sku: stringArg({ required: true }),
15 | unit: stringArg({ required: true }),
16 | categoryId: idArg({ required: true }),
17 | images: arg({
18 | type: 'ProductImageCreateWithoutProductInput',
19 | list: true,
20 | required: true,
21 | }),
22 | alreadyUploadedImages: arg({
23 | type: 'ProductImageCreateWithoutProductInput',
24 | list: true,
25 | required: true,
26 | }),
27 | },
28 | // name description price discount salePrice sku unit category* user*
29 | resolve: async (_, { id, name, description, price, discount, salePrice, sku, unit, categoryId, images, alreadyUploadedImages }, ctx) => {
30 | verifyUserIsAuthenticated(ctx.user);
31 | if (process.env.IS_DEMO_ACCOUNT === 'true') {
32 | throw Error('Sorry, you can\'t do update or delete in DEMO account');
33 | }
34 | const user = await ctx.prisma.user.findOne({
35 | where: { id: ctx.user.id }
36 | });
37 |
38 | const currentImages = await ctx.prisma.productImage.findMany({
39 | where: { productId: id },
40 | });
41 |
42 | const imageIdsToDelete = currentImages.reduce((prev: string[], curr) => {
43 | if (curr.id && alreadyUploadedImages.every((image) => image.id !== curr.id)) {
44 | prev.push(curr.id);
45 | }
46 | return prev;
47 | }, []);
48 |
49 | const imagesToCreate = images.filter((image) => currentImages.every((c) => c.id !== image.id));
50 |
51 | const product = await ctx.prisma.product.update({
52 | where: {
53 | id,
54 | },
55 | data: {
56 | User: { connect: { id: ctx.user.id } },
57 | Category: { connect: { id: categoryId } },
58 | name,
59 | description,
60 | price,
61 | discount,
62 | salePrice,
63 | sku,
64 | unit,
65 | ProductImages: {
66 | ...(imagesToCreate.length ? { create: imagesToCreate } : {}),
67 | ...(imageIdsToDelete.length
68 | ? {
69 | deleteMany: {
70 | id: { in: imageIdsToDelete },
71 | },
72 | }
73 | : {}),
74 | },
75 | },
76 | });
77 |
78 | analytics.track({
79 | eventType: 'Product updated',
80 | userId: ctx.user.id,
81 | eventProperties: {
82 | id: product.id,
83 | },
84 | });
85 | return product;
86 | },
87 | });
88 |
--------------------------------------------------------------------------------
/web-app/components/Dashboard/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Container,
4 | Grid,
5 | makeStyles
6 | } from '@material-ui/core';
7 | // import Page from 'src/components/Page';
8 | import Sale from './Sale';
9 | import LatestOrders from './LatestOrders';
10 | import LatestProducts from './LatestProducts';
11 | import DailyVisitsInsight from './DailyVisitsInsight';
12 | import Resolution from './Resolution';
13 | import TotalCustomers from './Customers';
14 | import TotalProfit from './TotalProfit';
15 | import TrafficByDevice from './TrafficByDevice';
16 | import ProfitAnalysis from './ProfitAnalysis';
17 | import SaleCategoryAnalysis from './SaleCategoryAnalysis';
18 |
19 | const useStyles = makeStyles((theme) => ({
20 | root: {
21 | backgroundColor: theme.palette.background.default,
22 | minHeight: '100%',
23 | paddingBottom: theme.spacing(3),
24 | paddingTop: theme.spacing(3)
25 | },
26 | firstRow: {
27 | height: 'auto'
28 | }
29 | }));
30 |
31 | const Dashboard = () => {
32 | const classes = useStyles();
33 |
34 | return (
35 |
36 |
37 |
41 |
48 |
49 |
50 |
57 |
58 |
59 |
66 |
67 |
68 |
75 |
76 |
77 |
84 |
85 |
86 |
93 |
94 |
95 |
96 |
103 |
104 |
105 |
112 |
113 |
114 |
115 |
122 |
123 |
124 |
131 |
132 |
133 |
134 |
135 |
136 | );
137 | };
138 |
139 | export default Dashboard;
140 |
--------------------------------------------------------------------------------
/api/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "mysql"
7 | url = env("API_DATABASE_URL")
8 | }
9 |
10 | model Card {
11 | brand String
12 | expMonth Int
13 | expYear Int
14 | id String @id @default(cuid())
15 | last4Digits String
16 | stripePaymentMethodId String
17 | }
18 |
19 | model GoogleMapsLocation {
20 | googlePlacesId String
21 | id String @id @default(cuid())
22 | name String
23 | OrderDetail OrderDetail[]
24 | }
25 |
26 | model OrderDetail {
27 |
28 | id String @id @default(cuid())
29 | googleMapsLocationId String?
30 | User User? @relation(fields: [userId], references: [id])
31 | userId String?
32 | GoogleMapsLocation GoogleMapsLocation? @relation(fields: [googleMapsLocationId], references: [id])
33 |
34 | }
35 |
36 | model Product {
37 | category String
38 | createdAt DateTime @default(now())
39 | description String
40 | discount Int
41 | id String @id @default(cuid())
42 | name String
43 | price Int
44 | salePrice Int
45 | sku String @unique
46 | unit String
47 | updatedAt DateTime @default(now())
48 | user String
49 | Category Category @relation(fields: [category], references: [id])
50 | User User @relation(fields: [user], references: [id])
51 | CartItem CartItem[]
52 | ProductImages ProductImage[]
53 |
54 | @@index([category], name: "category")
55 | @@index([user], name: "user")
56 | }
57 |
58 | model ProductImage {
59 | createdAt DateTime @default(now())
60 | id String @id @default(cuid())
61 | image String
62 | productId String
63 | updatedAt DateTime @default(now())
64 | Product Product @relation(fields: [productId], references: [id])
65 |
66 | @@index([productId], name: "ProductImage_ibfk_1")
67 | }
68 |
69 | enum User_role {
70 | USER
71 | ADMIN
72 | MANAGER
73 | }
74 |
75 | enum User_status {
76 | INACTIVE
77 | ACTIVE
78 | BLOCKED
79 | }
80 |
81 | model User {
82 | name String
83 | email String @unique
84 | emailConfirmationToken String?
85 | googleId String? @unique
86 | hasCompletedOnboarding Boolean @default(false)
87 | hasVerifiedEmail Boolean?
88 | id String @id @default(cuid())
89 | password String?
90 | resetToken String?
91 | resetTokenExpiry Float?
92 | role User_role @default(USER)
93 | status User_status @default(ACTIVE)
94 | billing String?
95 | CartItem CartItem[]
96 | Product Product[]
97 | OrderDetail OrderDetail[]
98 | }
99 |
100 | //////////////////////////////////////////////////////
101 | model CartItem {
102 | id String @id
103 | item String
104 | quantity Int
105 | user String
106 | User User @relation(fields: [user], references: [id])
107 |
108 | Product Product? @relation(fields: [productId], references: [id])
109 | productId String?
110 | @@index([item], name: "item")
111 | @@index([user], name: "user")
112 | }
113 |
114 | model Category {
115 | createdAt DateTime @default(now())
116 | id String @id @default(cuid())
117 | name String
118 | parent String
119 | slug String @unique
120 | updatedAt DateTime @default(now())
121 | Product Product[]
122 | }
123 |
--------------------------------------------------------------------------------
/api/migrations/20201202122403-first/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "mysql"
7 | url = "***"
8 | }
9 |
10 | model Card {
11 | brand String
12 | expMonth Int
13 | expYear Int
14 | id String @id @default(cuid())
15 | last4Digits String
16 | stripePaymentMethodId String
17 | }
18 |
19 | model GoogleMapsLocation {
20 | googlePlacesId String
21 | id String @id @default(cuid())
22 | name String
23 | OrderDetail OrderDetail[]
24 | }
25 |
26 | model OrderDetail {
27 |
28 | id String @id @default(cuid())
29 | googleMapsLocationId String?
30 | User User? @relation(fields: [userId], references: [id])
31 | userId String?
32 | GoogleMapsLocation GoogleMapsLocation? @relation(fields: [googleMapsLocationId], references: [id])
33 |
34 | }
35 |
36 | model Product {
37 | category String
38 | createdAt DateTime @default(now())
39 | description String
40 | discount Int
41 | id String @id @default(cuid())
42 | name String
43 | price Int
44 | salePrice Int
45 | sku String @unique
46 | unit String
47 | updatedAt DateTime @default(now())
48 | user String
49 | Category Category @relation(fields: [category], references: [id])
50 | User User @relation(fields: [user], references: [id])
51 | CartItem CartItem[]
52 | ProductImages ProductImage[]
53 |
54 | @@index([category], name: "category")
55 | @@index([user], name: "user")
56 | }
57 |
58 | model ProductImage {
59 | createdAt DateTime @default(now())
60 | id String @id @default(cuid())
61 | image String
62 | productId String
63 | updatedAt DateTime @default(now())
64 | Product Product @relation(fields: [productId], references: [id])
65 |
66 | @@index([productId], name: "ProductImage_ibfk_1")
67 | }
68 |
69 | enum User_role {
70 | USER
71 | ADMIN
72 | MANAGER
73 | }
74 |
75 | enum User_status {
76 | INACTIVE
77 | ACTIVE
78 | BLOCKED
79 | }
80 |
81 | model User {
82 | name String
83 | email String @unique
84 | emailConfirmationToken String?
85 | googleId String? @unique
86 | hasCompletedOnboarding Boolean @default(false)
87 | hasVerifiedEmail Boolean?
88 | id String @id @default(cuid())
89 | password String?
90 | resetToken String?
91 | resetTokenExpiry Float?
92 | role User_role @default(USER)
93 | status User_status @default(ACTIVE)
94 | billing String?
95 | CartItem CartItem[]
96 | Product Product[]
97 | OrderDetail OrderDetail[]
98 | }
99 |
100 | //////////////////////////////////////////////////////
101 | model CartItem {
102 | id String @id
103 | item String
104 | quantity Int
105 | user String
106 | User User @relation(fields: [user], references: [id])
107 |
108 | Product Product? @relation(fields: [productId], references: [id])
109 | productId String?
110 | @@index([item], name: "item")
111 | @@index([user], name: "user")
112 | }
113 |
114 | model Category {
115 | createdAt DateTime @default(now())
116 | id String @id @default(cuid())
117 | name String
118 | parent String
119 | slug String @unique
120 | updatedAt DateTime @default(now())
121 | Product Product[]
122 | }
123 |
--------------------------------------------------------------------------------
/web-app/public/images/conversation.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
96 |
--------------------------------------------------------------------------------
/web-app/components/Product/ProductStyle.tsx:
--------------------------------------------------------------------------------
1 | import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
2 | import { red } from '@material-ui/core/colors';
3 |
4 | export const useStyles = makeStyles((theme: Theme) =>
5 | createStyles({
6 | root: {
7 | width: 200,
8 | },
9 | formControl: {
10 | margin: theme.spacing(1),
11 | minWidth: 120,
12 | },
13 | title: {
14 | fontSize: '14px',
15 | height: '35px',
16 | overflow: 'hidden',
17 | lineHeight: '1.3em',
18 | textOverflow: 'ellipsis',
19 | color: '#161f6a',
20 | // color: theme.palette.primary.dark,
21 | fontWeight: theme.typography.fontWeightMedium,
22 | textAlign: 'center'
23 | },
24 | subheader: {
25 | fontSize: '12px',
26 | marginTop: '5px',
27 | textAlign: 'center'
28 | },
29 | cardContent: {
30 | paddingTop: 0,
31 | },
32 | discountedPrice: {
33 | paddingTop: 0,
34 | color: theme.palette.primary.dark
35 | },
36 | price: {
37 | marginLeft: '10px',
38 | color: theme.palette.grey[500],
39 | fontSize: '12px',
40 | textDecoration: 'line-through'
41 | },
42 | media: {
43 | // height: '240px',
44 | height: '120px',
45 | width: 120,
46 | textAlign: 'center',
47 | marginTop: '10px',
48 | margin: '0 auto',
49 | // paddingTop: '56.25%', // 16:9
50 | },
51 | expand: {
52 | transform: 'rotate(0deg)',
53 | marginLeft: 'auto',
54 | transition: theme.transitions.create('transform', {
55 | duration: theme.transitions.duration.shortest,
56 | }),
57 | },
58 | expandOpen: {
59 | transform: 'rotate(180deg)',
60 | },
61 | avatar: {
62 | backgroundColor: red[500],
63 | },
64 | parentImageContainer: {
65 | position: 'relative'
66 | },
67 | discountInPercent: {
68 | // ...$theme.typography.fontBold12,
69 | color: '#ffffff',
70 | lineHeight: '1.7',
71 | backgroundColor: theme.palette.secondary.main,
72 | paddingLeft: '7px',
73 | paddingRight: '7px',
74 | display: 'inline-block',
75 | position: 'absolute',
76 | bottom: '10px',
77 | right: '0',
78 | fontSize: '12px',
79 | fontWeight: theme.typography.fontWeightMedium,
80 | '&::before': {
81 | content: '""',
82 | position: 'absolute',
83 | left: '-8px',
84 | top: '0',
85 | width: '0',
86 | height: '0',
87 | borderStyle: 'solid',
88 | borderWidth: '0 8px 12px 0',
89 | borderColor: `transparent ${theme.palette.secondary.main} transparent transparent`,
90 | },
91 |
92 | '&::after': {
93 | content: '""',
94 | position: 'absolute',
95 | left: '-8px',
96 | bottom: ' 0',
97 | width: ' 0',
98 | height: '0',
99 | borderStyle: 'solid',
100 | borderWidth: '0 0 12px 8px',
101 | borderColor: `transparent transparent ${theme.palette.secondary.main}`,
102 | },
103 | },
104 | appBar: {
105 | position: 'relative',
106 | },
107 | dialogTitle: {
108 | marginLeft: theme.spacing(2),
109 | flex: 1,
110 | },
111 | drawerOpen: {
112 | color: 'red'
113 | },
114 | drawerClose: {
115 | position: 'relative!important' as any
116 | },
117 | newProduct: {
118 | bottom: theme.spacing(2),
119 | position: 'fixed',
120 | right: theme.spacing(2)
121 | },
122 | filterBox: {
123 | // marginTop: theme.spacing(2),
124 | marginBottom: theme.spacing(2),
125 | // padding: theme.spacing(2)
126 | },
127 | pagination: {
128 | padding: theme.spacing(2)
129 | },
130 | searchInput: {
131 | padding: theme.spacing(1)
132 | },
133 | searchBar: {
134 | background: theme.palette.grey[100],
135 | borderWidth: '1px',
136 | boxShadow: theme.shadows[0],
137 | marginBottom: theme.spacing(2),
138 | borderColor: theme.palette.primary.main,
139 | borderRadius: '5px',
140 | },
141 | searchLegend: {
142 | ...theme.typography.subtitle1,
143 | background: theme.palette.primary.main,
144 | color: theme.palette.primary.contrastText,
145 | paddingLeft: theme.spacing(1),
146 | paddingRight: theme.spacing(1),
147 | // borderRadius: '10%',
148 | fontWeight: theme.typography.fontWeightRegular
149 | }
150 | }),
151 | );
--------------------------------------------------------------------------------