├── .node-version
├── jest.setup.js
├── .husky
├── post-merge
├── pre-commit
└── commit-msg
├── public
├── images
│ ├── new-tab.png
│ └── split-discount.png
├── favicon
│ ├── favicon.ico
│ ├── large-og.jpg
│ ├── apple-icon.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon-96x96.png
│ ├── ms-icon-70x70.png
│ ├── apple-icon-57x57.png
│ ├── apple-icon-60x60.png
│ ├── apple-icon-72x72.png
│ ├── apple-icon-76x76.png
│ ├── ms-icon-144x144.png
│ ├── ms-icon-150x150.png
│ ├── ms-icon-310x310.png
│ ├── android-icon-36x36.png
│ ├── android-icon-48x48.png
│ ├── android-icon-72x72.png
│ ├── android-icon-96x96.png
│ ├── apple-icon-114x114.png
│ ├── apple-icon-120x120.png
│ ├── apple-icon-144x144.png
│ ├── apple-icon-152x152.png
│ ├── apple-icon-180x180.png
│ ├── android-icon-144x144.png
│ ├── android-icon-192x192.png
│ ├── apple-icon-precomposed.png
│ ├── browserconfig.xml
│ └── manifest.json
├── fonts
│ └── inter-var-latin.woff2
└── svg
│ └── Vercel.svg
├── postcss.config.js
├── prisma
├── migrations
│ ├── 20220308143014_add_phone_number_to_user
│ │ └── migration.sql
│ ├── migration_lock.toml
│ ├── 20220305032109_add_date_to_debt_table
│ │ └── migration.sql
│ ├── 20220305034059_change_title_to_description_for_debt
│ │ └── migration.sql
│ ├── 20220304172341_add_debt_model
│ │ └── migration.sql
│ ├── 20220305093746_change_debt_to_transaction
│ │ └── migration.sql
│ └── 20220304162448_user_with_google
│ │ └── migration.sql
└── schema.prisma
├── src
├── types
│ ├── dropzone.ts
│ ├── next-auth.d.ts
│ └── index.d.ts
├── lib
│ ├── date.ts
│ ├── clsxm.ts
│ ├── prisma.ts
│ ├── umami.ts
│ ├── axios.ts
│ ├── logger.ts
│ ├── __tests__
│ │ └── helper.test.ts
│ ├── require-session.server.ts
│ ├── email.server.ts
│ └── helper.ts
├── hooks
│ ├── useDialog.tsx
│ └── toast
│ │ ├── useLoadingToast.tsx
│ │ └── useSWRWithToast.tsx
├── constant
│ ├── env.ts
│ ├── toast.ts
│ ├── regex.ts
│ ├── food-lists.ts
│ └── email-whitelist.ts
├── pages
│ ├── api
│ │ ├── user
│ │ │ ├── index.ts
│ │ │ ├── [id].ts
│ │ │ └── edit.ts
│ │ ├── auth
│ │ │ └── [...nextauth].ts
│ │ ├── delete-trx
│ │ │ └── [trxId].ts
│ │ ├── remind.ts
│ │ └── trx
│ │ │ ├── create-many-by-id.ts
│ │ │ ├── create-many.ts
│ │ │ ├── summary.ts
│ │ │ ├── create.ts
│ │ │ └── [id].ts
│ ├── 404.tsx
│ ├── _document.tsx
│ ├── _app.tsx
│ ├── sandbox
│ │ ├── toast-swr.tsx
│ │ ├── dialog-zustand.tsx
│ │ └── rhf.tsx
│ ├── list.tsx
│ ├── profile.tsx
│ ├── index.tsx
│ ├── debt
│ │ ├── bayar
│ │ │ └── [id].tsx
│ │ ├── request.tsx
│ │ └── split.tsx
│ └── trx
│ │ └── [id].tsx
├── components
│ ├── Skeleton.tsx
│ ├── buttons
│ │ ├── TextButton.tsx
│ │ └── Button.tsx
│ ├── links
│ │ ├── PrimaryLink.tsx
│ │ ├── UnderlineLink.tsx
│ │ ├── UnstyledLink.tsx
│ │ ├── ArrowLink.tsx
│ │ └── ButtonLink.tsx
│ ├── layout
│ │ ├── Layout.tsx
│ │ └── Header.tsx
│ ├── UserImage.tsx
│ ├── DismissableToast.tsx
│ ├── NextImage.tsx
│ ├── UserListItem.tsx
│ ├── forms
│ │ ├── TextArea.tsx
│ │ ├── UserCheckboxes.tsx
│ │ ├── Input.tsx
│ │ ├── SelectInput.tsx
│ │ ├── PasswordInput.tsx
│ │ ├── DatePicker.tsx
│ │ ├── FilePreview.tsx
│ │ └── DropzoneInput.tsx
│ ├── Seo.tsx
│ ├── UserSelect.tsx
│ └── dialog
│ │ └── BaseDialog.tsx
├── styles
│ ├── nprogress.css
│ └── globals.css
├── container
│ └── FullScreenLoading.tsx
└── store
│ └── useDialogStore.tsx
├── .prettierrc.js
├── next-env.d.ts
├── .vscode
├── settings.json
├── extensions.json
├── css.code-snippets
└── typescriptreact.code-snippets
├── vercel.json
├── .github
├── issue-branch.yml
└── workflows
│ ├── create-branch.yml
│ ├── issue-autolink.yml
│ └── lint.yml
├── README.md
├── next-sitemap.js
├── commitlint.config.js
├── .prettierignore
├── .gitignore
├── next.config.js
├── tsconfig.json
├── .env.example
├── jest.config.js
├── CONTRIBUTING.md
├── tailwind.config.js
├── .eslintrc.js
└── package.json
/.node-version:
--------------------------------------------------------------------------------
1 | v16.14.0
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect';
2 |
--------------------------------------------------------------------------------
/.husky/post-merge:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | yarn
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
--------------------------------------------------------------------------------
/public/images/new-tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/images/new-tab.png
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no-install commitlint --edit "$1"
5 |
--------------------------------------------------------------------------------
/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/large-og.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/large-og.jpg
--------------------------------------------------------------------------------
/public/favicon/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/favicon-96x96.png
--------------------------------------------------------------------------------
/public/favicon/ms-icon-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/ms-icon-70x70.png
--------------------------------------------------------------------------------
/public/images/split-discount.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/images/split-discount.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-57x57.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-60x60.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-72x72.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-76x76.png
--------------------------------------------------------------------------------
/public/favicon/ms-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/ms-icon-144x144.png
--------------------------------------------------------------------------------
/public/favicon/ms-icon-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/ms-icon-150x150.png
--------------------------------------------------------------------------------
/public/favicon/ms-icon-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/ms-icon-310x310.png
--------------------------------------------------------------------------------
/public/fonts/inter-var-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/fonts/inter-var-latin.woff2
--------------------------------------------------------------------------------
/public/favicon/android-icon-36x36.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/android-icon-36x36.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/android-icon-48x48.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/android-icon-72x72.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/android-icon-96x96.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-114x114.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-120x120.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-144x144.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-152x152.png
--------------------------------------------------------------------------------
/public/favicon/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-180x180.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/android-icon-144x144.png
--------------------------------------------------------------------------------
/public/favicon/android-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/android-icon-192x192.png
--------------------------------------------------------------------------------
/prisma/migrations/20220308143014_add_phone_number_to_user/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "User" ADD COLUMN "phoneNumber" TEXT;
3 |
--------------------------------------------------------------------------------
/public/favicon/apple-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theodorusclarence/lhokutang/HEAD/public/favicon/apple-icon-precomposed.png
--------------------------------------------------------------------------------
/src/types/dropzone.ts:
--------------------------------------------------------------------------------
1 | import { FileWithPath } from 'react-dropzone';
2 |
3 | export type FileWithPreview = FileWithPath & { preview: string };
4 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | arrowParens: 'always',
3 | singleQuote: true,
4 | jsxSingleQuote: true,
5 | tabWidth: 2,
6 | semi: true,
7 | };
8 |
--------------------------------------------------------------------------------
/public/svg/Vercel.svg:
--------------------------------------------------------------------------------
1 | Vercel
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/src/lib/date.ts:
--------------------------------------------------------------------------------
1 | export const DATE_FORMAT = {
2 | /** 15 Agustus 2021 */
3 | FULL: 'dd MMMM yyyy',
4 | /** 15 Agu 2021 */
5 | FULL_DATE_HOUR_MINUTE: 'd MMMM yyyy HH:mm',
6 | };
7 |
--------------------------------------------------------------------------------
/src/hooks/useDialog.tsx:
--------------------------------------------------------------------------------
1 | import useDialogStore from '@/store/useDialogStore';
2 |
3 | /** Hook to use dialog cleanly */
4 | export default function useDialog() {
5 | return useDialogStore.useDialog();
6 | }
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.validate": false,
3 | "editor.formatOnSave": true,
4 | "editor.tabSize": 2,
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll": true
7 | },
8 | "headwind.runOnSave": false
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | // Tailwind CSS Intellisense
4 | "bradlc.vscode-tailwindcss",
5 | "esbenp.prettier-vscode",
6 | "dbaeumer.vscode-eslint",
7 | "aaron-bond.better-comments"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/css.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "Region CSS": {
3 | "prefix": "regc",
4 | "body": [
5 | "/* #region /**=========== ${1} =========== */",
6 | "$0",
7 | "/* #endregion /**======== ${1} =========== */"
8 | ]
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/constant/env.ts:
--------------------------------------------------------------------------------
1 | export const isProd = process.env.NODE_ENV === 'production';
2 | export const isLocal = process.env.NODE_ENV === 'development';
3 |
4 | export const showLogger = isLocal
5 | ? true
6 | : process.env.NEXT_PUBLIC_SHOW_LOGGER === 'true' ?? false;
7 |
--------------------------------------------------------------------------------
/src/lib/clsxm.ts:
--------------------------------------------------------------------------------
1 | import clsx, { ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | /** Merge classes with tailwind-merge with clsx full feature */
5 | export default function clsxm(...classes: ClassValue[]) {
6 | return twMerge(clsx(...classes));
7 | }
8 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "headers": [
3 | {
4 | "source": "/fonts/inter-var-latin.woff2",
5 | "headers": [
6 | {
7 | "key": "Cache-Control",
8 | "value": "public, max-age=31536000, immutable"
9 | }
10 | ]
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/src/constant/toast.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_TOAST_MESSAGE = {
2 | loading: 'Loading...',
3 | success: 'Success',
4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
5 | error: (err: any) =>
6 | err?.response?.data?.message ?? 'Something is wrong, please try again',
7 | };
8 |
--------------------------------------------------------------------------------
/.github/issue-branch.yml:
--------------------------------------------------------------------------------
1 | # https://github.com/robvanderleek/create-issue-branch#option-2-configure-github-action
2 |
3 | # ex: i4-lower_camel_upper
4 | branchName: 'i${issue.number}-${issue.title,}'
5 | branches:
6 | - label: epic
7 | skip: true
8 | - label: debt
9 | skip: true
10 |
--------------------------------------------------------------------------------
/public/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 | #ffffff
--------------------------------------------------------------------------------
/prisma/migrations/20220305032109_add_date_to_debt_table/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Added the required column `date` to the `Debt` table without a default value. This is not possible if the table is not empty.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "Debt" ADD COLUMN "date" TIMESTAMP(3) NOT NULL;
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
LhokUtang
3 |
Aplikasi perutangan 4.0 milik kos lhoktuan, sukolilo, surabaya.
4 |
5 |
6 | # Contributing & Project Setup
7 |
8 | If you are interested in contributing or running this project on your local machine, please check the [contributing guide](CONTRIBUTING.md)
9 |
--------------------------------------------------------------------------------
/src/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import { DefaultUser } from 'next-auth';
2 |
3 | declare module 'next-auth' {
4 | /**
5 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
6 | */
7 | interface Session {
8 | user: DefaultUser & {
9 | id: string;
10 | };
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/.github/workflows/create-branch.yml:
--------------------------------------------------------------------------------
1 | name: Create Branch from Issue
2 |
3 | on:
4 | issues:
5 | types: [assigned]
6 |
7 | jobs:
8 | create_issue_branch_job:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Create Issue Branch
12 | uses: robvanderleek/create-issue-branch@main
13 | env:
14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
15 |
--------------------------------------------------------------------------------
/.github/workflows/issue-autolink.yml:
--------------------------------------------------------------------------------
1 | name: 'Issue Autolink'
2 | on:
3 | pull_request:
4 | types: [opened]
5 |
6 | jobs:
7 | issue-links:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: tkt-actions/add-issue-links@v1.6.0
11 | with:
12 | repo-token: '${{ secrets.GITHUB_TOKEN }}'
13 | branch-prefix: 'i'
14 | resolve: 'true'
15 |
--------------------------------------------------------------------------------
/next-sitemap.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('next-sitemap').IConfig}
3 | * @see https://github.com/iamvishnusankar/next-sitemap#readme
4 | */
5 | module.exports = {
6 | /** Without additional '/' on the end, e.g. https://theodorusclarence.com */
7 | siteUrl: 'https://lhoks.thcl.dev',
8 | generateRobotsTxt: true,
9 | robotsTxtOptions: {
10 | policies: [{ userAgent: '*', allow: '/' }],
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | // @see https://www.prisma.io/docs/support/help-articles/nextjs-prisma-client-dev-practices
2 | // @see https://github.com/nextauthjs/next-auth/issues/824#issuecomment-860266512
3 | import { PrismaClient } from '@prisma/client';
4 |
5 | declare global {
6 | namespace NodeJS {
7 | interface Global {
8 | prisma: PrismaClient | undefined;
9 | }
10 | }
11 | }
12 |
13 | export {};
14 |
--------------------------------------------------------------------------------
/src/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | // @see https://www.prisma.io/docs/support/help-articles/nextjs-prisma-client-dev-practices
2 | // @see https://github.com/nextauthjs/next-auth/issues/824#issuecomment-860266512
3 | import { PrismaClient } from '@prisma/client';
4 |
5 | export const prisma =
6 | global.prisma ||
7 | new PrismaClient({
8 | // log: ['query'],
9 | });
10 |
11 | if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
12 |
--------------------------------------------------------------------------------
/src/constant/regex.ts:
--------------------------------------------------------------------------------
1 | export const REGEX = {
2 | EMAIL: {
3 | value:
4 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
5 | message: 'Email tidak valid',
6 | },
7 | PHONE_NUMBER: {
8 | value: /^\+628[1-9][0-9]{7,11}$/,
9 | message:
10 | 'Nomor Telepon harus diawali +62 dan memiliki panjang 13-15 karakter',
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/src/lib/umami.ts:
--------------------------------------------------------------------------------
1 | const EVENT_TYPE = ['link', 'click', 'navigate', 'recommend'] as const;
2 | type EventType = typeof EVENT_TYPE[number];
3 |
4 | type TrackEvent = (
5 | event_name: string,
6 | event_data?: { type?: EventType } & { [key: string]: string | number }
7 | ) => void;
8 |
9 | export const trackEvent: TrackEvent = (...args) => {
10 | if (window.umami && typeof window.umami.track === 'function') {
11 | window.umami.track(...args);
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/src/hooks/toast/useLoadingToast.tsx:
--------------------------------------------------------------------------------
1 | import { useToasterStore } from 'react-hot-toast';
2 |
3 | /**
4 | * Hook to get information whether something is loading
5 | * @returns true if there is a loading toast
6 | * @example const isLoading = useLoadingToast();
7 | */
8 | export default function useLoadingToast(): boolean {
9 | const { toasts } = useToasterStore();
10 | const isLoading = toasts.some((toast) => toast.type === 'loading');
11 | return isLoading;
12 | }
13 |
--------------------------------------------------------------------------------
/prisma/migrations/20220305034059_change_title_to_description_for_debt/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `title` on the `Debt` table. All the data in the column will be lost.
5 | - Added the required column `description` to the `Debt` table without a default value. This is not possible if the table is not empty.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "Debt" DROP COLUMN "title",
10 | ADD COLUMN "description" TEXT NOT NULL;
11 |
--------------------------------------------------------------------------------
/src/pages/api/user/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 |
3 | import { prisma } from '@/lib/prisma';
4 | import requireSession from '@/lib/require-session.server';
5 |
6 | export default requireSession(users);
7 |
8 | async function users(req: NextApiRequest, res: NextApiResponse) {
9 | if (req.method === 'GET') {
10 | res.status(200).json({ users: await prisma.user.findMany() });
11 | } else {
12 | res.status(405).json({ message: 'Method Not Allowed' });
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/lib/axios.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const axiosClient = axios.create({
4 | headers: {
5 | Authorization: '',
6 | 'Content-Type': 'application/json',
7 | },
8 | });
9 |
10 | axiosClient.defaults.withCredentials = false;
11 |
12 | axiosClient.interceptors.request.use(function (config) {
13 | const token = localStorage.getItem('token');
14 | if (config.headers) {
15 | config.headers.Authorization = token ? `Bearer ${token}` : '';
16 | }
17 | return config;
18 | });
19 |
20 | export default axiosClient;
21 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | rules: {
4 | // TODO Add Scope Enum Here
5 | // 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
6 | 'type-enum': [
7 | 2,
8 | 'always',
9 | [
10 | 'feat',
11 | 'fix',
12 | 'docs',
13 | 'chore',
14 | 'style',
15 | 'refactor',
16 | 'ci',
17 | 'test',
18 | 'perf',
19 | 'revert',
20 | 'vercel',
21 | ],
22 | ],
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/prisma/migrations/20220304172341_add_debt_model/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Debt" (
3 | "id" TEXT NOT NULL,
4 | "title" TEXT NOT NULL,
5 | "amount" INTEGER NOT NULL,
6 | "creditorId" TEXT NOT NULL,
7 | "debtorId" TEXT NOT NULL,
8 | "type" TEXT,
9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10 |
11 | CONSTRAINT "Debt_pkey" PRIMARY KEY ("id")
12 | );
13 |
14 | -- AddForeignKey
15 | ALTER TABLE "Debt" ADD CONSTRAINT "Debt_creditorId_fkey" FOREIGN KEY ("creditorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
16 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | .next
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # changelog
38 | CHANGELOG.md
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # next-sitemap
38 | robots.txt
39 | sitemap.xml
--------------------------------------------------------------------------------
/src/lib/logger.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import { showLogger } from '@/constant/env';
3 |
4 | /**
5 | * A logger function that will only logs on development
6 | * @param object - The object to log
7 | * @param comment - Autogenerated with `lg` snippet
8 | */
9 | export default function logger(object: unknown, comment?: string): void {
10 | if (!showLogger) return;
11 |
12 | console.log(
13 | '%c ============== INFO LOG \n',
14 | 'color: #22D3EE',
15 | `${typeof window !== 'undefined' && window?.location.pathname}\n`,
16 | `=== ${comment ?? ''}\n`,
17 | object
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | type SkeletonProps = React.ComponentPropsWithoutRef<'div'>;
6 |
7 | export default function Skeleton({ className, ...rest }: SkeletonProps) {
8 | return (
9 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/constant/food-lists.ts:
--------------------------------------------------------------------------------
1 | export const FOOD_LISTS = [
2 | 'Bu Lala',
3 | 'Kane',
4 | 'Mbak Lis',
5 | 'Jus',
6 | 'Urban Latte',
7 | 'Suprek',
8 | 'Bedjo',
9 | 'Xenteur',
10 | 'Joder',
11 | 'Teh Poci',
12 | 'Soto Abas',
13 | 'Laundry',
14 | 'Es Teh Surabaya',
15 | 'Mixue',
16 | 'Sego Ndog',
17 | 'Kopi Kenangan',
18 | 'Uduk Gebang',
19 | 'Bebek Ombrenk',
20 | 'Gobar',
21 | 'Uni',
22 | 'Bagong',
23 | 'Cak Kabul',
24 | 'Butong',
25 | 'Cak Rozi',
26 | 'Djayaraya',
27 | 'Ngelalap',
28 | 'Bangjo',
29 | 'Pak Ndut',
30 | 'Warjo',
31 | 'Ayam Ibu',
32 | 'Warung Jakarta',
33 | 'Pawon Chef Rudy',
34 | ];
35 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | module.exports = {
3 | eslint: {
4 | dirs: ['src'],
5 | },
6 |
7 | reactStrictMode: true,
8 |
9 | // Uncoment to add domain whitelist
10 | images: {
11 | domains: ['lh3.googleusercontent.com'],
12 | },
13 |
14 | // SVGR
15 | webpack(config) {
16 | config.module.rules.push({
17 | test: /\.svg$/i,
18 | issuer: /\.[jt]sx?$/,
19 | use: [
20 | {
21 | loader: '@svgr/webpack',
22 | options: {
23 | typescript: true,
24 | icon: true,
25 | },
26 | },
27 | ],
28 | });
29 |
30 | return config;
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/src/lib/__tests__/helper.test.ts:
--------------------------------------------------------------------------------
1 | import { openGraph } from '@/lib/helper';
2 |
3 | describe('Open Graph function should work correctly', () => {
4 | it('should not return templateTitle when not specified', () => {
5 | const result = openGraph({
6 | description: 'Test description',
7 | siteName: 'Test site name',
8 | });
9 | expect(result).not.toContain('&templateTitle=');
10 | });
11 |
12 | it('should return templateTitle when specified', () => {
13 | const result = openGraph({
14 | templateTitle: 'Test Template Title',
15 | description: 'Test description',
16 | siteName: 'Test site name',
17 | });
18 | expect(result).toContain('&templateTitle=Test%20Template%20Title');
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/lib/require-session.server.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { User } from 'next-auth';
3 | import { getSession } from 'next-auth/react';
4 |
5 | export type ApiRoute = (
6 | req: NextApiRequest,
7 | res: NextApiResponse,
8 | user: User
9 | ) => Promise;
10 |
11 | export default function requireSession(apiRoute: ApiRoute) {
12 | return async function handler(req: NextApiRequest, res: NextApiResponse) {
13 | const session = await getSession({ req });
14 | if (!session?.user?.email) {
15 | res.status(401).json({
16 | message: 'Unauthorized',
17 | });
18 | return null;
19 | } else {
20 | return apiRoute(req, res, session.user as User);
21 | }
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/src/styles/nprogress.css:
--------------------------------------------------------------------------------
1 | /* Make clicks pass-through */
2 | #nprogress {
3 | pointer-events: none;
4 | }
5 |
6 | #nprogress .bar {
7 | background: var(--color-primary-700);
8 |
9 | position: fixed;
10 | z-index: 1031;
11 | top: 0;
12 | left: 0;
13 |
14 | width: 100%;
15 | height: 2px;
16 | }
17 |
18 | /* Fancy blur effect */
19 | #nprogress .peg {
20 | display: block;
21 | position: absolute;
22 | right: 0px;
23 | width: 100px;
24 | height: 100%;
25 | box-shadow: 0 0 10px var(--clr-primary-700), 0 0 5px var(--clr-primary-700);
26 | opacity: 1;
27 |
28 | -webkit-transform: rotate(3deg) translate(0px, -4px);
29 | -ms-transform: rotate(3deg) translate(0px, -4px);
30 | transform: rotate(3deg) translate(0px, -4px);
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "baseUrl": ".",
17 | "paths": {
18 | "@/*": ["./src/*"],
19 | "~/*": ["./public/*"]
20 | },
21 | "incremental": true
22 | },
23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
24 | "exclude": ["node_modules"],
25 | "moduleResolution": ["node_modules", ".next", "node"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/buttons/TextButton.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | type TextButtonProps = {
4 | /** Button children element */
5 | children: React.ReactNode;
6 | /** Will be merged with button tag */
7 | className?: string;
8 | } & React.ComponentPropsWithoutRef<'button'>;
9 |
10 | export default function TextButton({
11 | children,
12 | className = '',
13 | type = 'button',
14 | ...rest
15 | }: TextButtonProps) {
16 | return (
17 |
27 | {children}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/prisma/migrations/20220305093746_change_debt_to_transaction/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `Debt` table. If the table is not empty, all the data it contains will be lost.
5 |
6 | */
7 | -- DropForeignKey
8 | ALTER TABLE "Debt" DROP CONSTRAINT "Debt_creditorId_fkey";
9 |
10 | -- DropTable
11 | DROP TABLE "Debt";
12 |
13 | -- CreateTable
14 | CREATE TABLE "Transaction" (
15 | "id" TEXT NOT NULL,
16 | "userId" TEXT NOT NULL,
17 | "amount" INTEGER NOT NULL,
18 | "description" TEXT NOT NULL,
19 | "date" TIMESTAMP(3) NOT NULL,
20 | "destinationUserId" TEXT NOT NULL,
21 |
22 | CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id")
23 | );
24 |
25 | -- AddForeignKey
26 | ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
27 |
--------------------------------------------------------------------------------
/src/components/links/PrimaryLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import UnstyledLink, {
6 | UnstyledLinkProps,
7 | } from '@/components/links/UnstyledLink';
8 |
9 | const PrimaryLink = React.forwardRef(
10 | ({ className, children, ...rest }, ref) => {
11 | return (
12 |
22 | {children}
23 |
24 | );
25 | }
26 | );
27 |
28 | export default PrimaryLink;
29 |
--------------------------------------------------------------------------------
/src/components/layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import BaseDialog from '@/components/dialog/BaseDialog';
4 | import Header from '@/components/layout/Header';
5 |
6 | import useDialogStore from '@/store/useDialogStore';
7 |
8 | export default function Layout({ children }: { children: React.ReactNode }) {
9 | //#region //*=========== Store ===========
10 | const open = useDialogStore.useOpen();
11 | const state = useDialogStore.useState();
12 | const handleClose = useDialogStore.useHandleClose();
13 | const handleSubmit = useDialogStore.useHandleSubmit();
14 | //#endregion //*======== Store ===========
15 |
16 | return (
17 |
18 |
19 |
20 | {children}
21 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/UserImage.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import NextImage from '@/components/NextImage';
6 |
7 | export default function UserImage({
8 | image,
9 | className,
10 | }: {
11 | image?: string | null;
12 | className?: string;
13 | }) {
14 | return (
15 | <>
16 | {image ? (
17 |
27 | ) : (
28 |
34 | )}
35 | >
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/links/UnderlineLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import UnstyledLink, {
6 | UnstyledLinkProps,
7 | } from '@/components/links/UnstyledLink';
8 |
9 | const UnderlineLink = React.forwardRef(
10 | ({ children, className, ...rest }, ref) => {
11 | return (
12 |
22 | {children}
23 |
24 | );
25 | }
26 | );
27 |
28 | export default UnderlineLink;
29 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Environment variables declared in this file are automatically made available to Prisma.
2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
3 |
4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB (Preview) and CockroachDB (Preview).
5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
6 |
7 | DATABASE_URL="postgres://[username]:[password]@localhost:5432/lhokutang"
8 |
9 |
10 | # Run `openssl rand -hex 32` or go to https://generate-secret.now.sh/32
11 | NEXTAUTH_SECRET=""
12 |
13 | # Google OAuth2 Credentials
14 | # @see https://developers.google.com/adwords/api/docs/guides/authentication
15 | # Add http://localhost:3000 for the origin, and http://localhost:3000/api/auth/callback/google for the redirect
16 | GOOGLE_CLIENT_ID=""
17 | GOOGLE_CLIENT_SECRET=""
18 |
--------------------------------------------------------------------------------
/public/favicon/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "App",
3 | "icons": [
4 | {
5 | "src": "/android-icon-36x36.png",
6 | "sizes": "36x36",
7 | "type": "image/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "/android-icon-48x48.png",
12 | "sizes": "48x48",
13 | "type": "image/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "/android-icon-72x72.png",
18 | "sizes": "72x72",
19 | "type": "image/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "/android-icon-96x96.png",
24 | "sizes": "96x96",
25 | "type": "image/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "/android-icon-144x144.png",
30 | "sizes": "144x144",
31 | "type": "image/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "/android-icon-192x192.png",
36 | "sizes": "192x192",
37 | "type": "image/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/src/container/FullScreenLoading.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useSession } from 'next-auth/react';
3 | import toast from 'react-hot-toast';
4 | import { ImSpinner6 } from 'react-icons/im';
5 |
6 | export default function FullScreenLoading({
7 | children,
8 | }: {
9 | children: React.ReactNode;
10 | }) {
11 | const { push } = useRouter();
12 |
13 | const { data: session } = useSession({
14 | required: true,
15 | onUnauthenticated: () => {
16 | toast.error('Please login first');
17 | push('/');
18 | },
19 | });
20 | const isUser = !!session?.user;
21 |
22 | return (
23 | <>
24 | {isUser ? (
25 | children
26 | ) : (
27 |
28 |
LhokUtang
29 | Loading...
30 |
31 |
32 | )}
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { RiAlarmWarningFill } from 'react-icons/ri';
3 |
4 | import Layout from '@/components/layout/Layout';
5 | import ArrowLink from '@/components/links/ArrowLink';
6 | import Seo from '@/components/Seo';
7 |
8 | export default function NotFoundPage() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
Page Not Found
21 |
22 | Back to Home
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/lib/email.server.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import mailjet from 'node-mailjet';
3 | export const sendMail = ({
4 | to,
5 | toName,
6 | subject,
7 | text,
8 | html,
9 | }: {
10 | to: string;
11 | toName: string;
12 | subject: string;
13 | text: string;
14 | html?: string;
15 | }) => {
16 | const mailjetClient = mailjet.apiConnect(
17 | process.env.MAILJET_API_KEY ?? '',
18 | process.env.MAILJET_SECRET_KEY ?? ''
19 | );
20 |
21 | const req = mailjetClient.post('send', { version: 'v3.1' }).request({
22 | Messages: [
23 | {
24 | From: {
25 | Email: 'givemefeedbackplease@gmail.com',
26 | Name: 'Lhokutang',
27 | },
28 | To: [{ Email: to, Name: toName }],
29 | Subject: subject,
30 | TextPart: text,
31 | HTMLPart: html,
32 | },
33 | ],
34 | });
35 |
36 | req
37 | .then((res) => {
38 | console.log(res.body);
39 | })
40 | .catch((err) => console.error(err));
41 |
42 | return;
43 | };
44 |
--------------------------------------------------------------------------------
/src/constant/email-whitelist.ts:
--------------------------------------------------------------------------------
1 | export const emailWhitelist = [
2 | 'ppdbultimate@gmail.com',
3 | 'theodorusclarence@gmail.com',
4 | 'jeremiakeloko@gmail.com',
5 | 'ivan.19400016@gmail.com',
6 | 'billharit@gmail.com',
7 | 'rayhanalifa@googlemail.com',
8 | 'ryan.ardhana27@gmail.com',
9 | 'williamhadiwijaya111@gmail.com',
10 | 'raynardbudiman7@gmail.com',
11 | 'tobiasega70@gmail.com',
12 | 'ariwwijyaa@gmail.com',
13 | 'gardasudarmanto@gmail.com',
14 | ];
15 |
16 | export const alumnusEmail = [
17 | 'ppdbultimate@gmail.com',
18 | 'kanda.wibisanan@gmail.com',
19 | 'stevearmando@gmail.com',
20 | 'adhwamaharika@gmail.com',
21 | 'satriaadam04@gmail.com',
22 | 'adhwamaharika@gmail.com',
23 | 'carlonugroho01@gmail.com',
24 | 'radityarobert@gmail.com',
25 | 'kanda.wibisanan@gmail.com',
26 | 'ferdinand.parulian@gmail.com',
27 | 'stevearmando@gmail.com',
28 | 'dimbum14@gmail.com',
29 | 'dega.ad@gmail.com',
30 | 'sihombingcharminuel@gmail.com',
31 | 'shrallvierdo@gmail.com',
32 | 'philippurba.pp@gmail.com',
33 | ];
34 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const nextJest = require('next/jest');
3 |
4 | const createJestConfig = nextJest({
5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
6 | dir: './',
7 | });
8 |
9 | // Add any custom config to be passed to Jest
10 | const customJestConfig = {
11 | // Add more setup options before each test is run
12 | setupFilesAfterEnv: ['/jest.setup.js'],
13 |
14 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
15 | moduleDirectories: ['node_modules', '/'],
16 |
17 | testEnvironment: 'jest-environment-jsdom',
18 |
19 | /**
20 | * Absolute imports and Module Path Aliases
21 | */
22 | moduleNameMapper: {
23 | '^@/(.*)$': '/src/$1',
24 | '^~/(.*)$': '/public/$1',
25 | },
26 | };
27 |
28 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
29 | module.exports = createJestConfig(customJestConfig);
30 |
--------------------------------------------------------------------------------
/src/pages/api/user/[id].ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from '@prisma/client';
2 | import { NextApiRequest, NextApiResponse } from 'next';
3 |
4 | import { prisma } from '@/lib/prisma';
5 | import requireSession from '@/lib/require-session.server';
6 |
7 | export default requireSession(GetSingleUser);
8 |
9 | async function GetSingleUser(req: NextApiRequest, res: NextApiResponse) {
10 | if (req.method === 'GET') {
11 | const userId = req.query.id;
12 |
13 | if (typeof userId !== 'string') {
14 | return res.status(400).json({
15 | message: 'Invalid ID',
16 | });
17 | }
18 |
19 | try {
20 | const user = await prisma.user.findUnique({
21 | where: {
22 | id: userId,
23 | },
24 | });
25 | return res.status(200).json(user);
26 | } catch (error) {
27 | if (error instanceof Prisma.PrismaClientUnknownRequestError)
28 | return res.status(500).send(error.message);
29 | else {
30 | throw error;
31 | }
32 | }
33 | } else {
34 | res.status(405).json({ message: 'Method Not Allowed' });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, {
2 | DocumentContext,
3 | Head,
4 | Html,
5 | Main,
6 | NextScript,
7 | } from 'next/document';
8 |
9 | class MyDocument extends Document {
10 | static async getInitialProps(ctx: DocumentContext) {
11 | const initialProps = await Document.getInitialProps(ctx);
12 | return { ...initialProps };
13 | }
14 |
15 | render() {
16 | return (
17 |
18 |
19 |
26 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 | }
42 |
43 | export default MyDocument;
44 |
--------------------------------------------------------------------------------
/src/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import { PrismaAdapter } from '@next-auth/prisma-adapter';
2 | import NextAuth from 'next-auth';
3 | import GoogleProvider from 'next-auth/providers/google';
4 |
5 | import { prisma } from '@/lib/prisma';
6 |
7 | import { alumnusEmail, emailWhitelist } from '@/constant/email-whitelist';
8 |
9 | export default NextAuth({
10 | adapter: PrismaAdapter(prisma),
11 | providers: [
12 | GoogleProvider({
13 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
14 | clientId: process.env.GOOGLE_CLIENT_ID!,
15 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
16 | clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
17 | }),
18 | ],
19 | callbacks: {
20 | session: async ({ session, user }) => {
21 | session.user.id = user.id;
22 | return Promise.resolve(session);
23 | },
24 | signIn: async ({ user }) => {
25 | if (!user.email) return false;
26 |
27 | // Only allow email within the whitelist
28 | if ([...emailWhitelist, ...alumnusEmail].includes(user.email))
29 | return true;
30 | return false;
31 | },
32 | },
33 | });
34 |
--------------------------------------------------------------------------------
/src/pages/api/delete-trx/[trxId].ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from '@prisma/client';
2 | import { NextApiRequest, NextApiResponse } from 'next';
3 |
4 | import { prisma } from '@/lib/prisma';
5 | import requireSession from '@/lib/require-session.server';
6 |
7 | export default requireSession(deleteTransaction);
8 | async function deleteTransaction(req: NextApiRequest, res: NextApiResponse) {
9 | if (req.method === 'DELETE') {
10 | const transactionId = req.query.trxId;
11 |
12 | if (typeof transactionId !== 'string') {
13 | return res.status(400).json({
14 | message: 'Invalid transactionId',
15 | });
16 | }
17 |
18 | try {
19 | const transaction = await prisma.transaction.delete({
20 | where: {
21 | id: transactionId,
22 | },
23 | });
24 |
25 | return res.status(200).json(transaction);
26 | } catch (error) {
27 | if (error instanceof Prisma.PrismaClientUnknownRequestError)
28 | return res.status(500).send(error.message);
29 | else {
30 | throw error;
31 | }
32 | }
33 | } else {
34 | res.status(405).json({ message: 'Method Not Allowed' });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/api/user/edit.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from '@prisma/client';
2 | import { NextApiRequest, NextApiResponse } from 'next';
3 | import { User } from 'next-auth';
4 |
5 | import { prisma } from '@/lib/prisma';
6 | import requireSession from '@/lib/require-session.server';
7 |
8 | export default requireSession(phone);
9 |
10 | type RequestBody = {
11 | phoneNumber: string;
12 | name: string;
13 | };
14 |
15 | async function phone(req: NextApiRequest, res: NextApiResponse, user: User) {
16 | if (req.method === 'POST') {
17 | const { phoneNumber, name } = req.body as RequestBody;
18 |
19 | try {
20 | await prisma.user.update({
21 | where: {
22 | id: user.id,
23 | },
24 | data: {
25 | phoneNumber,
26 | name,
27 | },
28 | });
29 | return res.status(200).json({ message: 'Profile updated' });
30 | } catch (error) {
31 | if (error instanceof Prisma.PrismaClientUnknownRequestError)
32 | return res.status(500).send(error.message);
33 | else {
34 | throw error;
35 | }
36 | }
37 | } else {
38 | res.status(405).json({ message: 'Method Not Allowed' });
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/DismissableToast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { toast, ToastBar, Toaster } from 'react-hot-toast';
3 | import { HiX } from 'react-icons/hi';
4 |
5 | export default function DismissableToast() {
6 | return (
7 |
8 |
19 | {(t) => (
20 |
21 | {({ icon, message }) => (
22 | <>
23 | {icon}
24 | {message}
25 | {t.type !== 'loading' && (
26 | toast.dismiss(t.id)}
29 | >
30 |
31 |
32 | )}
33 | >
34 | )}
35 |
36 | )}
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: PR Source Code Check
2 |
3 | on:
4 | - push
5 |
6 | jobs:
7 | lint:
8 | name: Run ESLint
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | - uses: actions/setup-node@v2
13 | with:
14 | node-version: '14'
15 | cache: 'yarn'
16 | - run: yarn
17 | - run: yarn lint:strict
18 |
19 | tsc:
20 | name: Run Type Check
21 | runs-on: ubuntu-latest
22 | steps:
23 | - uses: actions/checkout@v2
24 | - uses: actions/setup-node@v2
25 | with:
26 | node-version: '14'
27 | cache: 'yarn'
28 | - run: yarn
29 | - run: yarn typecheck
30 |
31 | prettier:
32 | name: Run Prettier Check
33 | runs-on: ubuntu-latest
34 | steps:
35 | - uses: actions/checkout@v2
36 | - uses: actions/setup-node@v2
37 | with:
38 | node-version: '14'
39 | cache: 'yarn'
40 | - run: yarn
41 | - run: yarn format:check
42 |
43 | test:
44 | name: Run Test
45 | runs-on: ubuntu-latest
46 | steps:
47 | - uses: actions/checkout@v2
48 | - uses: actions/setup-node@v2
49 | with:
50 | node-version: '14'
51 | cache: 'yarn'
52 | - run: yarn
53 | - run: yarn test
54 |
--------------------------------------------------------------------------------
/src/components/links/UnstyledLink.tsx:
--------------------------------------------------------------------------------
1 | import Link, { LinkProps } from 'next/link';
2 | import * as React from 'react';
3 |
4 | import clsxm from '@/lib/clsxm';
5 |
6 | export type UnstyledLinkProps = {
7 | href: string;
8 | children: React.ReactNode;
9 | openNewTab?: boolean;
10 | className?: string;
11 | nextLinkProps?: Omit;
12 | } & React.ComponentPropsWithRef<'a'>;
13 |
14 | const UnstyledLink = React.forwardRef(
15 | ({ children, href, openNewTab, className, nextLinkProps, ...rest }, ref) => {
16 | const isNewTab =
17 | openNewTab !== undefined
18 | ? openNewTab
19 | : href && !href.startsWith('/') && !href.startsWith('#');
20 |
21 | if (!isNewTab) {
22 | return (
23 |
24 |
25 | {children}
26 |
27 |
28 | );
29 | }
30 |
31 | return (
32 |
40 | {children}
41 |
42 | );
43 | }
44 | );
45 |
46 | export default UnstyledLink;
47 |
--------------------------------------------------------------------------------
/src/lib/helper.ts:
--------------------------------------------------------------------------------
1 | type OpenGraphType = {
2 | siteName: string;
3 | description: string;
4 | templateTitle?: string;
5 | logo?: string;
6 | };
7 | // Please clone them and self-host if your site is going to be visited by many people.
8 | // Then change the url and the default logo.
9 | export function openGraph({
10 | siteName,
11 | templateTitle,
12 | description,
13 | logo = 'https://og.clarence.link/images/logo.jpg',
14 | }: OpenGraphType): string {
15 | const ogLogo = encodeURIComponent(logo);
16 | const ogSiteName = encodeURIComponent(siteName.trim());
17 | const ogTemplateTitle = templateTitle
18 | ? encodeURIComponent(templateTitle.trim())
19 | : undefined;
20 | const ogDesc = encodeURIComponent(description.trim());
21 |
22 | return `https://og.clarence.link/api/general?siteName=${ogSiteName}&description=${ogDesc}&logo=${ogLogo}${
23 | ogTemplateTitle ? `&templateTitle=${ogTemplateTitle}` : ''
24 | }`;
25 | }
26 |
27 | export function numberWithCommas(x: number): string {
28 | return Math.floor(x).toLocaleString();
29 | }
30 |
31 | export function cleanNumber(str: string): number {
32 | return Number(str?.replace(/[^0-9]+/g, ''));
33 | }
34 |
35 | export function getFromLocalStorage(key: string) {
36 | if (typeof localStorage !== 'undefined') {
37 | return localStorage.getItem(key);
38 | }
39 | return null;
40 | }
41 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { AppProps } from 'next/app';
2 | import Router from 'next/router';
3 | import { SessionProvider } from 'next-auth/react';
4 | import nProgress from 'nprogress';
5 | import { SWRConfig } from 'swr';
6 |
7 | import '@/styles/globals.css';
8 | import '@/styles/nprogress.css';
9 |
10 | import axiosClient from '@/lib/axios';
11 |
12 | import DismissableToast from '@/components/DismissableToast';
13 | import FullScreenLoading from '@/container/FullScreenLoading';
14 |
15 | Router.events.on('routeChangeStart', nProgress.start);
16 | Router.events.on('routeChangeError', nProgress.done);
17 | Router.events.on('routeChangeComplete', nProgress.done);
18 |
19 | type AppAuthProps = AppProps & {
20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
21 | Component: Pick & Partial<{ auth: boolean }>;
22 | };
23 |
24 | function MyApp({ Component, pageProps }: AppAuthProps) {
25 | return (
26 |
27 |
28 |
29 | axiosClient.get(url).then((res) => res.data),
32 | }}
33 | >
34 | {Component.auth ? (
35 |
36 |
37 |
38 | ) : (
39 |
40 | )}
41 |
42 |
43 | );
44 | }
45 |
46 | export default MyApp;
47 |
--------------------------------------------------------------------------------
/src/components/NextImage.tsx:
--------------------------------------------------------------------------------
1 | import Image, { ImageProps } from 'next/image';
2 | import * as React from 'react';
3 |
4 | import clsxm from '@/lib/clsxm';
5 |
6 | type NextImageProps = {
7 | useSkeleton?: boolean;
8 | imgClassName?: string;
9 | blurClassName?: string;
10 | alt: string;
11 | width: string | number;
12 | } & (
13 | | { width: string | number; height: string | number }
14 | | { layout: 'fill'; width?: string | number; height?: string | number }
15 | ) &
16 | ImageProps;
17 |
18 | /**
19 | *
20 | * @description Must set width using `w-` className
21 | * @param useSkeleton add background with pulse animation, don't use it if image is transparent
22 | */
23 | export default function NextImage({
24 | useSkeleton = false,
25 | src,
26 | width,
27 | height,
28 | alt,
29 | className,
30 | imgClassName,
31 | blurClassName,
32 | ...rest
33 | }: NextImageProps) {
34 | const [status, setStatus] = React.useState(
35 | useSkeleton ? 'loading' : 'complete'
36 | );
37 | const widthIsSet = className?.includes('w-') ?? false;
38 |
39 | return (
40 |
44 | setStatus('complete')}
54 | layout='responsive'
55 | {...rest}
56 | />
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/hooks/toast/useSWRWithToast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import toast from 'react-hot-toast';
3 | import { SWRResponse } from 'swr';
4 |
5 | import useLoadingToast from '@/hooks/toast/useLoadingToast';
6 |
7 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
8 |
9 | type OptionType = {
10 | runCondition?: boolean;
11 | loading?: string;
12 | success?: string;
13 | error?: string;
14 | };
15 |
16 | export default function useWithToast(
17 | swr: SWRResponse,
18 | { runCondition = true, ...customMessages }: OptionType = {}
19 | ) {
20 | const { data, error } = swr;
21 |
22 | const toastStatus = React.useRef(data ? 'done' : 'idle');
23 |
24 | const toastMessage = {
25 | ...DEFAULT_TOAST_MESSAGE,
26 | ...customMessages,
27 | };
28 |
29 | React.useEffect(() => {
30 | if (!runCondition) return;
31 |
32 | // if toastStatus is done,
33 | // then it is not the first render or the data is already cached
34 | if (toastStatus.current === 'done') return;
35 |
36 | if (error) {
37 | toast.error(toastMessage.error, { id: toastStatus.current });
38 | toastStatus.current = 'done';
39 | } else if (data) {
40 | toast.success(toastMessage.success, { id: toastStatus.current });
41 | toastStatus.current = 'done';
42 | } else {
43 | toastStatus.current = toast.loading(toastMessage.loading);
44 | }
45 |
46 | return () => {
47 | toast.dismiss(toastStatus.current);
48 | };
49 | }, [
50 | data,
51 | error,
52 | runCondition,
53 | toastMessage.error,
54 | toastMessage.loading,
55 | toastMessage.success,
56 | ]);
57 |
58 | return { ...swr, isLoading: useLoadingToast() };
59 | }
60 |
--------------------------------------------------------------------------------
/src/pages/api/remind.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { User } from 'next-auth';
3 |
4 | import { sendMail } from '@/lib/email.server';
5 | import { numberWithCommas } from '@/lib/helper';
6 | import { prisma } from '@/lib/prisma';
7 | import requireSession from '@/lib/require-session.server';
8 |
9 | export default requireSession(email);
10 | async function email(req: NextApiRequest, res: NextApiResponse, user: User) {
11 | const { userId, amount, description } = req.body;
12 | if (req.method === 'POST') {
13 | const destinationUser = await prisma.user.findFirst({
14 | where: { id: userId },
15 | });
16 | const subject = `Permintaan pelunasan dari ${user.name}`;
17 | const text = `Hai! ${
18 | user.name
19 | } ingin mengingatkan untuk membayar utang sebesar Rp ${numberWithCommas(
20 | amount
21 | )}. Silakan melakukan pembayaran di https://lhoks.thcl.dev/trx/${
22 | user.id
23 | }. Pesan: ${description || '-'}`;
24 | const html = `
25 | Hai!
${
26 | user.name
27 | } ingin mengingatkan untuk membayar utang sebesar
28 | Rp ${numberWithCommas(amount)}
29 | Silakan melakukan pembayaran di link berikut.
32 |
33 | Pesan: ${description || '-'}
34 | `;
35 |
36 | sendMail({
37 | to: destinationUser?.email as string,
38 | toName: destinationUser?.name as string,
39 | subject,
40 | text,
41 | html,
42 | });
43 | res.status(200).json({ message: 'Email sent' });
44 | } else {
45 | res.status(405).json({ message: 'Method Not Allowed' });
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/pages/sandbox/toast-swr.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import * as React from 'react';
3 | import toast from 'react-hot-toast';
4 | import useSWR from 'swr';
5 |
6 | import useLoadingToast from '@/hooks/toast/useLoadingToast';
7 | import useWithToast from '@/hooks/toast/useSWRWithToast';
8 |
9 | import Button from '@/components/buttons/Button';
10 | import Seo from '@/components/Seo';
11 |
12 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
13 |
14 | export type DataTypeApi = { id: number; title: string; completed: boolean };
15 |
16 | export default function SandboxPage() {
17 | const isLoading = useLoadingToast();
18 |
19 | const { data: queryData } = useWithToast(
20 | useSWR('dummy', () =>
21 | axios
22 | .get('https://jsonplaceholder.typicode.com/todos')
23 | .then((res) => res.data)
24 | )
25 | );
26 |
27 | return (
28 | <>
29 |
30 |
31 |
32 |
33 |
toast.success('Hello!')}>Open Toast
34 |
37 | toast.promise(
38 | new Promise(function (resolve) {
39 | setTimeout(resolve, 1000);
40 | }),
41 | {
42 | ...DEFAULT_TOAST_MESSAGE,
43 | }
44 | )
45 | }
46 | >
47 | Submit
48 |
49 | {queryData && (
50 |
{JSON.stringify(queryData)}
51 | )}
52 |
53 |
54 | >
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/store/useDialogStore.tsx:
--------------------------------------------------------------------------------
1 | import { createSelectorHooks } from 'auto-zustand-selectors-hook';
2 | import produce from 'immer';
3 | import create from 'zustand';
4 |
5 | import { DialogOptions } from '@/components/dialog/BaseDialog';
6 |
7 | type DialogStoreType = {
8 | awaitingPromise: {
9 | resolve?: () => void;
10 | reject?: () => void;
11 | };
12 | open: boolean;
13 | state: DialogOptions;
14 | dialog: (options: Partial) => Promise;
15 | handleClose: () => void;
16 | handleSubmit: () => void;
17 | };
18 |
19 | const useDialogStoreBase = create((set) => ({
20 | awaitingPromise: {},
21 | open: false,
22 | state: {
23 | title: 'Title',
24 | description: 'Description',
25 | submitText: 'Yes',
26 | variant: 'warning',
27 | catchOnCancel: false,
28 | },
29 | dialog: (options) => {
30 | set(
31 | produce((state) => {
32 | state.open = true;
33 | state.state = { ...state.state, ...options };
34 | })
35 | );
36 | return new Promise((resolve, reject) => {
37 | set(
38 | produce((state) => {
39 | state.awaitingPromise = { resolve, reject };
40 | })
41 | );
42 | });
43 | },
44 | handleClose: () => {
45 | set(
46 | produce((state) => {
47 | state.state.catchOnCancel && state.awaitingPromise?.reject?.();
48 | state.open = false;
49 | })
50 | );
51 | },
52 | handleSubmit: () => {
53 | set(
54 | produce((state) => {
55 | state.awaitingPromise?.resolve?.();
56 | state.open = false;
57 | })
58 | );
59 | },
60 | }));
61 |
62 | const useDialogStore = createSelectorHooks(useDialogStoreBase);
63 |
64 | export default useDialogStore;
65 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "postgresql"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | }
9 |
10 | model Account {
11 | id String @id @default(cuid())
12 | userId String
13 | type String
14 | provider String
15 | providerAccountId String
16 | refresh_token String? @db.Text
17 | access_token String? @db.Text
18 | expires_at Int?
19 | token_type String?
20 | scope String?
21 | id_token String? @db.Text
22 | session_state String?
23 |
24 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
25 |
26 | @@unique([provider, providerAccountId])
27 | }
28 |
29 | model Session {
30 | id String @id @default(cuid())
31 | sessionToken String @unique
32 | userId String
33 | expires DateTime
34 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
35 | }
36 |
37 | model User {
38 | id String @id @default(cuid())
39 | name String?
40 | email String? @unique
41 | emailVerified DateTime?
42 | image String?
43 | phoneNumber String?
44 | accounts Account[]
45 | sessions Session[]
46 | transactions Transaction[]
47 | }
48 |
49 | model VerificationToken {
50 | identifier String
51 | token String @unique
52 | expires DateTime
53 |
54 | @@unique([identifier, token])
55 | }
56 |
57 | model Transaction {
58 | id String @id @default(cuid())
59 | userId String
60 | amount Int
61 | description String
62 | date DateTime
63 | destinationUserId String
64 |
65 | user User @relation(fields: [userId], references: [id])
66 | }
67 |
--------------------------------------------------------------------------------
/src/pages/sandbox/dialog-zustand.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import * as React from 'react';
3 |
4 | import useDialog from '@/hooks/useDialog';
5 |
6 | import Button from '@/components/buttons/Button';
7 | import Seo from '@/components/Seo';
8 |
9 | export default function UseDialogPage() {
10 | const dialog = useDialog();
11 |
12 | const openSuccess = () => {
13 | dialog({
14 | title: 'Success title',
15 | description: 'Success description whatever you want',
16 | submitText: 'Hurray',
17 | variant: 'success',
18 | catchOnCancel: true,
19 | })
20 | .then(() => console.log('accept'))
21 | .catch(() => console.log('reject'));
22 | };
23 | const openWarning = () => {
24 | dialog({
25 | title: 'Warning title !!!',
26 | description: 'Warning description whatever you want',
27 | submitText: 'Sure',
28 | variant: 'warning',
29 | catchOnCancel: true,
30 | })
31 | .then(() => console.log('accept'))
32 | .catch(() => console.log('reject'));
33 | };
34 | const openDanger = () => {
35 | dialog({
36 | title: "Danger action! Don't do it",
37 | description: 'Danger description, are you sure?',
38 | submitText: 'Do it',
39 | variant: 'danger',
40 | catchOnCancel: true,
41 | })
42 | .then(() => console.log('accept'))
43 | .catch(() => console.log('reject'));
44 | };
45 |
46 | return (
47 | <>
48 |
49 |
50 |
51 |
52 | Success Alert
53 | Warning Alert
54 | Danger Alert
55 |
56 |
57 | >
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing 👨💻
2 |
3 | Thank you for your interest to contribute!
4 |
5 | I would love your help to improve this project. Here are some tips and guidelines to help you along the way.
6 |
7 | ## Issues 🐞
8 |
9 | If you come across a bug or something that can be improved, please [open an issue](https://github.com/theodorusclarence/lhokutang/issues). It would be helpful if you provide some description or screen recording!
10 |
11 | For improvements, before you start working on it, please discuss it first so I can ensure to merge your beautiful work into the project. I'll do my best to answer quickly and discuss the upcoming ideas 🙌
12 |
13 | ## Pull Requests 🔃
14 |
15 | You can directly open a pull request for a bug fix or content typos.
16 |
17 | ## Project Setup 🔧
18 |
19 | If you want to set up the project locally, feel free to follow these steps:
20 |
21 | First, **fork the repo**, then:
22 |
23 | ```sh
24 | git clone
25 | cd ./lhokutang
26 |
27 | # Copy the .env.example to .env
28 | cp .env.example .env
29 |
30 | # Install the dependencies
31 | yarn
32 | ```
33 |
34 | You're required to add some env, check the `.env.example` for the steps.
35 |
36 | After all envs are filled, create a postgres database named `lhokutang`. Next:
37 |
38 | ```sh
39 | # Run all migrations
40 | npx prisma migrate dev
41 |
42 | # Run the server
43 | yarn dev
44 | ```
45 |
46 | You can now open up `http://localhost:3000` and start writing code!
47 |
48 | ## Format 💅
49 |
50 | When writing your code, please try to follow the existing code style.
51 |
52 | Your code will be automatically linted and formatted before each commit. However, if you want to manually lint and format, use the provided yarn scripts.
53 |
54 | ```sh
55 | yarn lint:fix
56 | yarn format
57 | ```
58 |
59 | You also have to follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for the commit message.
60 |
--------------------------------------------------------------------------------
/prisma/migrations/20220304162448_user_with_google/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Account" (
3 | "id" TEXT NOT NULL,
4 | "userId" TEXT NOT NULL,
5 | "type" TEXT NOT NULL,
6 | "provider" TEXT NOT NULL,
7 | "providerAccountId" TEXT NOT NULL,
8 | "refresh_token" TEXT,
9 | "access_token" TEXT,
10 | "expires_at" INTEGER,
11 | "token_type" TEXT,
12 | "scope" TEXT,
13 | "id_token" TEXT,
14 | "session_state" TEXT,
15 |
16 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
17 | );
18 |
19 | -- CreateTable
20 | CREATE TABLE "Session" (
21 | "id" TEXT NOT NULL,
22 | "sessionToken" TEXT NOT NULL,
23 | "userId" TEXT NOT NULL,
24 | "expires" TIMESTAMP(3) NOT NULL,
25 |
26 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
27 | );
28 |
29 | -- CreateTable
30 | CREATE TABLE "User" (
31 | "id" TEXT NOT NULL,
32 | "name" TEXT,
33 | "email" TEXT,
34 | "emailVerified" TIMESTAMP(3),
35 | "image" TEXT,
36 |
37 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
38 | );
39 |
40 | -- CreateTable
41 | CREATE TABLE "VerificationToken" (
42 | "identifier" TEXT NOT NULL,
43 | "token" TEXT NOT NULL,
44 | "expires" TIMESTAMP(3) NOT NULL
45 | );
46 |
47 | -- CreateIndex
48 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
49 |
50 | -- CreateIndex
51 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
52 |
53 | -- CreateIndex
54 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
55 |
56 | -- CreateIndex
57 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
58 |
59 | -- CreateIndex
60 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
61 |
62 | -- AddForeignKey
63 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
64 |
65 | -- AddForeignKey
66 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
67 |
--------------------------------------------------------------------------------
/src/components/UserListItem.tsx:
--------------------------------------------------------------------------------
1 | import { User } from '@prisma/client';
2 | import * as React from 'react';
3 | import { FaMoneyBillWave } from 'react-icons/fa';
4 |
5 | import clsxm from '@/lib/clsxm';
6 | import { numberWithCommas } from '@/lib/helper';
7 |
8 | import ButtonLink from '@/components/links/ButtonLink';
9 | import NextImage from '@/components/NextImage';
10 |
11 | type UserListItemProps = {
12 | user: { amount: number } & User;
13 | } & React.ComponentPropsWithoutRef<'div'>;
14 |
15 | export default function UserListItem({
16 | className,
17 | user,
18 | ...rest
19 | }: UserListItemProps) {
20 | return (
21 |
22 |
23 | {user.image ? (
24 |
31 | ) : (
32 |
33 | )}
34 |
35 |
{user.name}
36 |
37 | {user.amount ? (
38 | 0,
41 | 'text-red-600': user.amount < 0,
42 | })}
43 | >
44 | {user.amount > 0 ? '🤑' : '😭'}{' '}
45 | {user.amount > 0
46 | ? numberWithCommas(user.amount)
47 | : numberWithCommas(-user.amount) ?? 0}
48 |
49 | ) : (
50 | '👍 Lunas'
51 | )}
52 |
53 |
54 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/forms/TextArea.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { RegisterOptions, useFormContext } from 'react-hook-form';
3 | import { HiExclamationCircle } from 'react-icons/hi';
4 |
5 | export type TextAreaProps = {
6 | label: string;
7 | id: string;
8 | placeholder?: string;
9 | helperText?: string;
10 | readOnly?: boolean;
11 | hideError?: boolean;
12 | validation?: RegisterOptions;
13 | } & React.ComponentPropsWithoutRef<'textarea'>;
14 |
15 | export default function TextArea({
16 | label,
17 | placeholder = '',
18 | helperText,
19 | id,
20 | readOnly = false,
21 | hideError = false,
22 | validation,
23 | ...rest
24 | }: TextAreaProps) {
25 | const {
26 | register,
27 | formState: { errors },
28 | } = useFormContext();
29 |
30 | return (
31 |
32 |
33 | {label}
34 |
35 |
36 |
54 | {!hideError && errors[id] && (
55 |
56 |
57 |
58 | )}
59 |
60 |
61 | {helperText &&
{helperText}
}
62 | {!hideError && errors[id] && (
63 |
{errors[id].message}
64 | )}
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/links/ArrowLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import UnderlineLink from '@/components/links/UnderlineLink';
6 | import { UnstyledLinkProps } from '@/components/links/UnstyledLink';
7 |
8 | type ArrowLinkProps = {
9 | as?: C;
10 | direction?: 'left' | 'right';
11 | } & UnstyledLinkProps &
12 | React.ComponentProps;
13 |
14 | export default function ArrowLink({
15 | children,
16 | className,
17 | direction = 'right',
18 | as,
19 | ...rest
20 | }: ArrowLinkProps) {
21 | const Component = as || UnderlineLink;
22 |
23 | return (
24 |
32 | {children}
33 |
46 |
50 |
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const { fontFamily } = require('tailwindcss/defaultTheme');
3 |
4 | function withOpacityValue(variable) {
5 | return ({ opacityValue }) => {
6 | if (opacityValue === undefined) {
7 | return `rgb(var(${variable}))`;
8 | }
9 | return `rgb(var(${variable}) / ${opacityValue})`;
10 | };
11 | }
12 |
13 | /** @type {import("@types/tailwindcss/tailwind-config").TailwindConfig } */
14 | module.exports = {
15 | content: ['./src/**/*.{js,jsx,ts,tsx}'],
16 | theme: {
17 | extend: {
18 | fontFamily: {
19 | primary: ['Inter', ...fontFamily.sans],
20 | },
21 | colors: {
22 | primary: {
23 | // Customize it on globals.css :root
24 | 50: withOpacityValue('--tw-color-primary-50'),
25 | 100: withOpacityValue('--tw-color-primary-100'),
26 | 200: withOpacityValue('--tw-color-primary-200'),
27 | 300: withOpacityValue('--tw-color-primary-300'),
28 | 400: withOpacityValue('--tw-color-primary-400'),
29 | 500: withOpacityValue('--tw-color-primary-500'),
30 | 600: withOpacityValue('--tw-color-primary-600'),
31 | 700: withOpacityValue('--tw-color-primary-700'),
32 | 800: withOpacityValue('--tw-color-primary-800'),
33 | 900: withOpacityValue('--tw-color-primary-900'),
34 | },
35 | dark: '#222222',
36 | },
37 | keyframes: {
38 | flicker: {
39 | '0%, 19.999%, 22%, 62.999%, 64%, 64.999%, 70%, 100%': {
40 | opacity: 0.99,
41 | filter:
42 | 'drop-shadow(0 0 1px rgba(252, 211, 77)) drop-shadow(0 0 15px rgba(245, 158, 11)) drop-shadow(0 0 1px rgba(252, 211, 77))',
43 | },
44 | '20%, 21.999%, 63%, 63.999%, 65%, 69.999%': {
45 | opacity: 0.4,
46 | filter: 'none',
47 | },
48 | },
49 | shimmer: {
50 | '0%': {
51 | backgroundPosition: '-700px 0',
52 | },
53 | '100%': {
54 | backgroundPosition: '700px 0',
55 | },
56 | },
57 | },
58 | animation: {
59 | flicker: 'flicker 3s linear infinite',
60 | shimmer: 'shimmer 1.3s linear infinite',
61 | },
62 | },
63 | },
64 | plugins: [require('@tailwindcss/forms')],
65 | };
66 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | },
7 | plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'],
8 | extends: [
9 | 'eslint:recommended',
10 | 'next',
11 | 'next/core-web-vitals',
12 | 'plugin:@typescript-eslint/recommended',
13 | 'prettier',
14 | ],
15 | rules: {
16 | 'no-unused-vars': 'off',
17 | 'no-console': 'warn',
18 | '@typescript-eslint/explicit-module-boundary-types': 'off',
19 |
20 | 'react/display-name': 'off',
21 | 'react/no-unescaped-entities': 'off',
22 | 'react/jsx-curly-brace-presence': [
23 | 'warn',
24 | { props: 'never', children: 'never' },
25 | ],
26 |
27 | //#region //*=========== Unused Import ===========
28 | '@typescript-eslint/no-unused-vars': 'off',
29 | 'unused-imports/no-unused-imports': 'warn',
30 | 'unused-imports/no-unused-vars': [
31 | 'warn',
32 | {
33 | vars: 'all',
34 | varsIgnorePattern: '^_',
35 | args: 'after-used',
36 | argsIgnorePattern: '^_',
37 | },
38 | ],
39 | //#endregion //*======== Unused Import ===========
40 |
41 | //#region //*=========== Import Sort ===========
42 | 'simple-import-sort/exports': 'warn',
43 | 'simple-import-sort/imports': [
44 | 'warn',
45 | {
46 | groups: [
47 | // ext library & side effect imports
48 | ['^@?\\w', '^\\u0000'],
49 | // {s}css files
50 | ['^.+\\.s?css$'],
51 | // Lib and hooks
52 | ['^@/lib', '^@/hooks'],
53 | // static data
54 | ['^@/data'],
55 | // components
56 | ['^@/components', '^@/container'],
57 | // zustand store
58 | ['^@/store'],
59 | // Other imports
60 | ['^@/'],
61 | // relative paths up until 3 level
62 | [
63 | '^\\./?$',
64 | '^\\.(?!/?$)',
65 | '^\\.\\./?$',
66 | '^\\.\\.(?!/?$)',
67 | '^\\.\\./\\.\\./?$',
68 | '^\\.\\./\\.\\.(?!/?$)',
69 | '^\\.\\./\\.\\./\\.\\./?$',
70 | '^\\.\\./\\.\\./\\.\\.(?!/?$)',
71 | ],
72 | ['^@/types'],
73 | // other that didnt fit in
74 | ['^'],
75 | ],
76 | },
77 | ],
78 | //#endregion //*======== Import Sort ===========
79 | },
80 | globals: {
81 | React: true,
82 | JSX: true,
83 | },
84 | };
85 |
--------------------------------------------------------------------------------
/src/components/forms/UserCheckboxes.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useFormContext } from 'react-hook-form';
3 |
4 | import clsxm from '@/lib/clsxm';
5 |
6 | import UserImage from '@/components/UserImage';
7 | import { UserSelectPeople } from '@/components/UserSelect';
8 |
9 | import { alumnusEmail } from '@/constant/email-whitelist';
10 |
11 | type UserCheckboxesProps = {
12 | users: UserSelectPeople[];
13 | id: string;
14 | } & React.ComponentPropsWithoutRef<'div'>;
15 |
16 | export default function UserCheckboxes({
17 | className,
18 | users: _users,
19 | id,
20 | ...rest
21 | }: UserCheckboxesProps) {
22 | const users = _users.filter(
23 | (user) => !alumnusEmail.includes(user.email ?? '')
24 | );
25 | const alumnus = _users.filter((user) =>
26 | alumnusEmail.includes(user.email ?? '')
27 | );
28 |
29 | return (
30 |
31 |
32 |
33 | Pilih Penghuni
34 |
35 |
36 | {users.map((user) => (
37 |
38 | ))}
39 |
40 | Alumni
41 |
42 | {alumnus.map((user) => (
43 |
44 | ))}
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | function UserCheckboxItem({
52 | user,
53 | id,
54 | }: {
55 | user: UserSelectPeople;
56 | id: string;
57 | }) {
58 | const { register } = useFormContext();
59 |
60 | return (
61 |
62 |
63 |
67 |
68 | {user.name}
69 |
70 |
71 |
72 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/src/pages/api/trx/create-many-by-id.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from '@prisma/client';
2 | import { NextApiRequest, NextApiResponse } from 'next';
3 | import { User } from 'next-auth';
4 |
5 | import { sendMail } from '@/lib/email.server';
6 | import { numberWithCommas } from '@/lib/helper';
7 | import { prisma } from '@/lib/prisma';
8 | import requireSession from '@/lib/require-session.server';
9 |
10 | export type CreateManyByIDBody = {
11 | transactionData: {
12 | id: string;
13 | amount: number;
14 | }[];
15 | date: Date;
16 | description: string;
17 | };
18 |
19 | export default requireSession(handler);
20 |
21 | async function handler(req: NextApiRequest, res: NextApiResponse, user: User) {
22 | const { transactionData, date, description }: CreateManyByIDBody = req.body;
23 |
24 | if (req.method === 'POST') {
25 | try {
26 | await prisma.transaction.createMany({
27 | data: transactionData.map(({ id, amount }) => ({
28 | amount,
29 | date: new Date(date),
30 | description,
31 | destinationUserId: id,
32 | userId: user.id,
33 | })),
34 | });
35 |
36 | const users = await prisma.user.findMany();
37 |
38 | for (const transaction of transactionData) {
39 | const subject = `Request uang dari ${user.name}`;
40 | const text = `Hai! ${
41 | user.name
42 | } baru saja melakukan request uang sebesar Rp ${numberWithCommas(
43 | transaction.amount
44 | )} dengan keterangan: ${description}. Silakan melakukan pembayaran di https://lhoks.thcl.dev/trx/${
45 | user.id
46 | }`;
47 | const html = `
48 | Hai!
${
49 | user.name
50 | } baru saja melakukan request uang sebesar
51 | Rp ${numberWithCommas(transaction.amount)}
52 | dengan keterangan: ${description} . Silakan melakukan pembayaran di link berikut.
55 | `;
56 |
57 | sendMail({
58 | to: users.find((user) => user.id === transaction.id)?.email as string,
59 | toName: users.find((user) => user.id === transaction.id)
60 | ?.name as string,
61 | subject,
62 | text,
63 | html,
64 | });
65 | }
66 |
67 | return res.status(201).json({ message: 'Transactions created' });
68 | } catch (error) {
69 | if (error instanceof Prisma.PrismaClientUnknownRequestError)
70 | return res.status(500).send(error.message);
71 | else {
72 | throw error;
73 | }
74 | }
75 | } else {
76 | res.status(405).json({ message: 'Method Not Allowed' });
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/pages/api/trx/create-many.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from '@prisma/client';
2 | import { NextApiRequest, NextApiResponse } from 'next';
3 | import { User } from 'next-auth';
4 |
5 | import { sendMail } from '@/lib/email.server';
6 | import { numberWithCommas } from '@/lib/helper';
7 | import { prisma } from '@/lib/prisma';
8 | import requireSession from '@/lib/require-session.server';
9 |
10 | export type CreateManyBody = {
11 | destinationUserIdList: string[];
12 | amountPerPerson: number;
13 | date: Date;
14 | description: string;
15 | };
16 |
17 | export default requireSession(handler);
18 |
19 | async function handler(req: NextApiRequest, res: NextApiResponse, user: User) {
20 | const { destinationUserIdList, amountPerPerson, date, description } =
21 | req.body;
22 |
23 | if (req.method === 'POST') {
24 | try {
25 | await prisma.transaction.createMany({
26 | data: (destinationUserIdList as string[]).map((destinationUserId) => ({
27 | amount: amountPerPerson,
28 | date: new Date(date),
29 | description,
30 | destinationUserId,
31 | userId: user.id,
32 | })),
33 | });
34 |
35 | const users = await prisma.user.findMany();
36 |
37 | for (const destinationUserId of destinationUserIdList) {
38 | const subject = `Request uang dari ${user.name}`;
39 | const text = `Hai! ${
40 | user.name
41 | } baru saja melakukan request uang sebesar Rp ${numberWithCommas(
42 | amountPerPerson
43 | )} dengan keterangan: ${description}. Silakan melakukan pembayaran di https://lhoks.thcl.dev/trx/${
44 | user.id
45 | }`;
46 | const html = `
47 | Hai!
${
48 | user.name
49 | } baru saja melakukan request uang sebesar
50 | Rp ${numberWithCommas(amountPerPerson)}
51 | dengan keterangan: ${description} . Silakan melakukan pembayaran di link berikut.
54 | `;
55 |
56 | sendMail({
57 | to: users.find((user) => user.id === destinationUserId)
58 | ?.email as string,
59 | toName: users.find((user) => user.id === destinationUserId)
60 | ?.name as string,
61 | subject,
62 | text,
63 | html,
64 | });
65 | }
66 |
67 | return res.status(201).json({ message: 'Transactions created' });
68 | } catch (error) {
69 | if (error instanceof Prisma.PrismaClientUnknownRequestError)
70 | return res.status(500).send(error.message);
71 | else {
72 | throw error;
73 | }
74 | }
75 | } else {
76 | res.status(405).json({ message: 'Method Not Allowed' });
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/links/ButtonLink.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import clsxm from '@/lib/clsxm';
4 |
5 | import UnstyledLink, {
6 | UnstyledLinkProps,
7 | } from '@/components/links/UnstyledLink';
8 |
9 | enum ButtonVariant {
10 | 'primary',
11 | 'outline',
12 | 'ghost',
13 | 'light',
14 | 'dark',
15 | }
16 |
17 | type ButtonLinkProps = {
18 | isDarkBg?: boolean;
19 | variant?: keyof typeof ButtonVariant;
20 | } & UnstyledLinkProps;
21 |
22 | const ButtonLink = React.forwardRef(
23 | (
24 | { children, className, variant = 'primary', isDarkBg = false, ...rest },
25 | ref
26 | ) => {
27 | return (
28 |
76 | {children}
77 |
78 | );
79 | }
80 | );
81 |
82 | export default ButtonLink;
83 |
--------------------------------------------------------------------------------
/src/components/forms/Input.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import * as React from 'react';
3 | import { RegisterOptions, useFormContext } from 'react-hook-form';
4 | import { HiExclamationCircle } from 'react-icons/hi';
5 |
6 | export type InputProps = {
7 | /** Input label */
8 | label: string;
9 | /**
10 | * id to be initialized with React Hook Form,
11 | * must be the same with the pre-defined types.
12 | */
13 | id: string;
14 | /** Input placeholder */
15 | placeholder?: string;
16 | /** Small text below input, useful for additional information */
17 | helperText?: string;
18 | /**
19 | * Input type
20 | * @example text, email, password
21 | */
22 | type?: React.HTMLInputTypeAttribute;
23 | /** Disables the input and shows defaultValue (can be set from React Hook Form) */
24 | readOnly?: boolean;
25 | /** Disable error style (not disabling error validation) */
26 | hideError?: boolean;
27 | /** Manual validation using RHF, it is encouraged to use yup resolver instead */
28 | validation?: RegisterOptions;
29 | } & React.ComponentPropsWithoutRef<'input'>;
30 |
31 | export default function Input({
32 | label,
33 | placeholder = '',
34 | helperText,
35 | id,
36 | type = 'text',
37 | readOnly = false,
38 | hideError = false,
39 | validation,
40 | ...rest
41 | }: InputProps) {
42 | const {
43 | register,
44 | formState: { errors },
45 | } = useFormContext();
46 |
47 | return (
48 |
49 |
50 | {label}
51 |
52 |
53 |
71 |
72 | {!hideError && errors[id] && (
73 |
74 |
75 |
76 | )}
77 |
78 |
79 | {helperText &&
{helperText}
}
80 | {!hideError && errors[id] && (
81 |
{errors[id].message}
82 | )}
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/pages/api/trx/summary.ts:
--------------------------------------------------------------------------------
1 | import { User as PrismaUser } from '@prisma/client';
2 | import { NextApiRequest, NextApiResponse } from 'next';
3 | import { User } from 'next-auth';
4 |
5 | import { prisma } from '@/lib/prisma';
6 | import requireSession from '@/lib/require-session.server';
7 |
8 | export default requireSession(summary);
9 |
10 | async function summary(req: NextApiRequest, res: NextApiResponse, user: User) {
11 | if (req.method === 'GET') {
12 | const _transactions = await prisma.transaction.findMany({
13 | where: {
14 | OR: [
15 | {
16 | userId: user.id,
17 | },
18 | {
19 | destinationUserId: user.id,
20 | },
21 | ],
22 | },
23 | });
24 |
25 | const transactions = _transactions.map((transaction) => {
26 | let type: 'pay' | 'paid' | 'utang' | 'piutang';
27 | let destinationUser: string;
28 |
29 | if (transaction.description.startsWith('Pelunasan')) {
30 | if (transaction.userId === user.id) {
31 | // We pay to the user
32 | type = 'pay';
33 | destinationUser = transaction.destinationUserId;
34 | } else {
35 | // We receive from the user
36 | type = 'paid';
37 | destinationUser = transaction.userId;
38 | }
39 | } else {
40 | if (transaction.userId === user.id) {
41 | type = 'utang';
42 | destinationUser = transaction.destinationUserId;
43 | } else {
44 | type = 'piutang';
45 | destinationUser = transaction.userId;
46 | }
47 | }
48 |
49 | return {
50 | type,
51 | amount: transaction.amount,
52 | destinationUser,
53 | };
54 | });
55 |
56 | const usersDebt = transactions.reduce>(
57 | (acc, current) => {
58 | if (acc[current.destinationUser]) {
59 | if (current.type === 'pay' || current.type === 'utang') {
60 | acc[current.destinationUser].amount += current.amount;
61 | } else {
62 | acc[current.destinationUser].amount -= current.amount;
63 | }
64 | } else {
65 | acc[current.destinationUser] = {
66 | amount:
67 | current.type === 'pay' || current.type === 'utang'
68 | ? current.amount
69 | : -current.amount,
70 | };
71 | }
72 |
73 | return acc;
74 | },
75 | {}
76 | );
77 |
78 | // sort if not 0
79 | const summary: ({ amount: number } & PrismaUser)[] = (
80 | await prisma.user.findMany()
81 | )
82 | .map((user) => ({ ...user, amount: usersDebt?.[user.id]?.amount ?? 0 }))
83 | // Bring lunas to the end
84 | .sort((a) => (a.amount === 0 ? 1 : -1));
85 |
86 | return res.status(200).json({ summary });
87 | } else {
88 | res.status(405).json({ message: 'Method Not Allowed' });
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ts-nextjs-tailwind-starter",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "lint:fix": "eslint src --fix && yarn format",
11 | "lint:strict": "eslint --max-warnings=0 src",
12 | "typecheck": "tsc --noEmit --incremental false",
13 | "test:watch": "jest --watch",
14 | "test": "jest",
15 | "format": "prettier -w .",
16 | "format:check": "prettier -c .",
17 | "release": "standard-version",
18 | "push-release": "git push --follow-tags origin main",
19 | "postbuild": "next-sitemap",
20 | "prepare": "husky install"
21 | },
22 | "dependencies": {
23 | "@headlessui/react": "^1.6.6",
24 | "@next-auth/prisma-adapter": "^1.0.1",
25 | "@prisma/client": "^3.10.0",
26 | "auto-zustand-selectors-hook": "^2.0.0",
27 | "axios": "^0.26.0",
28 | "clsx": "^1.1.1",
29 | "date-fns": "^2.28.0",
30 | "immer": "^9.0.15",
31 | "next": "^12.1.0",
32 | "next-auth": "^4.2.1",
33 | "node-mailjet": "^5.1.1",
34 | "nodemailer": "^6.7.2",
35 | "nprogress": "^0.2.0",
36 | "react": "^17.0.2",
37 | "react-datepicker": "^4.7.0",
38 | "react-dom": "^17.0.2",
39 | "react-dropzone": "^12.0.4",
40 | "react-hook-form": "^7.27.1",
41 | "react-hot-toast": "^2.2.0",
42 | "react-icons": "^4.3.1",
43 | "react-image-lightbox": "^5.1.4",
44 | "swr": "^1.2.2",
45 | "tailwind-merge": "^1.2.0",
46 | "zustand": "^4.1.0"
47 | },
48 | "devDependencies": {
49 | "@commitlint/cli": "^13.2.1",
50 | "@commitlint/config-conventional": "^13.2.0",
51 | "@svgr/webpack": "^6.2.1",
52 | "@tailwindcss/forms": "^0.4.0",
53 | "@testing-library/jest-dom": "^5.16.2",
54 | "@testing-library/react": "^12.1.3",
55 | "@types/node-mailjet": "^3.3.9",
56 | "@types/nodemailer": "^6.4.4",
57 | "@types/nprogress": "^0.2.0",
58 | "@types/react": "^17.0.39",
59 | "@types/react-datepicker": "^4.3.4",
60 | "@types/tailwindcss": "^2.2.4",
61 | "@types/umami": "^0.1.2",
62 | "@typescript-eslint/eslint-plugin": "^4.33.0",
63 | "@typescript-eslint/parser": "^4.33.0",
64 | "autoprefixer": "^10.4.2",
65 | "eslint": "^7.32.0",
66 | "eslint-config-next": "^11.1.4",
67 | "eslint-config-prettier": "^8.4.0",
68 | "eslint-plugin-simple-import-sort": "^7.0.0",
69 | "eslint-plugin-unused-imports": "^1.1.5",
70 | "husky": "^7.0.4",
71 | "jest": "^27.5.1",
72 | "lint-staged": "^11.2.6",
73 | "next-sitemap": "^1.9.12",
74 | "postcss": "^8.4.7",
75 | "prettier": "^2.5.1",
76 | "prettier-plugin-tailwindcss": "^0.1.8",
77 | "prisma": "^3.10.0",
78 | "standard-version": "^9.3.2",
79 | "tailwindcss": "^3.0.23",
80 | "typescript": "^4.5.5"
81 | },
82 | "lint-staged": {
83 | "src/**/*.{js,jsx,ts,tsx,}": [
84 | "eslint --max-warnings=0",
85 | "prettier -w"
86 | ],
87 | "src/**/*.{json,css,scss,md}": [
88 | "prettier -w"
89 | ]
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/components/forms/SelectInput.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import * as React from 'react';
3 | import { RegisterOptions, useFormContext } from 'react-hook-form';
4 | import { HiExclamationCircle } from 'react-icons/hi';
5 |
6 | export type SelectInputProps = {
7 | label: string;
8 | id: string;
9 | placeholder?: string;
10 | helperText?: string;
11 | type?: string;
12 | readOnly?: boolean;
13 | validation?: RegisterOptions;
14 | children: React.ReactNode;
15 | } & React.ComponentPropsWithoutRef<'select'>;
16 |
17 | export default function SelectInput({
18 | label,
19 | helperText,
20 | id,
21 | placeholder,
22 | readOnly = false,
23 | children,
24 | validation,
25 | ...rest
26 | }: SelectInputProps) {
27 | const {
28 | register,
29 | formState: { errors },
30 | watch,
31 | } = useFormContext();
32 |
33 | const value = watch(id);
34 |
35 | // Add disabled and selected attribute to option, will be used if readonly
36 | const readOnlyChildren = React.Children.map(
37 | children,
38 | (child) => {
39 | if (React.isValidElement(child)) {
40 | return React.cloneElement(child, {
41 | disabled: child.props.value !== rest?.defaultValue,
42 | // selected: child.props.value === rest?.defaultValue,
43 | });
44 | }
45 | }
46 | );
47 |
48 | return (
49 |
50 |
51 | {label}
52 |
53 |
54 |
72 | {placeholder && (
73 |
74 | {placeholder}
75 |
76 | )}
77 | {readOnly ? readOnlyChildren : children}
78 |
79 |
80 | {errors[id] && (
81 |
82 |
83 |
84 | )}
85 |
86 |
87 | {helperText &&
{helperText}
}
88 | {errors[id] && (
89 |
{errors[id].message}
90 | )}
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/pages/api/trx/create.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from '@prisma/client';
2 | import { NextApiRequest, NextApiResponse } from 'next';
3 | import { User } from 'next-auth';
4 |
5 | import { sendMail } from '@/lib/email.server';
6 | import { numberWithCommas } from '@/lib/helper';
7 | import { prisma } from '@/lib/prisma';
8 | import requireSession from '@/lib/require-session.server';
9 |
10 | export default requireSession(create);
11 |
12 | async function create(req: NextApiRequest, res: NextApiResponse, user: User) {
13 | const { destinationUserId, amount, date, description } = req.body;
14 |
15 | if (req.method === 'POST') {
16 | try {
17 | await prisma.transaction.create({
18 | data: {
19 | amount,
20 | date: new Date(date),
21 | description,
22 | destinationUserId,
23 | userId: user.id,
24 | },
25 | });
26 |
27 | const destinationUser = await prisma.user.findFirst({
28 | where: { id: destinationUserId },
29 | });
30 |
31 | let text, html, subject;
32 |
33 | if (description.startsWith('Pelunasan')) {
34 | subject = `Pelunasan dari ${user.name}`;
35 | text = `Hai! ${
36 | user.name
37 | } baru saja melakukan pelunasan sebesar Rp ${numberWithCommas(
38 | amount
39 | )}. Anda bisa mengecek pembayaran di https://lhoks.thcl.dev/trx/${
40 | user.id
41 | }`;
42 | html = `
43 | Hai!
${
44 | user.name
45 | } baru saja melakukan pelunasan sebesar
46 | Rp ${numberWithCommas(amount)}
47 | . Anda bisa mengecek pembayaran di link berikut.
50 | `;
51 | } else {
52 | subject = `Request uang dari ${user.name}`;
53 | text = `Hai! ${
54 | user.name
55 | } baru saja melakukan request uang sebesar Rp ${numberWithCommas(
56 | amount
57 | )} dengan keterangan: ${description}. Silakan melakukan pembayaran di https://lhoks.thcl.dev/trx/${
58 | user.id
59 | }`;
60 | html = `
61 | Hai!
${
62 | user.name
63 | } baru saja melakukan request uang sebesar
64 | Rp ${numberWithCommas(amount)}
65 | dengan keterangan: ${description} . Silakan melakukan pembayaran di link berikut.
68 | `;
69 | }
70 |
71 | sendMail({
72 | to: destinationUser?.email as string,
73 | toName: destinationUser?.name as string,
74 | subject,
75 | text,
76 | html,
77 | });
78 |
79 | return res.status(201).json({ message: 'Transaction created' });
80 | } catch (error) {
81 | if (error instanceof Prisma.PrismaClientUnknownRequestError)
82 | return res.status(500).send(error.message);
83 | else {
84 | throw error;
85 | }
86 | }
87 | } else {
88 | return res.status(405).json({ message: 'Method Not Allowed' });
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/pages/list.tsx:
--------------------------------------------------------------------------------
1 | import { User } from '@prisma/client';
2 | import { useSession } from 'next-auth/react';
3 | import * as React from 'react';
4 | import useSWR from 'swr';
5 |
6 | import clsxm from '@/lib/clsxm';
7 | import { numberWithCommas } from '@/lib/helper';
8 | import useWithToast from '@/hooks/toast/useSWRWithToast';
9 |
10 | import Layout from '@/components/layout/Layout';
11 | import PrimaryLink from '@/components/links/PrimaryLink';
12 | import Seo from '@/components/Seo';
13 | import UserListItem from '@/components/UserListItem';
14 |
15 | import { alumnusEmail } from '@/constant/email-whitelist';
16 |
17 | ListPage.auth = true;
18 |
19 | export default function ListPage() {
20 | const { data: sessionData } = useSession();
21 | const { data: summaryData } = useWithToast(
22 | useSWR<{ summary: ({ amount: number } & User)[] }>('/api/trx/summary'),
23 | {
24 | loading: 'Menghitung utang-piutang anda...',
25 | }
26 | );
27 | const _users =
28 | summaryData?.summary.filter((user) => user.id !== sessionData?.user?.id) ??
29 | [];
30 |
31 | const users = _users.filter(
32 | (user) => !alumnusEmail.includes(user.email ?? '')
33 | );
34 | const alumnus = _users.filter((user) =>
35 | alumnusEmail.includes(user.email ?? '')
36 | );
37 |
38 | const currentUser = summaryData?.summary.find(
39 | (user) => user.id === sessionData?.user.id
40 | );
41 |
42 | const totalDebt = _users.reduce((total, u) => total + u.amount, 0);
43 |
44 | return (
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
Kondisi Perutangan Anda
53 |
0,
56 | 'text-red-600': totalDebt < 0,
57 | })}
58 | >
59 | {totalDebt ? (
60 |
61 | {totalDebt > 0 ? '🤑' : '😭'}{' '}
62 | {totalDebt > 0
63 | ? numberWithCommas(totalDebt)
64 | : numberWithCommas(-totalDebt) ?? 0}
65 |
66 | ) : (
67 | '👍 0'
68 | )}
69 |
70 |
71 |
Penghuni
72 | {currentUser?.phoneNumber === null && (
73 |
74 |
75 | Anda masih belum memasukkan nomor telepon, silakan menambahkan
76 | pada link ini
77 |
78 |
79 | )}
80 | {users.map((user) => (
81 |
82 | ))}
83 |
84 |
Alumni
85 | {alumnus.map((user) => (
86 |
87 | ))}
88 |
89 |
90 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/forms/PasswordInput.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useState } from 'react';
3 | import { RegisterOptions, useFormContext } from 'react-hook-form';
4 | import { HiEye, HiEyeOff } from 'react-icons/hi';
5 |
6 | export type PasswordInputProps = {
7 | /** Input label */
8 | label: string;
9 | /**
10 | * id to be initialized with React Hook Form,
11 | * must be the same with the pre-defined types.
12 | */
13 | id: string;
14 | /** Input placeholder */
15 | placeholder?: string;
16 | /** Small text below input, useful for additional information */
17 | helperText?: string;
18 | /**
19 | * Input type
20 | * @example text, email, password
21 | */
22 | type?: React.HTMLInputTypeAttribute;
23 | /** Disables the input and shows defaultValue (can be set from React Hook Form) */
24 | readOnly?: boolean;
25 | /** Disable error style (not disabling error validation) */
26 | hideError?: boolean;
27 | /** Manual validation using RHF, it is encouraged to use yup resolver instead */
28 | validation?: RegisterOptions;
29 | } & React.ComponentPropsWithoutRef<'input'>;
30 |
31 | export default function PasswordInput({
32 | label,
33 | placeholder = '',
34 | helperText,
35 | id,
36 | readOnly = false,
37 | validation,
38 | ...rest
39 | }: PasswordInputProps) {
40 | const {
41 | register,
42 | formState: { errors },
43 | } = useFormContext();
44 |
45 | const [showPassword, setShowPassword] = useState(false);
46 | const togglePassword = () => setShowPassword((prev) => !prev);
47 |
48 | return (
49 |
50 |
51 | {label}
52 |
53 |
54 |
72 |
73 |
78 | {showPassword ? (
79 |
80 | ) : (
81 |
82 | )}
83 |
84 |
85 |
86 | {helperText &&
{helperText}
}
87 | {errors[id] && (
88 |
{errors[id].message}
89 | )}
90 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/forms/DatePicker.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import ReactDatePicker, { ReactDatePickerProps } from 'react-datepicker';
3 | import { Controller, RegisterOptions, useFormContext } from 'react-hook-form';
4 | import { HiOutlineCalendar } from 'react-icons/hi';
5 |
6 | import 'react-datepicker/dist/react-datepicker.css';
7 |
8 | type DatePickerProps = {
9 | validation?: RegisterOptions;
10 | label: string;
11 | id: string;
12 | placeholder?: string;
13 | defaultYear?: number;
14 | defaultMonth?: number;
15 | defaultValue?: string;
16 | helperText?: string;
17 | readOnly?: boolean;
18 | } & Omit;
19 |
20 | export default function DatePicker({
21 | validation,
22 | label,
23 | id,
24 | placeholder,
25 | defaultYear,
26 | defaultMonth,
27 | defaultValue,
28 | helperText,
29 | readOnly = false,
30 | ...rest
31 | }: DatePickerProps) {
32 | const {
33 | formState: { errors },
34 | control,
35 | } = useFormContext();
36 |
37 | // If there is a year default, then change the year to the props
38 | const defaultDate = new Date();
39 | if (defaultYear) defaultDate.setFullYear(defaultYear);
40 | if (defaultMonth) defaultDate.setMonth(defaultMonth);
41 |
42 | return (
43 |
44 |
45 | {label}
46 |
47 |
48 |
(
54 | <>
55 |
56 |
79 |
80 |
81 |
82 | {helperText !== '' && (
83 |
{helperText}
84 | )}
85 | {errors[id] && (
86 |
87 | {errors[id].message}
88 |
89 | )}
90 |
91 | >
92 | )}
93 | />
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/pages/api/trx/[id].ts:
--------------------------------------------------------------------------------
1 | import { Prisma, User } from '@prisma/client';
2 | import { NextApiRequest, NextApiResponse } from 'next';
3 |
4 | import { prisma } from '@/lib/prisma';
5 | import requireSession from '@/lib/require-session.server';
6 |
7 | export type GetTransactionsApi = {
8 | transactions: {
9 | id: string;
10 | amount: number;
11 | description: string;
12 | date: Date;
13 | type: string;
14 | user: User;
15 | }[];
16 | total: number;
17 | collapseIndex: number;
18 | };
19 |
20 | export default requireSession(GetTransactions);
21 |
22 | async function GetTransactions(req: NextApiRequest, res: NextApiResponse) {
23 | if (req.method === 'GET') {
24 | const userId = req.query.id;
25 |
26 | if (typeof userId !== 'string') {
27 | return res.status(400).json({
28 | message: 'Invalid ID',
29 | });
30 | }
31 |
32 | // get parameters
33 | const destinationUserId = req.query.destinationUserId;
34 |
35 | if (typeof destinationUserId !== 'string') {
36 | return res.status(400).json({
37 | message: 'Invalid destinationUser',
38 | });
39 | }
40 |
41 | try {
42 | const _transactions = await prisma.transaction.findMany({
43 | where: {
44 | OR: [
45 | {
46 | userId,
47 | destinationUserId,
48 | },
49 | {
50 | userId: destinationUserId,
51 | destinationUserId: userId,
52 | },
53 | ],
54 | },
55 | select: {
56 | id: true,
57 | user: true,
58 | date: true,
59 | amount: true,
60 | description: true,
61 | },
62 | orderBy: {
63 | date: 'desc',
64 | },
65 | });
66 |
67 | const transactions = _transactions.map(
68 | ({ id, user, date, amount, description }) => ({
69 | id,
70 | amount,
71 | description,
72 | date,
73 | user,
74 | type: description.startsWith('Pelunasan')
75 | ? 'payment'
76 | : user.id === userId
77 | ? 'piutang'
78 | : 'utang',
79 | })
80 | );
81 |
82 | let collapseIndex = 0;
83 | const total = transactions
84 | .slice()
85 | .reverse()
86 | .reduce((acc, { amount, type, user }, i) => {
87 | const tempAcc =
88 | type === 'utang' ||
89 | (type === 'payment' && user.id === destinationUserId)
90 | ? acc + amount
91 | : acc - amount;
92 |
93 | if (tempAcc === 0) {
94 | collapseIndex = i;
95 | }
96 |
97 | return tempAcc;
98 | }, 0);
99 |
100 | const transactionReturn: GetTransactionsApi = {
101 | transactions,
102 | total,
103 | // -1 to account for index length
104 | collapseIndex: transactions.length - collapseIndex - 1,
105 | };
106 |
107 | return res.status(200).json(transactionReturn);
108 | } catch (error) {
109 | if (error instanceof Prisma.PrismaClientUnknownRequestError)
110 | return res.status(500).send(error.message);
111 | else {
112 | throw error;
113 | }
114 | }
115 | } else {
116 | res.status(405).json({ message: 'Method Not Allowed' });
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/pages/sandbox/rhf.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { FormProvider, useForm } from 'react-hook-form';
3 |
4 | import Button from '@/components/buttons/Button';
5 | import DatePicker from '@/components/forms/DatePicker';
6 | import DropzoneInput from '@/components/forms/DropzoneInput';
7 | import Input from '@/components/forms/Input';
8 | import SelectInput from '@/components/forms/SelectInput';
9 | import TextArea from '@/components/forms/TextArea';
10 | import Layout from '@/components/layout/Layout';
11 | import Seo from '@/components/Seo';
12 |
13 | export default function RHFSandbox() {
14 | //#region //*=========== Form ===========
15 | const methods = useForm({
16 | mode: 'onTouched',
17 | });
18 | const { handleSubmit } = methods;
19 | //#endregion //*======== Form ===========
20 |
21 | //#region //*=========== Form Submit ===========
22 | const onSubmit = (data: unknown) => {
23 | // eslint-disable-next-line no-console
24 | console.log(data);
25 | return;
26 | };
27 | //#endregion //*======== Form Submit ===========
28 |
29 | return (
30 |
31 |
32 |
33 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/pages/profile.tsx:
--------------------------------------------------------------------------------
1 | import { User } from '@prisma/client';
2 | import { useRouter } from 'next/router';
3 | import { useSession } from 'next-auth/react';
4 | import * as React from 'react';
5 | import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
6 | import toast from 'react-hot-toast';
7 | import useSWR from 'swr';
8 |
9 | import axiosClient from '@/lib/axios';
10 |
11 | import Button from '@/components/buttons/Button';
12 | import Input from '@/components/forms/Input';
13 | import Layout from '@/components/layout/Layout';
14 | import Seo from '@/components/Seo';
15 |
16 | import { REGEX } from '@/constant/regex';
17 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
18 |
19 | type ProfileData = {
20 | name: string;
21 | phoneNumber: string;
22 | };
23 |
24 | ProfilePage.auth = true;
25 |
26 | export default function ProfilePage() {
27 | //#region //*=========== Get Route Param ===========
28 | const router = useRouter();
29 | //#endregion //*======== Get Route Param ===========
30 |
31 | //#region //*=========== Form ===========
32 | const methods = useForm({
33 | mode: 'onTouched',
34 | });
35 | const { handleSubmit, setValue } = methods;
36 | //#endregion //*======== Form ===========
37 |
38 | //#region //*=========== Load Default Values ===========
39 | const { data: session } = useSession();
40 | const { data: currentUser } = useSWR(
41 | session?.user ? `/api/user/${session.user.id}` : undefined
42 | );
43 | React.useEffect(() => {
44 | setValue('phoneNumber', currentUser?.phoneNumber ?? '');
45 | setValue('name', currentUser?.name ?? '');
46 | }, [currentUser?.phoneNumber, currentUser?.name, setValue]);
47 | //#endregion //*======== Load Default Values ===========
48 |
49 | //#region //*=========== Form Submit ===========
50 | const onSubmit: SubmitHandler = (data) => {
51 | toast
52 | .promise(axiosClient.post('/api/user/edit', data), {
53 | ...DEFAULT_TOAST_MESSAGE,
54 | loading: 'Mengubah data profil..',
55 | success: 'Profil berhasil diubah',
56 | })
57 | .then(() => {
58 | router.push('/');
59 | });
60 | };
61 | //#endregion //*======== Form Submit ===========
62 |
63 | return (
64 |
65 |
66 |
67 |
68 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
2 | import { getSession, signIn } from 'next-auth/react';
3 | import * as React from 'react';
4 |
5 | import Layout from '@/components/layout/Layout';
6 | import UnderlineLink from '@/components/links/UnderlineLink';
7 | import NextImage from '@/components/NextImage';
8 | import Seo from '@/components/Seo';
9 |
10 | export default function HomePage({
11 | session,
12 | }: InferGetServerSidePropsType) {
13 | return (
14 |
15 | {/* */}
16 |
17 |
18 |
19 |
20 |
21 | {!session ? (
22 |
23 |
24 |
25 |
26 | Selamat datang di {' '}
27 |
28 | LhokUtang
29 |
30 |
31 |
32 | Solusi untuk perutangan duniawi di kos lhoktuan.
33 |
34 |
35 |
36 | signIn('google')}
38 | className='flex w-full items-center justify-center rounded-md border border-transparent bg-primary-600 px-8 py-3 text-base font-medium text-white hover:bg-primary-700 md:py-4 md:px-10 md:text-lg'
39 | >
40 | Login
41 |
42 |
43 |
44 |
45 |
46 |
47 | ) : (
48 |
49 |
Welcome,
50 |
51 | {session.user?.image && (
52 |
59 | )}
60 |
{session.user?.name}
61 |
62 |
63 | )}
64 |
65 |
66 | © {new Date().getFullYear()} By{' '}
67 |
68 | Theodorus Clarence
69 |
70 |
71 |
72 |
73 |
74 |
75 | );
76 | }
77 |
78 | export const getServerSideProps = async (
79 | context: GetServerSidePropsContext
80 | ) => {
81 | const session = await getSession(context);
82 |
83 | if (session) {
84 | return {
85 | redirect: {
86 | destination: '/list',
87 | permanent: false,
88 | },
89 | };
90 | }
91 |
92 | return {
93 | props: { session },
94 | };
95 | };
96 |
--------------------------------------------------------------------------------
/src/components/buttons/Button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ImSpinner2 } from 'react-icons/im';
3 |
4 | import clsxm from '@/lib/clsxm';
5 |
6 | enum ButtonVariant {
7 | 'primary',
8 | 'outline',
9 | 'ghost',
10 | 'light',
11 | 'dark',
12 | }
13 |
14 | type ButtonProps = {
15 | isLoading?: boolean;
16 | isDarkBg?: boolean;
17 | variant?: keyof typeof ButtonVariant;
18 | } & React.ComponentPropsWithRef<'button'>;
19 |
20 | const Button = React.forwardRef(
21 | (
22 | {
23 | children,
24 | className,
25 | disabled: buttonDisabled,
26 | isLoading,
27 | variant = 'primary',
28 | isDarkBg = false,
29 | ...rest
30 | },
31 | ref
32 | ) => {
33 | const disabled = isLoading || buttonDisabled;
34 |
35 | return (
36 |
88 | {isLoading && (
89 |
99 |
100 |
101 | )}
102 | {children}
103 |
104 | );
105 | }
106 | );
107 |
108 | export default Button;
109 |
--------------------------------------------------------------------------------
/src/components/forms/FilePreview.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {
3 | HiOutlineExternalLink,
4 | HiOutlineEye,
5 | HiOutlinePaperClip,
6 | HiOutlinePhotograph,
7 | HiX,
8 | } from 'react-icons/hi';
9 | import Lightbox from 'react-image-lightbox';
10 |
11 | import 'react-image-lightbox/style.css';
12 |
13 | import UnstyledLink from '@/components/links/UnstyledLink';
14 |
15 | import { FileWithPreview } from '@/types/dropzone';
16 |
17 | type FilePreviewProps = {
18 | file: FileWithPreview;
19 | } & (
20 | | {
21 | deleteFile?: (
22 | e: React.MouseEvent,
23 | file: FileWithPreview
24 | ) => void;
25 | readOnly?: true;
26 | }
27 | | {
28 | deleteFile: (
29 | e: React.MouseEvent,
30 | file: FileWithPreview
31 | ) => void;
32 | readOnly?: false;
33 | }
34 | );
35 |
36 | export default function FilePreview({
37 | deleteFile,
38 | file,
39 | readOnly,
40 | }: FilePreviewProps): React.ReactElement {
41 | const [index, setIndex] = React.useState(0);
42 | const [isOpen, setIsOpen] = React.useState(false);
43 |
44 | const images = [file.preview];
45 |
46 | const handleDelete = (e: React.MouseEvent) => {
47 | e.stopPropagation();
48 | deleteFile?.(e, file);
49 | };
50 |
51 | const imagesType = ['image/png', 'image/jpg', 'image/jpeg'];
52 |
53 | return imagesType.includes(file.type) ? (
54 | <>
55 |
59 |
60 |
64 | {file.name}
65 |
66 |
67 | setIsOpen(true)}
70 | className='inline-block rounded text-xl font-medium text-gray-500 hover:text-gray-700 focus:outline-none focus:ring focus:ring-primary-500'
71 | >
72 |
73 |
74 | {!readOnly && (
75 |
80 |
81 |
82 | )}
83 |
84 |
85 | {isOpen && (
86 | setIsOpen(false)}
91 | onMovePrevRequest={() =>
92 | setIndex(
93 | (prevIndex) => (prevIndex + images.length - 1) % images.length
94 | )
95 | }
96 | onMoveNextRequest={() =>
97 | setIndex((prevIndex) => (prevIndex + 1) % images.length)
98 | }
99 | />
100 | )}
101 | >
102 | ) : (
103 |
107 |
108 |
112 | {file.name}
113 |
114 |
115 |
119 |
120 |
121 | {!readOnly && (
122 | deleteFile?.(e, file)}
126 | >
127 |
128 |
129 | )}
130 |
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/src/components/Seo.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import { useRouter } from 'next/router';
3 |
4 | import { openGraph } from '@/lib/helper';
5 |
6 | const defaultMeta = {
7 | title: 'LhokUtang - Perutangan duniawi',
8 | siteName: 'LhokUtang',
9 | description: 'Aplikasi perutangan 4.0 milik kos loktuan',
10 | /** Without additional '/' on the end, e.g. https://theodorusclarence.com */
11 | url: 'https://lhoks.thcl.dev',
12 | type: 'website',
13 | robots: 'follow, index',
14 | /** No need to be filled, will be populated with openGraph function */
15 | image: '',
16 | };
17 |
18 | type SeoProps = {
19 | date?: string;
20 | templateTitle?: string;
21 | } & Partial;
22 |
23 | export default function Seo(props: SeoProps) {
24 | const router = useRouter();
25 | const meta = {
26 | ...defaultMeta,
27 | ...props,
28 | };
29 | meta['title'] = props.templateTitle
30 | ? `${props.templateTitle} | ${meta.siteName}`
31 | : meta.title;
32 |
33 | // Use siteName if there is templateTitle
34 | // but show full title if there is none
35 | meta['image'] = openGraph({
36 | description: meta.description,
37 | siteName: props.templateTitle ? meta.siteName : meta.title,
38 | templateTitle: props.templateTitle,
39 | });
40 |
41 | return (
42 |
43 | {meta.title}
44 |
45 |
46 |
47 |
48 | {/* Open Graph */}
49 |
50 |
51 |
52 |
53 |
54 | {/* Twitter */}
55 |
56 |
57 |
58 |
59 |
60 | {meta.date && (
61 | <>
62 |
63 |
68 |
73 | >
74 | )}
75 |
76 | {/* Favicons */}
77 | {favicons.map((linkProps) => (
78 |
79 | ))}
80 |
81 |
85 |
86 |
87 | );
88 | }
89 |
90 | type Favicons = {
91 | rel: string;
92 | href: string;
93 | sizes?: string;
94 | type?: string;
95 | };
96 |
97 | const favicons: Array = [
98 | {
99 | rel: 'apple-touch-icon',
100 | sizes: '57x57',
101 | href: '/favicon/apple-icon-57x57.png',
102 | },
103 | {
104 | rel: 'apple-touch-icon',
105 | sizes: '60x60',
106 | href: '/favicon/apple-icon-60x60.png',
107 | },
108 | {
109 | rel: 'apple-touch-icon',
110 | sizes: '72x72',
111 | href: '/favicon/apple-icon-72x72.png',
112 | },
113 | {
114 | rel: 'apple-touch-icon',
115 | sizes: '76x76',
116 | href: '/favicon/apple-icon-76x76.png',
117 | },
118 | {
119 | rel: 'apple-touch-icon',
120 | sizes: '114x114',
121 | href: '/favicon/apple-icon-114x114.png',
122 | },
123 | {
124 | rel: 'apple-touch-icon',
125 | sizes: '120x120',
126 | href: '/favicon/apple-icon-120x120.png',
127 | },
128 | {
129 | rel: 'apple-touch-icon',
130 | sizes: '144x144',
131 | href: '/favicon/apple-icon-144x144.png',
132 | },
133 | {
134 | rel: 'apple-touch-icon',
135 | sizes: '152x152',
136 | href: '/favicon/apple-icon-152x152.png',
137 | },
138 | {
139 | rel: 'apple-touch-icon',
140 | sizes: '180x180',
141 | href: '/favicon/apple-icon-180x180.png',
142 | },
143 | {
144 | rel: 'icon',
145 | type: 'image/png',
146 | sizes: '192x192',
147 | href: '/favicon/android-icon-192x192.png',
148 | },
149 | {
150 | rel: 'icon',
151 | type: 'image/png',
152 | sizes: '32x32',
153 | href: '/favicon/favicon-32x32.png',
154 | },
155 | {
156 | rel: 'icon',
157 | type: 'image/png',
158 | sizes: '96x96',
159 | href: '/favicon/favicon-96x96.png',
160 | },
161 | {
162 | rel: 'icon',
163 | type: 'image/png',
164 | sizes: '16x16',
165 | href: '/favicon/favicon-16x16.png',
166 | },
167 | {
168 | rel: 'manifest',
169 | href: '/favicon/manifest.json',
170 | },
171 | ];
172 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | /* #region /**=========== Primary Color =========== */
7 | --tw-color-primary-50: 240 253 250;
8 | --tw-color-primary-100: 204 251 241;
9 | --tw-color-primary-200: 153 246 228;
10 | --tw-color-primary-300: 94 234 212;
11 | --tw-color-primary-400: 45 212 191;
12 | --tw-color-primary-500: 20 184 166;
13 | --tw-color-primary-600: 13 148 136;
14 | --tw-color-primary-700: 15 118 110;
15 | --tw-color-primary-800: 17 94 89;
16 | --tw-color-primary-900: 19 78 74;
17 | --color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0fdfa */
18 | --color-primary-100: rgb(var(--tw-color-primary-100)); /* #ccfbf1 */
19 | --color-primary-200: rgb(var(--tw-color-primary-200)); /* #99f6e4 */
20 | --color-primary-300: rgb(var(--tw-color-primary-300)); /* #5eead4 */
21 | --color-primary-400: rgb(var(--tw-color-primary-400)); /* #2dd4bf */
22 | --color-primary-500: rgb(var(--tw-color-primary-500)); /* #14b8a6 */
23 | --color-primary-600: rgb(var(--tw-color-primary-600)); /* #0d9488 */
24 | --color-primary-700: rgb(var(--tw-color-primary-700)); /* #0f766e */
25 | --color-primary-800: rgb(var(--tw-color-primary-800)); /* #115e59 */
26 | --color-primary-900: rgb(var(--tw-color-primary-900)); /* #134e4a */
27 | /* #endregion /**======== Primary Color =========== */
28 | }
29 |
30 | @layer base {
31 | /* inter var - latin */
32 | @font-face {
33 | font-family: 'Inter';
34 | font-style: normal;
35 | font-weight: 100 900;
36 | font-display: optional;
37 | src: url('/fonts/inter-var-latin.woff2') format('woff2');
38 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
39 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
40 | U+2215, U+FEFF, U+FFFD;
41 | }
42 |
43 | .cursor-newtab {
44 | cursor: url('/images/new-tab.png') 10 10, pointer;
45 | }
46 |
47 | /* #region /**=========== Typography =========== */
48 | .h0 {
49 | @apply font-primary text-3xl font-bold md:text-5xl;
50 | }
51 |
52 | h1,
53 | .h1 {
54 | @apply font-primary text-2xl font-bold md:text-4xl;
55 | }
56 |
57 | h2,
58 | .h2 {
59 | @apply font-primary text-xl font-bold md:text-3xl;
60 | }
61 |
62 | h3,
63 | .h3 {
64 | @apply font-primary text-lg font-bold md:text-2xl;
65 | }
66 |
67 | h4,
68 | .h4 {
69 | @apply font-primary text-base font-bold md:text-lg;
70 | }
71 |
72 | body,
73 | .p {
74 | @apply font-primary text-sm md:text-base;
75 | }
76 | /* #endregion /**======== Typography =========== */
77 |
78 | .layout {
79 | /* 1100px */
80 | max-width: 68.75rem;
81 | @apply mx-auto w-11/12;
82 | }
83 |
84 | .bg-dark a.custom-link {
85 | @apply border-gray-200 hover:border-gray-200/0;
86 | }
87 |
88 | /* Class to adjust with sticky footer */
89 | .min-h-main {
90 | @apply min-h-[calc(100vh-56px)];
91 | }
92 | }
93 |
94 | @layer utilities {
95 | .animated-underline {
96 | background-image: linear-gradient(#33333300, #33333300),
97 | linear-gradient(
98 | to right,
99 | var(--color-primary-400),
100 | var(--color-primary-500)
101 | );
102 | background-size: 100% 2px, 0 2px;
103 | background-position: 100% 100%, 0 100%;
104 | background-repeat: no-repeat;
105 | }
106 | @media (prefers-reduced-motion: no-preference) {
107 | .animated-underline {
108 | transition: 0.3s ease;
109 | transition-property: background-size, color, background-color,
110 | border-color;
111 | }
112 | }
113 | .animated-underline:hover,
114 | .animated-underline:focus-visible {
115 | background-size: 0 2px, 100% 2px;
116 | }
117 | }
118 |
119 | /* #region /**=========== Datepicker =========== */
120 | .react-datepicker-wrapper {
121 | display: block;
122 | width: 100%;
123 | }
124 |
125 | .react-datepicker__navigation.react-datepicker__navigation--previous,
126 | .react-datepicker__navigation.react-datepicker__navigation--next {
127 | top: 6px;
128 | }
129 |
130 | .react-datepicker__header__dropdown.react-datepicker__header__dropdown--select {
131 | padding: 0 5px;
132 | }
133 |
134 | .react-datepicker__header__dropdown {
135 | margin-top: 0.5rem;
136 | }
137 |
138 | .react-datepicker__year-select,
139 | .react-datepicker__month-select {
140 | padding-top: 0.2rem;
141 | padding-bottom: 0.2rem;
142 | padding-left: 0.7rem;
143 | border-radius: 0.25rem;
144 | }
145 |
146 | /* Selected date color */
147 | .react-datepicker__day--selected,
148 | .react-datepicker__day--in-selecting-range,
149 | .react-datepicker__day--in-range,
150 | .react-datepicker__month-text--selected,
151 | .react-datepicker__month-text--in-selecting-range,
152 | .react-datepicker__month-text--in-range,
153 | .react-datepicker__quarter-text--selected,
154 | .react-datepicker__quarter-text--in-selecting-range,
155 | .react-datepicker__quarter-text--in-range,
156 | .react-datepicker__year-text--selected,
157 | .react-datepicker__year-text--in-selecting-range,
158 | .react-datepicker__year-text--in-range,
159 | .react-datepicker__day--keyboard-selected,
160 | .react-datepicker__month-text--keyboard-selected,
161 | .react-datepicker__quarter-text--keyboard-selected,
162 | .react-datepicker__year-text--keyboard-selected {
163 | @apply !bg-primary-500;
164 | }
165 | /* #endregion /**======== Datepicker =========== */
166 |
--------------------------------------------------------------------------------
/src/pages/debt/bayar/[id].tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { User } from 'next-auth';
3 | import { useSession } from 'next-auth/react';
4 | import * as React from 'react';
5 | import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
6 | import toast from 'react-hot-toast';
7 | import useSWR from 'swr';
8 |
9 | import axiosClient from '@/lib/axios';
10 | import { numberWithCommas } from '@/lib/helper';
11 | import { trackEvent } from '@/lib/umami';
12 | import useLoadingToast from '@/hooks/toast/useLoadingToast';
13 | import useWithToast from '@/hooks/toast/useSWRWithToast';
14 |
15 | import Button from '@/components/buttons/Button';
16 | import Input from '@/components/forms/Input';
17 | import SelectInput from '@/components/forms/SelectInput';
18 | import Layout from '@/components/layout/Layout';
19 | import Seo from '@/components/Seo';
20 | import UserImage from '@/components/UserImage';
21 |
22 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
23 | import { GetTransactionsApi } from '@/pages/api/trx/[id]';
24 |
25 | type BayarData = {
26 | amount: number;
27 | method: string;
28 | };
29 |
30 | BayarPage.auth = true;
31 |
32 | export default function BayarPage() {
33 | //#region //*=========== Get Route Param ===========
34 | const router = useRouter();
35 | const userId = router.query.id;
36 | //#endregion //*======== Get Route Param ===========
37 |
38 | const isLoading = useLoadingToast();
39 |
40 | const { data: session } = useSession();
41 |
42 | const { data: destinationUser } = useSWR(`/api/user/${userId}`);
43 |
44 | const { data: transactionData } = useWithToast(
45 | useSWR(
46 | `/api/trx/${session?.user.id}?destinationUserId=${destinationUser?.id}`
47 | )
48 | );
49 |
50 | const _total = transactionData?.total ?? 0;
51 | const total = {
52 | amount: _total ?? 0,
53 | status: _total === 0 ? 'aman' : _total > 0 ? 'bayar' : 'minta',
54 | };
55 |
56 | //#region //*=========== Form ===========
57 | const methods = useForm({
58 | mode: 'onTouched',
59 | });
60 | const { handleSubmit } = methods;
61 | //#endregion //*======== Form ===========
62 |
63 | //#region //*=========== Form Submit ===========
64 | const onSubmit: SubmitHandler = (data) => {
65 | const postData = {
66 | amount: data.amount,
67 | destinationUserId: destinationUser?.id,
68 | description: `Pelunasan via ${data.method}`,
69 | date: new Date().toISOString(),
70 | };
71 | toast
72 | .promise(axiosClient.post('/api/trx/create', postData), {
73 | ...DEFAULT_TOAST_MESSAGE,
74 | loading: 'Mengirim pembayaran...',
75 | success: 'Pembayaran berhasil dicatat',
76 | })
77 | .then(() => {
78 | trackEvent('Bayar', {
79 | type: 'click',
80 | user: session?.user.name ?? '',
81 | to: destinationUser?.name ?? '',
82 | });
83 | router.replace(`/trx/${destinationUser?.id}`);
84 | });
85 | };
86 | //#endregion //*======== Form Submit ===========
87 |
88 | return (
89 |
90 |
91 |
92 |
93 |
94 |
95 |
Transaksi dengan:
96 |
103 |
104 |
105 | {total.status === 'aman'
106 | ? 'Kamu tidak punya utang / piutang 👍'
107 | : total.status === 'bayar'
108 | ? `Kamu harus membayar Rp ${numberWithCommas(total.amount)} 🥲`
109 | : `Kamu bisa minta Rp ${numberWithCommas(
110 | total.amount * -1
111 | )} 🤑`}
112 |
113 |
114 |
115 |
116 |
120 |
138 |
139 | Shopee
140 | Cash
141 | Gopay
142 | OVO
143 | Bank Transfer
144 |
145 |
146 |
147 | Submit
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 | );
157 | }
158 |
--------------------------------------------------------------------------------
/src/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Menu, Transition } from '@headlessui/react';
2 | import clsx from 'clsx';
3 | import { useRouter } from 'next/router';
4 | import { signIn, signOut, useSession } from 'next-auth/react';
5 | import { Fragment } from 'react';
6 | import { FcGoogle } from 'react-icons/fc';
7 |
8 | import clsxm from '@/lib/clsxm';
9 |
10 | import Button from '@/components/buttons/Button';
11 | import UnstyledLink from '@/components/links/UnstyledLink';
12 | import NextImage from '@/components/NextImage';
13 |
14 | /* This example requires Tailwind CSS v2.0+ */
15 | const navigation = [
16 | { name: 'Lihat (Pi)utang', href: '/list', role: 'authenticated' },
17 | { name: 'Request Uang', href: '/debt/request', role: 'authenticated' },
18 | { name: 'Simple Split', href: '/debt/split', role: 'authenticated' },
19 | {
20 | name: 'Complex Split',
21 | href: '/debt/split-discount',
22 | role: 'authenticated',
23 | },
24 | // { name: 'Lihat Penghuni', href: '/list', role: 'unauthenticated' },
25 | ];
26 |
27 | export default function Header() {
28 | const { data: session, status } = useSession();
29 | const { asPath } = useRouter();
30 |
31 | return (
32 |
91 | );
92 | }
93 |
94 | function ProfileDropdown() {
95 | const { data: session } = useSession();
96 |
97 | return (
98 | <>
99 | {session && (
100 |
101 |
102 |
103 | Open user menu
104 | {session.user?.image && (
105 |
112 | )}
113 |
114 |
115 |
124 |
125 |
126 | {({ active }) => (
127 |
134 | Edit Profile
135 |
136 | )}
137 |
138 |
139 | {({ active }) => (
140 | signOut()}
146 | >
147 | Logout
148 |
149 | )}
150 |
151 |
152 |
153 |
154 | )}
155 | >
156 | );
157 | }
158 |
--------------------------------------------------------------------------------
/src/pages/debt/request.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { User } from 'next-auth';
3 | import { useSession } from 'next-auth/react';
4 | import * as React from 'react';
5 | import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
6 | import toast from 'react-hot-toast';
7 | import useSWR from 'swr';
8 |
9 | import axiosClient from '@/lib/axios';
10 | import { cleanNumber } from '@/lib/helper';
11 | import { trackEvent } from '@/lib/umami';
12 | import useLoadingToast from '@/hooks/toast/useLoadingToast';
13 | import useWithToast from '@/hooks/toast/useSWRWithToast';
14 |
15 | import Button from '@/components/buttons/Button';
16 | import Input from '@/components/forms/Input';
17 | import Layout from '@/components/layout/Layout';
18 | import Seo from '@/components/Seo';
19 | import UserSelect, { UserSelectPeople } from '@/components/UserSelect';
20 |
21 | import { FOOD_LISTS } from '@/constant/food-lists';
22 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
23 |
24 | type RequestData = {
25 | destinationUserId: string;
26 | amount: string;
27 | date: string;
28 | description: string;
29 | };
30 |
31 | DebtPage.auth = true;
32 |
33 | export default function DebtPage() {
34 | const { status, data: sessionData } = useSession();
35 | const router = useRouter();
36 |
37 | if (status === 'unauthenticated') {
38 | router.replace('/');
39 | }
40 |
41 | const isLoading = useLoadingToast();
42 |
43 | //#region //*=========== Form ===========
44 | const methods = useForm({
45 | mode: 'onTouched',
46 | });
47 | const { handleSubmit, setValue } = methods;
48 | //#endregion //*======== Form ===========
49 |
50 | //#region //*=========== Form Submit ===========
51 | const onSubmit: SubmitHandler = (data) => {
52 | const parsedData = {
53 | ...data,
54 | amount: cleanNumber(data.amount),
55 | date: new Date(),
56 | };
57 |
58 | toast
59 | .promise(axiosClient.post('/api/trx/create', parsedData), {
60 | ...DEFAULT_TOAST_MESSAGE,
61 | loading: 'Mengirim request uang...',
62 | success: 'Request uang berhasil dikirim',
63 | })
64 | .then(() => {
65 | trackEvent('Request Uang', {
66 | type: 'click',
67 | user: sessionData?.user.name ?? '',
68 | to:
69 | userData?.users.find((user) => user.id === data.destinationUserId)
70 | ?.name ?? '',
71 | });
72 | toast.dismiss();
73 | router.replace(`/trx/${data.destinationUserId}`);
74 | });
75 | };
76 | //#endregion //*======== Form Submit ===========
77 |
78 | //#region //*=========== User Select ===========
79 | const [userSelected, setUserSelected] = React.useState({
80 | id: '',
81 | name: 'Pick a user',
82 | });
83 | const { data: userData } = useWithToast(
84 | useSWR<{ users: User[] }>('/api/user'),
85 | {
86 | loading: 'getting user data',
87 | }
88 | );
89 | const users: UserSelectPeople[] = React.useMemo(
90 | () =>
91 | userData?.users
92 | ? userData.users
93 | .map((user) => ({
94 | id: user.id,
95 | name: user.name ? user.name : (user.email as string),
96 | image: user.image,
97 | email: user.email,
98 | }))
99 | .filter((user) => user.id !== sessionData?.user?.id)
100 | : [],
101 | [sessionData?.user?.id, userData]
102 | );
103 | //#endregion //*======== User Select ===========
104 |
105 | //#region //*=========== Auto Select From Query ===========
106 | React.useEffect(() => {
107 | const { to } = router.query;
108 | if (to) {
109 | const user = users.find((user) => user.id === to);
110 | if (user) {
111 | setUserSelected(user);
112 | setValue('destinationUserId', user.id, { shouldValidate: true });
113 | }
114 | }
115 | }, [router.query, setValue, users]);
116 | //#endregion //*======== Auto Select From Query ===========
117 |
118 | return (
119 |
120 |
121 |
122 |
123 |
124 |
125 |
Request Uang
126 |
127 | Abis dititipin sesuatu? Minta uangnya biar ga lupa
128 |
129 |
130 |
131 |
135 |
141 | {
147 | setValue(
148 | 'amount',
149 | cleanNumber(e.target.value).toLocaleString()
150 | );
151 | }}
152 | validation={{
153 | required: 'Nominal harus diisi',
154 | }}
155 | />
156 |
163 |
164 | {FOOD_LISTS?.map((description) => (
165 |
166 | ))}
167 |
168 |
169 |
170 | Submit
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 | );
180 | }
181 |
--------------------------------------------------------------------------------
/.vscode/typescriptreact.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | //#region //*=========== React ===========
3 | "import React": {
4 | "prefix": "ir",
5 | "body": ["import * as React from 'react';"]
6 | },
7 | "React.useState": {
8 | "prefix": "us",
9 | "body": [
10 | "const [${1}, set${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}] = React.useState<$3>(${2:initial${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}})$0"
11 | ]
12 | },
13 | "React.useEffect": {
14 | "prefix": "uf",
15 | "body": ["React.useEffect(() => {", " $0", "}, []);"]
16 | },
17 | "React.useReducer": {
18 | "prefix": "ur",
19 | "body": [
20 | "const [state, dispatch] = React.useReducer(${0:someReducer}, {",
21 | " ",
22 | "})"
23 | ]
24 | },
25 | "React.useRef": {
26 | "prefix": "urf",
27 | "body": ["const ${1:someRef} = React.useRef($0)"]
28 | },
29 | "React Functional Component": {
30 | "prefix": "rc",
31 | "body": [
32 | "import * as React from 'react';\n",
33 | "export default function ${1:${TM_FILENAME_BASE}}() {",
34 | " return (",
35 | " ",
36 | " $0",
37 | "
",
38 | " )",
39 | "}"
40 | ]
41 | },
42 | "React Functional Component with Props": {
43 | "prefix": "rcp",
44 | "body": [
45 | "import * as React from 'react';\n",
46 | "import clsxm from '@/lib/clsxm';\n",
47 | "type ${1:${TM_FILENAME_BASE}}Props= {\n",
48 | "} & React.ComponentPropsWithoutRef<'div'>\n",
49 | "export default function ${1:${TM_FILENAME_BASE}}({className, ...rest}: ${1:${TM_FILENAME_BASE}}Props) {",
50 | " return (",
51 | " ",
52 | " $0",
53 | "
",
54 | " )",
55 | "}"
56 | ]
57 | },
58 | //#endregion //*======== React ===========
59 |
60 | //#region //*=========== Commons ===========
61 | "Region": {
62 | "prefix": "reg",
63 | "scope": "javascript, typescript, javascriptreact, typescriptreact",
64 | "body": [
65 | "//#region //*=========== ${1} ===========",
66 | "${TM_SELECTED_TEXT}$0",
67 | "//#endregion //*======== ${1} ==========="
68 | ]
69 | },
70 | "Region CSS": {
71 | "prefix": "regc",
72 | "scope": "css, scss",
73 | "body": [
74 | "/* #region /**=========== ${1} =========== */",
75 | "${TM_SELECTED_TEXT}$0",
76 | "/* #endregion /**======== ${1} =========== */"
77 | ]
78 | },
79 | "JSON Stringify": {
80 | "prefix": "jstring",
81 | "body": ["{JSON.stringify(${1:obj}, null, 2)} "]
82 | },
83 | "ClassName": {
84 | "prefix": "class",
85 | "scope": "javascript, typescript, javascriptreact, typescriptreact",
86 | "body": ["className='$0'"]
87 | },
88 | //#endregion //*======== Commons ===========
89 |
90 | //#region //*=========== Nextjs ===========
91 | "Next Pages": {
92 | "prefix": "np",
93 | "body": [
94 | "import * as React from 'react';\n",
95 | "import Layout from '@/components/layout/Layout';",
96 | "import Seo from '@/components/Seo';\n",
97 | "export default function ${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}Page() {",
98 | " return (",
99 | " ",
100 | " \n",
101 | " \n",
102 | " ",
103 | " ",
104 | " $0",
105 | "
",
106 | " ",
107 | " ",
108 | " ",
109 | " )",
110 | "}"
111 | ]
112 | },
113 | "Next API": {
114 | "prefix": "napi",
115 | "body": [
116 | "import { NextApiRequest, NextApiResponse } from 'next';\n",
117 | "export default async function ${1:${TM_FILENAME_BASE}}(req: NextApiRequest, res: NextApiResponse) {",
118 | " if (req.method === 'GET') {",
119 | " res.status(200).json({ name: 'Bambang' });",
120 | " } else {",
121 | " res.status(405).json({ message: 'Method Not Allowed' });",
122 | " }",
123 | "}"
124 | ]
125 | },
126 | "Get Static Props": {
127 | "prefix": "gsp",
128 | "body": [
129 | "export const getStaticProps = async (context: GetStaticPropsContext) => {",
130 | " return {",
131 | " props: {}",
132 | " };",
133 | "}"
134 | ]
135 | },
136 | "Get Static Paths": {
137 | "prefix": "gspa",
138 | "body": [
139 | "export const getStaticPaths: GetStaticPaths = async () => {",
140 | " return {",
141 | " paths: [",
142 | " { params: { $1 }}",
143 | " ],",
144 | " fallback: ",
145 | " };",
146 | "}"
147 | ]
148 | },
149 | "Get Server Side Props": {
150 | "prefix": "gssp",
151 | "body": [
152 | "export const getServerSideProps = async (context: GetServerSidePropsContext) => {",
153 | " return {",
154 | " props: {}",
155 | " };",
156 | "}"
157 | ]
158 | },
159 | "Infer Get Static Props": {
160 | "prefix": "igsp",
161 | "body": "InferGetStaticPropsType"
162 | },
163 | "Infer Get Server Side Props": {
164 | "prefix": "igssp",
165 | "body": "InferGetServerSidePropsType"
166 | },
167 | "Import useRouter": {
168 | "prefix": "imust",
169 | "body": ["import { useRouter } from 'next/router';"]
170 | },
171 | "Import Next Image": {
172 | "prefix": "imimg",
173 | "body": ["import Image from 'next/image';"]
174 | },
175 | "Import Next Link": {
176 | "prefix": "iml",
177 | "body": ["import Link from 'next/link';"]
178 | },
179 | //#endregion //*======== Nextjs ===========
180 |
181 | //#region //*=========== Snippet Wrap ===========
182 | "Wrap with Fragment": {
183 | "prefix": "ff",
184 | "body": ["<>", "\t${TM_SELECTED_TEXT}", ">"]
185 | },
186 | "Wrap with clsx": {
187 | "prefix": "cx",
188 | "body": ["{clsx(${TM_SELECTED_TEXT}$0)}"]
189 | },
190 | "Wrap with clsxm": {
191 | "prefix": "cxm",
192 | "body": ["{clsxm(${TM_SELECTED_TEXT}$0, className)}"]
193 | },
194 | //#endregion //*======== Snippet Wrap ===========
195 |
196 | "Logger": {
197 | "prefix": "lg",
198 | "body": [
199 | "logger({ ${1:${CLIPBOARD}} }, '${TM_FILENAME} line ${TM_LINE_NUMBER}')"
200 | ]
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/src/components/UserSelect.tsx:
--------------------------------------------------------------------------------
1 | /* This example requires Tailwind CSS v2.0+ */
2 | import { Listbox, Transition } from '@headlessui/react';
3 | import clsx from 'clsx';
4 | import * as React from 'react';
5 | import { useFormContext } from 'react-hook-form';
6 | import { HiCheck, HiSelector } from 'react-icons/hi';
7 |
8 | import clsxm from '@/lib/clsxm';
9 |
10 | import NextImage from '@/components/NextImage';
11 |
12 | import { alumnusEmail } from '@/constant/email-whitelist';
13 |
14 | import Input from './forms/Input';
15 |
16 | export type UserSelectPeople = {
17 | id: string;
18 | name: string;
19 | image?: string | null;
20 | email?: string | null;
21 | };
22 |
23 | type UserSelectProps = {
24 | selected: UserSelectPeople;
25 | setSelected: React.Dispatch>;
26 | people: UserSelectPeople[];
27 | label?: string;
28 | };
29 |
30 | export default function UserSelect({
31 | selected,
32 | people,
33 | setSelected,
34 | label = 'User',
35 | }: UserSelectProps) {
36 | const {
37 | formState: { errors },
38 | setValue,
39 | } = useFormContext();
40 |
41 | const users = people.filter(
42 | (user) => !alumnusEmail.includes(user.email ?? '')
43 | );
44 | const alumnus = people.filter((user) =>
45 | alumnusEmail.includes(user.email ?? '')
46 | );
47 |
48 | return (
49 | {
52 | setSelected(e);
53 | setValue('destinationUserId', e.id, { shouldValidate: true });
54 | }}
55 | >
56 | {({ open }) => (
57 |
58 |
59 |
64 |
65 |
66 | {label}
67 |
68 |
69 |
76 |
77 | {selected?.image ? (
78 |
85 | ) : (
86 |
87 | )}
88 | {selected.name}
89 |
90 |
91 |
95 |
96 |
97 |
98 |
105 |
106 | {users.map((person) => (
107 |
108 | ))}
109 |
110 | Alumni
111 |
112 | {alumnus.map((person) => (
113 |
114 | ))}
115 |
116 |
117 |
118 |
119 | {errors['destinationUserId'] && (
120 |
121 | {errors['destinationUserId'].message}
122 |
123 | )}
124 |
125 |
126 |
127 | )}
128 |
129 | );
130 | }
131 |
132 | const UserSelectOption = ({ person }: { person: UserSelectPeople }) => {
133 | return (
134 |
136 | clsx(
137 | active ? 'bg-primary-600 text-white' : 'text-gray-900',
138 | 'relative cursor-default select-none py-2 pl-3 pr-9'
139 | )
140 | }
141 | value={person}
142 | >
143 | {({ selected, active }) => (
144 | <>
145 |
146 | {person?.image ? (
147 |
154 | ) : (
155 |
156 | )}
157 |
163 | {person.name}
164 |
165 |
166 |
167 | {selected ? (
168 |
174 |
175 |
176 | ) : null}
177 | >
178 | )}
179 |
180 | );
181 | };
182 |
--------------------------------------------------------------------------------
/src/components/dialog/BaseDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog, Transition } from '@headlessui/react';
2 | import clsx from 'clsx';
3 | import * as React from 'react';
4 | import {
5 | HiExclamationCircle,
6 | HiOutlineCheck,
7 | HiOutlineExclamation,
8 | HiOutlineX,
9 | } from 'react-icons/hi';
10 |
11 | import Button from '@/components/buttons/Button';
12 |
13 | type BaseDialogProps = {
14 | /** Maintained by useDialogStore */
15 | open: boolean;
16 | /** Maintained by useDialogStore */
17 | onSubmit: () => void;
18 | /** Maintained by useDialogStore */
19 | onClose: () => void;
20 | /** Customizable Dialog Options */
21 | options: DialogOptions;
22 | };
23 |
24 | export type DialogOptions = {
25 | catchOnCancel?: boolean;
26 | title: React.ReactNode;
27 | description: React.ReactNode;
28 | variant: 'success' | 'warning' | 'danger';
29 | submitText: React.ReactNode;
30 | };
31 |
32 | /**
33 | * Base Dialog for useDialog hook implementation.
34 | *
35 | * **Should be called with the hook, not by the component itself**
36 | *
37 | *
38 | * @see useDialogStore
39 | * @example ```tsx
40 | * const dialog = useDialog();
41 | *
42 | * dialog(options);
43 | * ```
44 | */
45 | export default function BaseDialog({
46 | open,
47 | onSubmit,
48 | onClose,
49 | options: { title, description, variant, submitText },
50 | }: BaseDialogProps) {
51 | const current = colorVariant[variant];
52 |
53 | return (
54 |
55 | onClose()}
61 | >
62 |
63 |
72 |
73 |
74 |
75 | {/* This element is to trick the browser into centering the modal contents. */}
76 |
80 |
81 |
82 |
91 |
92 |
93 |
102 | Close
103 |
104 |
105 |
106 |
107 |
114 |
118 |
119 |
120 |
124 | {title}
125 |
126 |
127 |
{description}
128 |
129 |
130 |
131 |
132 |
138 | {submitText}
139 |
140 |
146 | Cancel
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 | );
155 | }
156 |
157 | const colorVariant = {
158 | success: {
159 | bg: {
160 | light: 'bg-green-100',
161 | },
162 | text: {
163 | primary: 'text-green-500',
164 | },
165 | icon: HiOutlineCheck,
166 | },
167 | warning: {
168 | bg: {
169 | light: 'bg-yellow-100',
170 | },
171 | text: {
172 | primary: 'text-yellow-500',
173 | },
174 | icon: HiOutlineExclamation,
175 | },
176 | danger: {
177 | bg: {
178 | light: 'bg-red-100',
179 | },
180 | text: {
181 | primary: 'text-red-500',
182 | },
183 | icon: HiExclamationCircle,
184 | },
185 | };
186 |
--------------------------------------------------------------------------------
/src/components/forms/DropzoneInput.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import * as React from 'react';
3 | import { useDropzone } from 'react-dropzone';
4 | import { Controller, useFormContext } from 'react-hook-form';
5 |
6 | import FilePreview from '@/components/forms/FilePreview';
7 |
8 | import { FileWithPreview } from '@/types/dropzone';
9 |
10 | type DropzoneInputProps = {
11 | accept?: string;
12 | helperText?: string;
13 | id: string;
14 | label: string;
15 | maxFiles?: number;
16 | readOnly?: boolean;
17 | validation?: Record;
18 | };
19 |
20 | export default function DropzoneInput({
21 | accept,
22 | helperText = '',
23 | id,
24 | label,
25 | maxFiles = 1,
26 | validation,
27 | readOnly,
28 | }: DropzoneInputProps) {
29 | const {
30 | control,
31 | getValues,
32 | setValue,
33 | setError,
34 | clearErrors,
35 | formState: { errors },
36 | } = useFormContext();
37 |
38 | //#region //*=========== Error Focus ===========
39 | const dropzoneRef = React.useRef(null);
40 |
41 | React.useEffect(() => {
42 | errors[id] && dropzoneRef.current?.focus();
43 | }, [errors, id]);
44 | //#endregion //*======== Error Focus ===========
45 |
46 | const [files, setFiles] = React.useState(
47 | getValues(id) || []
48 | );
49 |
50 | const onDrop = React.useCallback(
51 | (acceptedFiles, rejectedFiles) => {
52 | if (rejectedFiles && rejectedFiles.length > 0) {
53 | setValue(id, files ? [...files] : null);
54 | setError(id, {
55 | type: 'manual',
56 | message: rejectedFiles && rejectedFiles[0].errors[0].message,
57 | });
58 | } else {
59 | const acceptedFilesPreview = acceptedFiles.map(
60 | (file: FileWithPreview) =>
61 | Object.assign(file, {
62 | preview: URL.createObjectURL(file),
63 | })
64 | );
65 |
66 | setFiles(
67 | files
68 | ? [...files, ...acceptedFilesPreview].slice(0, maxFiles)
69 | : acceptedFilesPreview
70 | );
71 |
72 | setValue(
73 | id,
74 | files
75 | ? [...files, ...acceptedFiles].slice(0, maxFiles)
76 | : acceptedFiles,
77 | {
78 | shouldValidate: true,
79 | }
80 | );
81 | clearErrors(id);
82 | }
83 | },
84 | [clearErrors, files, id, maxFiles, setError, setValue]
85 | );
86 |
87 | React.useEffect(() => {
88 | return () => {
89 | () => {
90 | files.forEach((file) => URL.revokeObjectURL(file.preview));
91 | };
92 | };
93 | }, [files]);
94 |
95 | const deleteFile = (
96 | e: React.MouseEvent,
97 | file: FileWithPreview
98 | ) => {
99 | e.preventDefault();
100 | const newFiles = [...files];
101 |
102 | newFiles.splice(newFiles.indexOf(file), 1);
103 |
104 | if (newFiles.length > 0) {
105 | setFiles(newFiles);
106 | setValue(id, newFiles, {
107 | shouldValidate: true,
108 | shouldDirty: true,
109 | shouldTouch: true,
110 | });
111 | } else {
112 | setFiles([]);
113 | setValue(id, null, {
114 | shouldValidate: true,
115 | shouldDirty: true,
116 | shouldTouch: true,
117 | });
118 | }
119 | };
120 |
121 | const { getRootProps, getInputProps } = useDropzone({
122 | onDrop,
123 | accept,
124 | maxFiles,
125 | maxSize: 1000000,
126 | });
127 |
128 | return (
129 |
130 |
131 | {label}
132 |
133 |
134 | {readOnly && !(files?.length > 0) ? (
135 |
136 | No file uploaded
137 |
138 | ) : files?.length >= maxFiles ? (
139 |
140 | {files.map((file, index) => (
141 |
147 | ))}
148 |
149 | ) : (
150 |
(
155 | <>
156 |
161 |
162 |
170 |
171 |
178 |
184 |
185 |
186 | Drag and drop file here, or click to choose file
187 |
188 |
{`${
189 | maxFiles - (files?.length || 0)
190 | } file(s) remaining`}
191 |
192 |
193 |
194 |
195 |
196 | {helperText !== '' && (
197 |
{helperText}
198 | )}
199 | {errors[id] && (
200 |
{errors[id].message}
201 | )}
202 |
203 | {!readOnly && !!files?.length && (
204 |
205 | {files.map((file, index) => (
206 |
212 | ))}
213 |
214 | )}
215 | >
216 | )}
217 | />
218 | )}
219 |
220 | );
221 | }
222 |
--------------------------------------------------------------------------------
/src/pages/debt/split.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { User } from 'next-auth';
3 | import { useSession } from 'next-auth/react';
4 | import * as React from 'react';
5 | import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
6 | import toast from 'react-hot-toast';
7 | import useSWR from 'swr';
8 |
9 | import axiosClient from '@/lib/axios';
10 | import { cleanNumber, numberWithCommas } from '@/lib/helper';
11 | import { trackEvent } from '@/lib/umami';
12 | import useLoadingToast from '@/hooks/toast/useLoadingToast';
13 | import useWithToast from '@/hooks/toast/useSWRWithToast';
14 |
15 | import Button from '@/components/buttons/Button';
16 | import Input from '@/components/forms/Input';
17 | import UserCheckboxes from '@/components/forms/UserCheckboxes';
18 | import Layout from '@/components/layout/Layout';
19 | import PrimaryLink from '@/components/links/PrimaryLink';
20 | import Seo from '@/components/Seo';
21 | import { UserSelectPeople } from '@/components/UserSelect';
22 |
23 | import { FOOD_LISTS } from '@/constant/food-lists';
24 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
25 | import { CreateManyBody } from '@/pages/api/trx/create-many';
26 |
27 | type RequestData = {
28 | destinationUserId: Record;
29 | amount: string;
30 | description: string;
31 | };
32 |
33 | DebtSplit.auth = true;
34 |
35 | export default function DebtSplit() {
36 | const { status, data: sessionData } = useSession();
37 | const router = useRouter();
38 |
39 | if (status === 'unauthenticated') {
40 | router.replace('/');
41 | }
42 |
43 | const isLoading = useLoadingToast();
44 |
45 | //#region //*=========== Form ===========
46 | const methods = useForm({
47 | mode: 'onTouched',
48 | });
49 | const { handleSubmit, watch, setValue } = methods;
50 | //#endregion //*======== Form ===========
51 |
52 | //#region //*=========== User Select ============
53 | const { data: userData } = useWithToast(
54 | useSWR<{ users: User[] }>('/api/user'),
55 | {
56 | loading: 'getting user data',
57 | }
58 | );
59 | const users: UserSelectPeople[] = userData?.users
60 | ? userData.users
61 | .map((user) => ({
62 | id: user.id,
63 | name: user.name ? user.name : (user.email as string),
64 | image: user.image,
65 | email: user.email,
66 | }))
67 | .filter((user) => user.id !== sessionData?.user?.id)
68 | : [];
69 | //#endregion //*======== User Select ===========
70 |
71 | //#region //*=========== Split Bill Logic ===========
72 | const { destinationUserId: destinationUserIdObject } = watch();
73 | const totalPerson = destinationUserIdObject
74 | ? Object.values(destinationUserIdObject).filter((bool) => bool).length
75 | : 0;
76 |
77 | const amount = cleanNumber(watch('amount'));
78 |
79 | const amountPerPerson = Math.floor(amount / (totalPerson + 1));
80 | //#endregion //*======== Split Bill Logic ===========
81 |
82 | //#region //*=========== Form Submit ===========
83 | const onSubmit: SubmitHandler = (data) => {
84 | // If no one checked
85 | if (totalPerson === 0) {
86 | return toast.error('Silakan pilih minimal 1 orang');
87 | }
88 |
89 | const parsedData: CreateManyBody = {
90 | amountPerPerson,
91 | destinationUserIdList: Object.entries(destinationUserIdObject)
92 | .filter(([, bool]) => bool)
93 | .map(([user]) => user),
94 | description: data.description,
95 | date: new Date(),
96 | };
97 | toast
98 | .promise(axiosClient.post('/api/trx/create-many', parsedData), {
99 | ...DEFAULT_TOAST_MESSAGE,
100 | loading: 'Mengirim request uang...',
101 | success: 'Request uang berhasil dikirim',
102 | })
103 | .then(() => {
104 | trackEvent('Split Bill', {
105 | type: 'click',
106 | user: sessionData?.user.name ?? '',
107 | to: parsedData.destinationUserIdList
108 | .map(
109 | (id) => userData?.users.find((user) => user.id === id)?.name ?? ''
110 | )
111 | .join(', '),
112 | });
113 | router.replace(`/trx/${parsedData.destinationUserIdList[0]}`);
114 | });
115 | };
116 | //#endregion //*======== Form Submit ===========
117 |
118 | return (
119 |
120 |
121 |
122 |
123 |
124 |
125 |
Simple Split Bill
126 |
127 | Bagi" pesanan secara rata dengan mudah
128 |
129 |
130 | Nominal makanan beda-beda? Pake{' '}
131 |
132 | Split Discount
133 |
134 |
135 |
136 |
137 |
141 | {
147 | setValue(
148 | 'amount',
149 | cleanNumber(e.target.value).toLocaleString()
150 | );
151 | }}
152 | validation={{
153 | required: 'Nominal harus diisi',
154 | }}
155 | helperText='Total akan dibagi dengan jumlah orang dan kamu'
156 | />
157 |
158 |
165 |
166 | {FOOD_LISTS?.map((description) => (
167 |
168 | ))}
169 |
170 |
171 |
172 |
Rincian
173 |
Pembagian: Kamu + {totalPerson} orang
174 |
175 | Per orangnya jadi{' '}
176 |
177 | Rp{' '}
178 | {!isNaN(amountPerPerson)
179 | ? numberWithCommas(amountPerPerson)
180 | : 0}
181 |
182 |
183 |
184 |
185 |
186 |
187 | Submit
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 | );
197 | }
198 |
--------------------------------------------------------------------------------
/src/pages/trx/[id].tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
2 | import { User } from '@prisma/client';
3 | import clsx from 'clsx';
4 | import format from 'date-fns/format';
5 | import isToday from 'date-fns/isToday';
6 | import { useRouter } from 'next/router';
7 | import { useSession } from 'next-auth/react';
8 | import * as React from 'react';
9 | import toast from 'react-hot-toast';
10 | import { HiOutlineMail, HiOutlineTrash, HiPhone } from 'react-icons/hi';
11 | import useSWR from 'swr';
12 |
13 | import axiosClient from '@/lib/axios';
14 | import { DATE_FORMAT } from '@/lib/date';
15 | import { getFromLocalStorage, numberWithCommas } from '@/lib/helper';
16 | import { trackEvent } from '@/lib/umami';
17 | import useWithToast from '@/hooks/toast/useSWRWithToast';
18 | import useDialog from '@/hooks/useDialog';
19 |
20 | import Button from '@/components/buttons/Button';
21 | import TextButton from '@/components/buttons/TextButton';
22 | import Layout from '@/components/layout/Layout';
23 | import ArrowLink from '@/components/links/ArrowLink';
24 | import ButtonLink from '@/components/links/ButtonLink';
25 | import PrimaryLink from '@/components/links/PrimaryLink';
26 | import Seo from '@/components/Seo';
27 | import UserImage from '@/components/UserImage';
28 |
29 | import { DEFAULT_TOAST_MESSAGE } from '@/constant/toast';
30 | import { GetTransactionsApi } from '@/pages/api/trx/[id]';
31 |
32 | UserTransactionPage.auth = true;
33 |
34 | export default function UserTransactionPage() {
35 | //#region //*=========== Get Route Param ===========
36 | const router = useRouter();
37 | const userId = router.query.id;
38 | //#endregion //*======== Get Route Param ===========
39 | const dialog = useDialog();
40 | const { data: session } = useSession();
41 |
42 | const { data: destinationUser } = useSWR(
43 | userId ? `/api/user/${userId}` : undefined
44 | );
45 |
46 | const { data: transactionData, mutate } = useWithToast(
47 | useSWR(
48 | session?.user.id && destinationUser?.id
49 | ? `/api/trx/${session?.user.id}?destinationUserId=${destinationUser?.id}`
50 | : undefined
51 | )
52 | );
53 | const transactions = transactionData?.transactions ?? [];
54 |
55 | const collapseIndex = transactionData?.collapseIndex ?? null;
56 | const _total = transactionData?.total ?? 0;
57 | const total = {
58 | amount: _total ?? 0,
59 | status: _total === 0 ? 'aman' : _total > 0 ? 'bayar' : 'minta',
60 | };
61 |
62 | //#region //*=========== Remove Item ===========
63 | const deleteTransaction = async (id: string) => {
64 | dialog({
65 | title: 'Hapus transaksi',
66 | description: 'Apakah anda yakin? Transaksi tidak dapat dikembalikan',
67 | submitText: 'Hapus',
68 | variant: 'danger',
69 | }).then(() => {
70 | toast
71 | .promise(axiosClient.delete(`/api/delete-trx/${id}`), {
72 | ...DEFAULT_TOAST_MESSAGE,
73 | loading: 'Menghapus transaksi...',
74 | success: 'Transaksi berhasil dihapus',
75 | })
76 | .then(() => {
77 | trackEvent('Hapus Transaksi', {
78 | type: 'click',
79 | user: session?.user.name ?? '',
80 | });
81 | toast.dismiss();
82 | mutate();
83 | });
84 | });
85 | };
86 | //#endregion //*======== Remove Item ===========
87 |
88 | const onEmailClick = () => {
89 | const description = window.prompt('Pesan untuk orang ini (opsional)');
90 | if (description === null) return;
91 |
92 | localStorage.setItem(
93 | `@lhokutang/email-trx-${userId}`,
94 | new Date().toISOString()
95 | );
96 |
97 | toast.promise(
98 | axiosClient
99 | .post('/api/remind', {
100 | userId: userId,
101 | amount: -total.amount,
102 | description,
103 | })
104 | .then(() => {
105 | trackEvent('Remind', {
106 | type: 'click',
107 | user: session?.user.name ?? '',
108 | });
109 | }),
110 | {
111 | ...DEFAULT_TOAST_MESSAGE,
112 | loading: 'Mengirim email...',
113 | success: 'Email berhasil dikirim',
114 | }
115 | );
116 | };
117 | // check if date is in 1 hour ago
118 | const isAbleToSendEmail = () => {
119 | const date = getFromLocalStorage(`@lhokutang/email-trx-${userId}`);
120 | if (!date) return true;
121 |
122 | const now = new Date();
123 | const dateIn1HourAgo = new Date(now.getTime() - 60 * 60 * 1000);
124 | return new Date(date) < dateIn1HourAgo;
125 | };
126 |
127 | return (
128 |
129 |
130 |
131 |
132 |
133 |
134 |
Transaksi dengan:
135 |
145 |
150 | 💸 Request Uang
151 |
152 |
153 |
154 |
155 | {total.status === 'aman'
156 | ? 'Kamu tidak punya utang / piutang 👍'
157 | : total.status === 'bayar'
158 | ? `Kamu harus membayar Rp ${numberWithCommas(total.amount)} 🥲`
159 | : `Kamu bisa minta Rp ${numberWithCommas(
160 | total.amount * -1
161 | )} 🤑`}
162 |
163 |
164 |
165 | {total.status === 'bayar' && (
166 |
170 | 💵 Bayar Sekarang
171 |
172 | )}
173 | {total.status === 'minta' &&
174 | (isAbleToSendEmail() ? (
175 |
180 |
181 | Kirim email minta uang
182 |
183 | ) : (
184 |
185 | Anda telah mengirim email pada{' '}
186 | {new Date(
187 | getFromLocalStorage(`@lhokutang/email-trx-${userId}`)!
188 | ).toLocaleTimeString()}
189 | , tunggu 1 jam untuk mengirim kembali
190 |
191 | ))}
192 |
193 |
194 |
195 | {transactions.map(
196 | ({ id, description, type, amount, date, user }, i) => (
197 |
198 | {i === collapseIndex && (
199 |
200 |
201 |
LUNAS
202 |
203 |
204 | )}
205 |
206 |
207 |
217 |
218 |
{description}
219 |
220 | {format(
221 | new Date(date),
222 | DATE_FORMAT.FULL_DATE_HOUR_MINUTE
223 | )}
224 |
225 |
226 |
227 |
228 |
238 | {numberWithCommas(amount)}
239 |
240 | {/* check if the date is still current date */}
241 | {isToday(new Date(date)) && (
242 |
deleteTransaction(id)}
244 | className='text-sm text-red-500 hover:text-red-600 focus-visible:text-red-600'
245 | >
246 |
247 | Hapus Transaksi
248 |
249 | )}
250 |
251 |
252 |
253 | )
254 | )}
255 |
256 |
257 |
258 |
259 |
260 | );
261 | }
262 |
--------------------------------------------------------------------------------