├── .husky
├── pre-push
├── pre-commit
└── commit-msg
├── google1bacd6ce012d968f.html
├── src
├── app
│ ├── favicon.ico
│ ├── (auth)
│ │ ├── sign-in
│ │ │ ├── page.tsx
│ │ │ └── components
│ │ │ │ ├── sign-in.tsx
│ │ │ │ ├── phone-sign-in.tsx
│ │ │ │ └── email-sign-in.tsx
│ │ ├── sign-up
│ │ │ ├── page.tsx
│ │ │ └── components
│ │ │ │ ├── sign-up.tsx
│ │ │ │ ├── phone-sign-up.tsx
│ │ │ │ └── email-sign-up.tsx
│ │ └── verify-otp
│ │ │ └── [phone]
│ │ │ ├── page.tsx
│ │ │ └── components
│ │ │ └── verify.tsx
│ ├── (system)
│ │ ├── layout.tsx
│ │ ├── (main)
│ │ │ ├── loading.tsx
│ │ │ ├── feed
│ │ │ │ ├── log-feed.tsx
│ │ │ │ └── recent-log-feed.tsx
│ │ │ └── page.tsx
│ │ ├── logs
│ │ │ ├── new
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── components
│ │ │ │ │ └── create-log.tsx
│ │ │ ├── [id]
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── details-page.tsx
│ │ │ │ │ └── details-card.tsx
│ │ │ │ └── page.tsx
│ │ │ └── update
│ │ │ │ └── [id]
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── components
│ │ │ │ └── update-log.tsx
│ │ ├── settings
│ │ │ └── page.tsx
│ │ └── components
│ │ │ ├── navbar.tsx
│ │ │ └── user-profile-dropdown.tsx
│ ├── layout.tsx
│ ├── auth
│ │ └── callback
│ │ │ └── route.ts
│ ├── globals.css
│ └── actions
│ │ ├── logs.actions.ts
│ │ └── auth.actions.ts
├── utils
│ ├── index.ts
│ ├── regex.ts
│ ├── supabase
│ │ ├── client.ts
│ │ ├── server.ts
│ │ └── middleware.ts
│ ├── errors.ts
│ └── date.ts
├── lib
│ └── utils.ts
├── types
│ └── index.ts
├── middleware.ts
└── components
│ ├── providers
│ └── theme-provider.tsx
│ ├── ui
│ ├── edit-button.tsx
│ ├── skeleton.tsx
│ ├── back-button.tsx
│ ├── loader.tsx
│ ├── textarea.tsx
│ ├── label.tsx
│ ├── separator.tsx
│ ├── sonner.tsx
│ ├── checkbox.tsx
│ ├── hover-card.tsx
│ ├── input.tsx
│ ├── search-bar.tsx
│ ├── color-mode-toggle.tsx
│ ├── avatar.tsx
│ ├── button.tsx
│ ├── tabs.tsx
│ ├── card.tsx
│ ├── input-otp.tsx
│ ├── sheet.tsx
│ ├── form.tsx
│ ├── alert-dialog.tsx
│ └── dropdown-menu.tsx
│ └── cards
│ ├── card-skeleton.tsx
│ ├── skeletons.tsx
│ └── log-card.tsx
├── next.config.mjs
├── tsconfig.test.json
├── .env.local.example
├── postcss.config.mjs
├── tests
└── app
│ └── index.spec.ts
├── components.json
├── .gitignore
├── public
├── vercel.svg
└── next.svg
├── tsconfig.json
├── jest.config.ts
├── contributing
└── CONTRIBUTING.md
├── .eslintrc.json
├── README.md
├── commitlint.config.js
├── package.json
└── tailwind.config.ts
/.husky/pre-push:
--------------------------------------------------------------------------------
1 | npm test
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm run lint
2 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | npx --no -- commitlint --edit $1
2 |
--------------------------------------------------------------------------------
/google1bacd6ce012d968f.html:
--------------------------------------------------------------------------------
1 | google-site-verification: google1bacd6ce012d968f.html
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OpenSource-GH/dumsor-tracker-frontend/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * as validators from "./regex";
2 | export * as dateUtils from "./regex";
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/utils/regex.ts:
--------------------------------------------------------------------------------
1 | export const PhoneNumberRegex: RegExp = new RegExp(
2 | /^\+?(?:[0-9] ?){6,14}[0-9]$/,
3 | );
4 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx"
5 | }
6 | }
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_SUPABASE_URL=
2 | NEXT_PUBLIC_SUPABASE_KEY=
3 | NEXT_PUBLIC_API_BASE_URL = http:/localhost:3000/
4 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/page.tsx:
--------------------------------------------------------------------------------
1 | import SignInForm from "./components/sign-in";
2 |
3 | function SignInPage() {
4 | return ;
5 | }
6 |
7 | export default SignInPage;
8 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import SignUpForm from "./components/sign-up";
3 |
4 | function SignUpPage() {
5 | return ;
6 | }
7 |
8 | export default SignUpPage;
9 |
--------------------------------------------------------------------------------
/src/app/(auth)/verify-otp/[phone]/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import VerifyOTPForm from "./components/verify";
3 |
4 | function VerifyOTPPage() {
5 | return ;
6 | }
7 |
8 | export default VerifyOTPPage;
9 |
--------------------------------------------------------------------------------
/src/utils/supabase/client.ts:
--------------------------------------------------------------------------------
1 | import { createBrowserClient } from "@supabase/ssr";
2 |
3 | export function createClient() {
4 | return createBrowserClient(
5 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 | process.env.NEXT_PUBLIC_SUPABASE_KEY!
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/(system)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from "./components/navbar";
2 | import React from "react";
3 |
4 | export default function AppLayout({
5 | children,
6 | }: Readonly<{
7 | children: React.ReactNode;
8 | }>) {
9 | return {children};
10 | }
11 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export type EmailCredentialsPayload = {
2 | email: string;
3 | password: string;
4 | };
5 |
6 | export type PhoneCredentialsPayload = {
7 | phone: string;
8 | };
9 |
10 | export type VerifyPhoneCredentialsPayload = {
11 | phone: string;
12 | pin: string;
13 | };
14 |
--------------------------------------------------------------------------------
/src/app/(system)/(main)/loading.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { PageSkeleton } from "@/components/cards/skeletons";
4 |
5 | export default function Loading() {
6 | return (
7 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/tests/app/index.spec.ts:
--------------------------------------------------------------------------------
1 | import expect from "expect";
2 | import {test} from "@jest/globals";
3 |
4 | const addTwoNumbers = (a: number, b: number) => {
5 | return a + b;
6 | };
7 |
8 | test("This is a sample function that adds 2 numbers", () => {
9 | expect(addTwoNumbers(2, 3)).toBe(5);
10 | });
11 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/new/loading.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Loader from "@/components/ui/loader";
4 |
5 | export default function Loading() {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/[id]/loading.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Loader from "@/components/ui/loader";
4 |
5 | export default function Loading() {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/update/[id]/loading.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Loader from "@/components/ui/loader";
4 |
5 | export default function Loading() {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { type NextRequest } from "next/server";
2 | import { updateSession } from "@/utils/supabase/middleware";
3 |
4 | export async function middleware(request: NextRequest) {
5 | return await updateSession(request);
6 | }
7 |
8 | export const config = {
9 | matcher: [
10 | "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
11 | ],
12 | };
13 |
--------------------------------------------------------------------------------
/src/components/providers/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children};
9 | }
10 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "gray",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/ui/edit-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { Button } from "./button";
5 | import Link from "next/link";
6 |
7 | function EditButton({ id }: { id: string }) {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
17 | export default EditButton;
18 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { cn } from "@/lib/utils";
3 |
4 | function Skeleton({
5 | className,
6 | ...props
7 | }: React.HTMLAttributes) {
8 | return (
9 |
16 | );
17 | }
18 |
19 | export { Skeleton };
20 |
--------------------------------------------------------------------------------
/src/components/ui/back-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useRouter } from "next/navigation";
3 | import React from "react";
4 | import { Button } from "./button";
5 |
6 | function BackButton() {
7 | const router = useRouter();
8 | return (
9 |
10 |
13 |
14 | );
15 | }
16 |
17 | export default BackButton;
18 |
--------------------------------------------------------------------------------
/src/components/ui/loader.tsx:
--------------------------------------------------------------------------------
1 | import { TailSpin } from "react-loader-spinner";
2 |
3 | type LoaderProps = {
4 | height: string;
5 | width: string;
6 | color: string;
7 | };
8 |
9 | function Loader({ height, width, color }: LoaderProps) {
10 | return (
11 |
19 | );
20 | }
21 |
22 | export default Loader;
--------------------------------------------------------------------------------
/src/app/(system)/logs/new/page.tsx:
--------------------------------------------------------------------------------
1 | import CreateLogForm from "./components/create-log";
2 | import { redirect } from "next/navigation";
3 | import { createClient } from "@/utils/supabase/server";
4 |
5 | async function CreateLogPage() {
6 | const supabase = await createClient();
7 |
8 | const { data, error } = await supabase.auth.getUser();
9 | if (error || !data?.user) {
10 | redirect("/sign-in");
11 | }
12 | return (
13 |
14 |
15 |
16 | );
17 | }
18 |
19 | export default CreateLogPage;
20 |
--------------------------------------------------------------------------------
/.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 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
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 | yarn.lock
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | .idea/
39 |
40 | supabase
41 |
--------------------------------------------------------------------------------
/src/app/(system)/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { ColorModeToggle } from "@/components/ui/color-mode-toggle";
2 | import React from "react";
3 |
4 | function SettingsPage() {
5 | return (
6 |
7 |
8 |
Settings
9 |
10 |
11 |
12 |
13 |
Color Theme
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | export default SettingsPage;
22 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/cards/card-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export function CardSkeleton() {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | }
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type {Config} from "@jest/types";
2 |
3 | export default async (): Promise => {
4 | return {
5 | transform: {
6 | "^.+\\.tsx?$": ["ts-jest", { /* ts-jest config goes here in Jest */}],
7 | },
8 |
9 | // preset: "ts-jest",
10 | // globals: {
11 | // "ts-jest": {
12 | // tsconfig: "tsconfig.test.json",
13 | // },
14 | // },
15 |
16 | // Automatically clear mock calls and instances between every test
17 | clearMocks: true,
18 |
19 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
20 | setupFilesAfterEnv: ["@testing-library/jest-dom"],
21 |
22 | moduleNameMapper: {
23 | "src/(.*)": "/src/$1",
24 | },
25 | };
26 | };
--------------------------------------------------------------------------------
/src/utils/errors.ts:
--------------------------------------------------------------------------------
1 | function normalizeSupabaseError(error: string): string {
2 | if (typeof error !== "string") {
3 | return "There was an error";
4 | }
5 |
6 | const errorMap: { [key: string]: string } = {
7 | "AuthApiError: Invalid login credentials": "Invalid Credentials",
8 | "AuthApiError: Unsupported phone provider": "Unsupported Phone Provider",
9 | "AuthApiError: Token has expired or is invalid":
10 | "Token has expired or is invalid",
11 | "AuthApiError: Error sending confirmation sms":
12 | "There was an error sending the OTP.",
13 | // Add more error mappings here as needed
14 | };
15 |
16 | for (const key in errorMap) {
17 | if (error.includes(key)) {
18 | return errorMap[key];
19 | }
20 | }
21 |
22 | return "There was an error";
23 | }
24 |
25 | export { normalizeSupabaseError };
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/contributing/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribution Guidelines
2 |
3 | Thank you for considering contributing to our project! We welcome all contributions.
4 |
5 | ## Reporting Bugs
6 |
7 | If you encounter a bug, please open an issue on GitHub and provide detailed information about the bug, including steps to reproduce it.
8 |
9 | ## Suggesting Enhancements
10 |
11 | If you have an idea for an enhancement or new feature, please open an issue on GitHub and describe your proposal.
12 |
13 | ## Code Style
14 |
15 | Please follow our coding style guidelines when contributing code to the project.
16 |
17 | ## Submitting Pull Requests
18 |
19 | To contribute code changes, please fork the repository and submit a pull request with your changes.
20 |
21 | ## Code Review
22 |
23 | All pull requests will be reviewed by project maintainers. Please be patient and responsive to feedback during the review process.
24 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | );
29 | Separator.displayName = SeparatorPrimitive.Root.displayName;
30 |
31 | export { Separator };
32 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "eslint:recommended",
5 | "prettier"
6 | ],
7 | "plugins": [
8 | "@typescript-eslint/eslint-plugin",
9 | "jest"
10 | ],
11 | "rules": {
12 | "react/no-unescaped-entities": "off",
13 | "@next/next/no-page-custom-font": "off",
14 | "no-unused-vars": "off",
15 | "no-useless-escape": "off",
16 | "no-trailing-spaces": "warn",
17 | "no-multi-spaces": "warn",
18 | "no-extra-semi": "warn",
19 | "@typescript-eslint/ban-ts-ignore": "off",
20 |
21 | // "@typescript-eslint/ban-ts-comment": "warn",
22 | "semi": "warn",
23 | "quotes": [
24 | "error",
25 | "double",
26 | {
27 | "allowTemplateLiterals": true
28 | }
29 | ],
30 | "@typescript-eslint/no-unused-vars": "warn"
31 | },
32 | "env": {
33 | "browser": true,
34 | "node": true,
35 | "jest": true,
36 | "es6": true
37 | }
38 | }
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { GeistSans } from "geist/font/sans";
3 | import "./globals.css";
4 | import React from "react";
5 | import { ThemeProvider } from "@/components/providers/theme-provider";
6 | import { Toaster } from "sonner";
7 |
8 | export const metadata: Metadata = {
9 | title: "Dumsor | Power Outage Tracker",
10 | description: "An app for monitoring the rate of lights going out in Ghana ",
11 | };
12 |
13 | export default function RootLayout({
14 | children,
15 | }: Readonly<{
16 | children: React.ReactNode;
17 | }>) {
18 | return (
19 |
20 |
21 |
27 | {children}
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/(system)/(main)/feed/log-feed.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import LogCard from "@/components/cards/log-card";
3 | import { Lightbulb } from "lucide-react";
4 |
5 | type Log = {
6 | _id: string;
7 | location: string;
8 | timeOff: string;
9 | timeBackOn: string;
10 | };
11 |
12 | type Props = {
13 | data: Log[];
14 | };
15 |
16 | function Home({ data }: Props) {
17 | return (
18 |
19 |
20 | {data.length === 0 ? (
21 |
22 |
23 |
No posts to display.
24 |
25 | ) : (
26 |
27 | {data.map((log) => (
28 |
29 | ))}
30 |
31 | )}
32 |
33 |
34 | );
35 | }
36 |
37 | export default Home;
38 |
--------------------------------------------------------------------------------
/src/utils/supabase/server.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import { createServerClient, type CookieOptions } from "@supabase/ssr";
3 | import { cookies } from "next/headers";
4 |
5 | export async function createClient() {
6 | const cookieStore = cookies();
7 |
8 | return createServerClient(
9 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
10 | process.env.NEXT_PUBLIC_SUPABASE_KEY!,
11 | {
12 | cookies: {
13 | get(name: string) {
14 | return cookieStore.get(name)?.value;
15 | },
16 | set(name: string, value: string, options: CookieOptions) {
17 | try {
18 | cookieStore.set({ name, value, ...options });
19 | } catch (error) {
20 | console.log(error);
21 | }
22 | },
23 | remove(name: string, options: CookieOptions) {
24 | try {
25 | cookieStore.set({ name, value: "", ...options });
26 | } catch (error) {
27 | console.log(error);
28 | }
29 | },
30 | },
31 | }
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/(system)/(main)/feed/recent-log-feed.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import LogCard from "@/components/cards/log-card";
3 | import { Lightbulb } from "lucide-react";
4 |
5 | type Log = {
6 | _id: string;
7 | location: string;
8 | timeOff: string;
9 | timeBackOn: string;
10 | };
11 |
12 | type Props = {
13 | data: Log[];
14 | };
15 |
16 | function Home({ data }: Props) {
17 | return (
18 |
19 |
20 | {data.length === 0 ? (
21 |
22 |
23 |
No posts to display.
24 |
25 | ) : (
26 |
27 | {data.map((log) => (
28 |
29 | ))}
30 |
31 | )}
32 |
33 |
34 | );
35 | }
36 |
37 | export default Home;
38 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/[id]/components/details-page.tsx:
--------------------------------------------------------------------------------
1 | import DetailsCard from "@/app/(system)/logs/[id]/components/details-card";
2 | import BackButton from "@/components/ui/back-button";
3 | import EditButton from "@/components/ui/edit-button";
4 |
5 | type Props = {
6 | id: string;
7 | location: string;
8 | timeBackOn: any;
9 | timeOff: any;
10 | userid: string;
11 | authorid: string;
12 | };
13 | async function DetailsPage({
14 | id,
15 | location,
16 | timeOff,
17 | timeBackOn,
18 | userid,
19 | authorid,
20 | }: Props) {
21 | return (
22 |
23 |
24 |
25 |
26 | {userid === authorid && }
27 |
28 |
33 |
34 |
35 | );
36 | }
37 |
38 | export default DetailsPage;
39 |
--------------------------------------------------------------------------------
/src/components/cards/skeletons.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export function PageSkeleton() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { useTheme } from "next-themes";
5 | import { Toaster as Sonner } from "sonner";
6 |
7 | type ToasterProps = React.ComponentProps;
8 |
9 | const Toaster = ({ ...props }: ToasterProps) => {
10 | const { theme = "system" } = useTheme();
11 |
12 | return (
13 |
29 | );
30 | };
31 |
32 | export { Toaster };
33 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
5 | import { CheckIcon } from "@radix-ui/react-icons";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ));
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
29 |
30 | export { Checkbox };
31 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import DetailsPage from "@/app/(system)/logs/[id]/components/details-page";
2 | import { createClient } from "@/utils/supabase/server";
3 | import { redirect } from "next/navigation";
4 | import { getLog } from "@/app/actions/logs.actions";
5 | import { toast } from "sonner";
6 |
7 | type Props = {
8 | params: {
9 | id: string;
10 | };
11 | };
12 | async function LogDetailsPage({ params }: Props) {
13 | const supabase = await createClient();
14 |
15 | const { data: userData, error: userError } = await supabase.auth.getUser();
16 | if (userError || !userData?.user) {
17 | redirect("/sign-in");
18 | }
19 |
20 | const data = await getLog(params.id);
21 |
22 | if (!data) {
23 | toast.error("Log not found");
24 | return;
25 | }
26 | const author = data.data.log.userId;
27 |
28 | return (
29 |
30 |
38 |
39 | );
40 | }
41 |
42 | export default LogDetailsPage;
43 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/update/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import UpdateLogForm from "./components/update-log";
2 | import { createClient } from "@/utils/supabase/server";
3 | import { redirect } from "next/navigation";
4 | import { getLog } from "@/app/actions/logs.actions";
5 | import { toast } from "sonner";
6 |
7 | type Props = {
8 | params: {
9 | id: string;
10 | };
11 | };
12 |
13 | async function UpdateLogPage({ params }: Props) {
14 | const supabase = await createClient();
15 |
16 | const { data: userData, error: userError } = await supabase.auth.getUser();
17 | if (userError || !userData?.user) {
18 | redirect("/sign-in");
19 | }
20 |
21 | const data = await getLog(params.id);
22 |
23 | if (!data) {
24 | toast.error("Log not found");
25 | return;
26 | }
27 | const author = data.data.log.userId;
28 |
29 | if (userData?.user.id !== author) {
30 | redirect("/");
31 | }
32 |
33 | return (
34 |
35 |
41 |
42 | );
43 | }
44 |
45 | export default UpdateLogPage;
46 |
--------------------------------------------------------------------------------
/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const HoverCard = HoverCardPrimitive.Root;
9 |
10 | const HoverCardTrigger = HoverCardPrimitive.Trigger;
11 |
12 | const HoverCardContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
26 | ));
27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
28 |
29 | export { HoverCard, HoverCardTrigger, HoverCardContent };
30 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cn } from "@/lib/utils";
3 |
4 | export interface InputProps
5 | extends React.InputHTMLAttributes {
6 | variant?: "default" | "large"; // Added variants so we can use the same component but with different styles
7 | }
8 |
9 | const Input = React.forwardRef(
10 | ({ className, type, variant = "default", ...props }, ref) => {
11 | const baseClassName =
12 | "flex bg-transparent py-2 placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50";
13 | let specificClassName = "";
14 |
15 | switch (variant) {
16 | case "large":
17 | specificClassName = "min-h-[60px] w-3/4 text-2xl placeholder:text-2xl";
18 | break;
19 | case "default":
20 | default:
21 | specificClassName =
22 | "h-9 w-full rounded-md border border-input px-3 py-1 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:ring-ring";
23 | break;
24 | }
25 |
26 | return (
27 |
33 | );
34 | }
35 | );
36 | Input.displayName = "Input";
37 |
38 | export { Input };
39 |
--------------------------------------------------------------------------------
/src/app/auth/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers";
2 | import { NextResponse } from "next/server";
3 | import { type CookieOptions, createServerClient } from "@supabase/ssr";
4 |
5 | export async function GET(request: Request) {
6 | const { searchParams, origin } = new URL(request.url);
7 | const code = searchParams.get("code");
8 | // if "next" is in param, use it as the redirect URL
9 | const next = searchParams.get("next") ?? "/";
10 |
11 | if (code) {
12 | const cookieStore = cookies();
13 | const supabase = createServerClient(
14 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
15 | process.env.NEXT_PUBLIC_SUPABASE_KEY!,
16 | {
17 | cookies: {
18 | get(name: string) {
19 | return cookieStore.get(name)?.value;
20 | },
21 | set(name: string, value: string, options: CookieOptions) {
22 | cookieStore.set({ name, value, ...options });
23 | },
24 | remove(name: string, options: CookieOptions) {
25 | cookieStore.delete({ name, ...options });
26 | },
27 | },
28 | }
29 | );
30 | const { error } = await supabase.auth.exchangeCodeForSession(code);
31 | if (!error) {
32 | return NextResponse.redirect(`${origin}${next}?page=1`);
33 | }
34 | }
35 |
36 | // return the user to an error page with instructions
37 | return NextResponse.redirect(`${origin}/auth/auth-code-error`);
38 | }
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Dumsor Tracker 💡
2 |
3 | ### About
4 |
5 | We're building this project to track the pattern of power outages across the country.
6 |
7 | ### Stack
8 |
9 | The project is built using the following technologies:
10 |
11 | ### Frontend
12 |
13 | Next.js: A React framework for building server-side rendered and statically generated web applications.
14 | TypeScript: A statically typed superset of JavaScript that adds type annotations to the language.
15 |
16 | ### Backend
17 |
18 | Node.js: A JavaScript runtime built on Chrome's V8 JavaScript engine.
19 | Express: A fast, unopinionated, minimalist web framework for Node.js.
20 |
21 | ### Database
22 |
23 | [To be determined based on project requirements]
24 |
25 | ### App Flow
26 |
27 | - Authentication: The application will require authentication. Users can view data without creating an account, but will have to create one to post data.
28 | - When your power gets cut off, simply visit the app, and provide the following details:
29 | - Your location
30 | - Power Status: Do you have light or not?
31 |
32 | ### Setting Up:
33 | - To set up. you'll need to generate a URL and a key from supabase.
34 | - After acquiring that, store them in an `.env.local` file.
35 | - add this as well: `NEXT_PUBLIC_API_BASE_URL = http:/localhost:3000/`
36 | - Install all dependencies, and you're good to go!
37 |
38 | ## Contributing
39 |
40 | Please read our [Contribution Guidelines](contributing/CONTRIBUTING.md) before contributing to the project.
41 |
--------------------------------------------------------------------------------
/src/components/ui/search-bar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useSearchParams, usePathname, useRouter } from "next/navigation";
3 | import { useDebouncedCallback } from "use-debounce";
4 |
5 | export default function Search({ placeholder }: { placeholder: string }) {
6 | const searchParams = useSearchParams();
7 | const pathname = usePathname();
8 | const { replace } = useRouter();
9 |
10 | const handleSearch = useDebouncedCallback((term) => {
11 | const params = new URLSearchParams(searchParams);
12 | params.set("page", "1");
13 | if (term) {
14 | params.set("location", term);
15 | } else {
16 | params.delete("location");
17 | }
18 | replace(`${pathname}?${params.toString()}`);
19 | }, 200);
20 |
21 | return (
22 |
23 |
26 | {
30 | handleSearch(e.target.value);
31 | }}
32 | defaultValue={searchParams.get("location")?.toString()}
33 | />
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ui/color-mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
5 | import { useTheme } from "next-themes";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 |
15 | export function ColorModeToggle() {
16 | const { setTheme } = useTheme();
17 |
18 | return (
19 |
20 |
21 |
26 |
27 |
28 | setTheme("light")}>
29 | Light
30 |
31 | setTheme("dark")}>
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ));
21 | Avatar.displayName = AvatarPrimitive.Root.displayName;
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ));
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ));
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
49 |
50 | export { Avatar, AvatarImage, AvatarFallback };
51 |
--------------------------------------------------------------------------------
/src/utils/supabase/middleware.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient, type CookieOptions } from "@supabase/ssr";
2 | import { NextResponse, type NextRequest } from "next/server";
3 |
4 | export async function updateSession(request: NextRequest) {
5 | let response = NextResponse.next({
6 | request: {
7 | headers: request.headers,
8 | },
9 | });
10 |
11 | const supabase = createServerClient(
12 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
13 | process.env.NEXT_PUBLIC_SUPABASE_KEY!,
14 | {
15 | cookies: {
16 | get(name: string) {
17 | return request.cookies.get(name)?.value;
18 | },
19 | set(name: string, value: string, options: CookieOptions) {
20 | request.cookies.set({
21 | name,
22 | value,
23 | ...options,
24 | });
25 | response = NextResponse.next({
26 | request: {
27 | headers: request.headers,
28 | },
29 | });
30 | response.cookies.set({
31 | name,
32 | value,
33 | ...options,
34 | });
35 | },
36 | remove(name: string, options: CookieOptions) {
37 | request.cookies.set({
38 | name,
39 | value: "",
40 | ...options,
41 | });
42 | response = NextResponse.next({
43 | request: {
44 | headers: request.headers,
45 | },
46 | });
47 | response.cookies.set({
48 | name,
49 | value: "",
50 | ...options,
51 | });
52 | },
53 | },
54 | }
55 | );
56 |
57 | await supabase.auth.getUser();
58 |
59 | return response;
60 | }
61 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | // build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
2 | // ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
3 | // docs: Documentation only changes
4 | // feat: A new feature
5 | // fix: A bug fix
6 | // perf: A code change that improves performance
7 | // refactor: A code change that neither fixes a bug nor adds a feature
8 | // style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
9 | // test: Adding missing tests or correcting existing tests
10 |
11 | module.exports = {
12 | extends: ["@commitlint/config-conventional"],
13 | rules: {
14 | "body-leading-blank": [1, "always"],
15 | "body-max-line-length": [2, "always", 100],
16 | "footer-leading-blank": [1, "always"],
17 | "footer-max-line-length": [2, "always", 100],
18 | "header-max-length": [2, "always", 100],
19 | "scope-case": [2, "always", "lower-case"],
20 | "subject-case": [2, "never", ["sentence-case", "start-case", "pascal-case", "upper-case"]],
21 | "subject-empty": [2, "never"],
22 | "subject-full-stop": [2, "never", "."],
23 | "type-case": [2, "always", "lower-case"],
24 | "type-empty": [2, "never"],
25 | "type-enum": [
26 | 2,
27 | "always",
28 | [
29 | "build",
30 | "chore",
31 | "ci",
32 | "docs",
33 | "feat",
34 | "fix",
35 | "perf",
36 | "refactor",
37 | "revert",
38 | "style",
39 | "test",
40 | "translation",
41 | "security",
42 | "changeset"
43 | ]
44 | ]
45 | }
46 | };
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/[id]/components/details-card.tsx:
--------------------------------------------------------------------------------
1 | import { Lightbulb, LightbulbOff, Map } from "lucide-react";
2 | import React from "react";
3 |
4 | type Props = {
5 | location: string;
6 | timeBackOn: any;
7 | timeOff: any;
8 | };
9 | async function DetailsCard({ location, timeOff, timeBackOn }: Props) {
10 | return (
11 |
12 |
13 |
19 | {location}
20 |
21 |
22 |
23 |
24 | Time off: {timeOff}
25 |
26 |
27 | {timeBackOn === "0" ? (
28 | <>
29 |
30 | Still off
31 | >
32 | ) : (
33 | <>
34 |
35 | Time back: {timeBackOn}
36 | >
37 | )}
38 |
39 |
40 |
41 | );
42 | }
43 |
44 | export default DetailsCard;
45 |
--------------------------------------------------------------------------------
/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | export function getCurrentDate(): string {
2 | const months: string[] = [
3 | "January",
4 | "February",
5 | "March",
6 | "April",
7 | "May",
8 | "June",
9 | "July",
10 | "August",
11 | "September",
12 | "October",
13 | "November",
14 | "December",
15 | ];
16 | const today: Date = new Date();
17 | const day: number = today.getDate();
18 | const month: string = months[today.getMonth()];
19 | const year: number = today.getFullYear();
20 |
21 | // Function to get the ordinal suffix for the day
22 | const getOrdinalSuffix = (num: number): string => {
23 | if (num === 1 || num === 21 || num === 31) return "st";
24 | if (num === 2 || num === 22) return "nd";
25 | if (num === 3 || num === 23) return "rd";
26 | return "th";
27 | };
28 |
29 | const ordinalSuffix: string = getOrdinalSuffix(day);
30 |
31 | const date = `${day}${ordinalSuffix} ${month}, ${year}`;
32 | return date;
33 | }
34 |
35 | export function getCurrentTime(): string {
36 | const today: Date = new Date();
37 | let hour: number = today.getHours();
38 | const minute: number = today.getMinutes();
39 | const period: string = hour < 12 ? "am" : "pm";
40 |
41 | // Convert 24-hour time to 12-hour time
42 | if (hour > 12) {
43 | hour -= 12;
44 | } else if (hour === 0) {
45 | hour = 12;
46 | }
47 |
48 | const time = `${hour}.${minute < 10 ? "0" : ""}${minute}${period}`;
49 | return time;
50 | }
51 |
52 | export function getCurrentFormattedTime(): string {
53 | const today: Date = new Date();
54 | const hour: number = today.getHours();
55 | const minute: number = today.getMinutes();
56 |
57 | const hourStr: string = hour < 10 ? "0" + hour : "" + hour;
58 | const minuteStr: string = minute < 10 ? "0" + minute : "" + minute;
59 |
60 | const time = `${hourStr}:${minuteStr}`;
61 | return time;
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/cards/log-card.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Lightbulb, LightbulbOff } from "lucide-react";
3 | import { Map } from "lucide-react";
4 |
5 | interface Log {
6 | _id: string;
7 | location: string;
8 | timeOff: string;
9 | timeBackOn: string;
10 | }
11 |
12 | export function LogCard({ log }: { log: Log }) {
13 | return (
14 |
15 |
16 |
17 |
23 | {log.location}
24 |
25 |
26 |
27 |
28 | Time off: {log.timeOff}
29 |
30 |
31 | {log.timeBackOn === "0" ? (
32 | <>
33 |
34 | Still off
35 | >
36 | ) : (
37 | <>
38 |
39 | Time back: {log.timeBackOn}
40 | >
41 | )}
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
49 | export default LogCard;
50 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | }
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as TabsPrimitive from "@radix-ui/react-tabs";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Tabs = TabsPrimitive.Root;
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | TabsList.displayName = TabsPrimitive.List.displayName;
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ));
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ));
53 | TabsContent.displayName = TabsPrimitive.Content.displayName;
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent };
56 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ));
42 | CardTitle.displayName = "CardTitle";
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ));
54 | CardDescription.displayName = "CardDescription";
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ));
62 | CardContent.displayName = "CardContent";
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | CardFooter.displayName = "CardFooter";
75 |
76 | export {
77 | Card,
78 | CardHeader,
79 | CardFooter,
80 | CardTitle,
81 | CardDescription,
82 | CardContent,
83 | };
84 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 20 14.3% 4.1%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 20 14.3% 4.1%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 20 14.3% 4.1%;
13 | --primary: 24.6 95% 53.1%;
14 | --primary-foreground: 60 9.1% 97.8%;
15 | --secondary: 60 4.8% 95.9%;
16 | --secondary-foreground: 24 9.8% 10%;
17 | --muted: 60 4.8% 95.9%;
18 | --muted-foreground: 25 5.3% 44.7%;
19 | --accent: 60 4.8% 95.9%;
20 | --accent-foreground: 24 9.8% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 60 9.1% 97.8%;
23 | --border: 20 5.9% 90%;
24 | --input: 20 5.9% 90%;
25 | --ring: 24.6 95% 53.1%;
26 | --radius: 0.5rem;
27 | }
28 |
29 | .dark {
30 | --background: 20 14.3% 4.1%;
31 | --foreground: 60 9.1% 97.8%;
32 | --card: 20 14.3% 4.1%;
33 | --card-foreground: 60 9.1% 97.8%;
34 | --popover: 20 14.3% 4.1%;
35 | --popover-foreground: 60 9.1% 97.8%;
36 | --primary: 20.5 90.2% 48.2%;
37 | --primary-foreground: 60 9.1% 97.8%;
38 | --secondary: 12 6.5% 15.1%;
39 | --secondary-foreground: 60 9.1% 97.8%;
40 | --muted: 12 6.5% 15.1%;
41 | --muted-foreground: 24 5.4% 63.9%;
42 | --accent: 12 6.5% 15.1%;
43 | --accent-foreground: 60 9.1% 97.8%;
44 | --destructive: 0 72.2% 50.6%;
45 | --destructive-foreground: 60 9.1% 97.8%;
46 | --border: 12 6.5% 15.1%;
47 | --input: 12 6.5% 15.1%;
48 | --ring: 20.5 90.2% 48.2%;
49 | }
50 | }
51 |
52 | @layer base {
53 | * {
54 | @apply border-border;
55 | }
56 | body {
57 | @apply bg-background text-foreground;
58 | }
59 | h1 {
60 | @apply scroll-m-20 text-4xl font-bold tracking-tight lg:text-5xl;
61 | }
62 | h2 {
63 | @apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0;
64 | }
65 | h3 {
66 | @apply scroll-m-20 text-2xl font-semibold tracking-tight;
67 | }
68 | h4 {
69 | @apply scroll-m-20 text-xl font-semibold tracking-tight;
70 | }
71 | h5 {
72 | @apply scroll-m-20 text-lg font-semibold tracking-tight;
73 | }
74 | h6 {
75 | @apply scroll-m-20 text-sm font-semibold tracking-tight;
76 | }
77 | p {
78 | @apply leading-7;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/ui/input-otp.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { DashIcon } from "@radix-ui/react-icons";
5 | import { OTPInput, OTPInputContext } from "input-otp";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const InputOTP = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, containerClassName, ...props }, ref) => (
13 |
22 | ));
23 | InputOTP.displayName = "InputOTP";
24 |
25 | const InputOTPGroup = React.forwardRef<
26 | React.ElementRef<"div">,
27 | React.ComponentPropsWithoutRef<"div">
28 | >(({ className, ...props }, ref) => (
29 |
30 | ));
31 | InputOTPGroup.displayName = "InputOTPGroup";
32 |
33 | const InputOTPSlot = React.forwardRef<
34 | React.ElementRef<"div">,
35 | React.ComponentPropsWithoutRef<"div"> & { index: number }
36 | >(({ index, className, ...props }, ref) => {
37 | const inputOTPContext = React.useContext(OTPInputContext);
38 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
39 |
40 | return (
41 |
50 | {char}
51 | {hasFakeCaret && (
52 |
55 | )}
56 |
57 | );
58 | });
59 | InputOTPSlot.displayName = "InputOTPSlot";
60 |
61 | const InputOTPSeparator = React.forwardRef<
62 | React.ElementRef<"div">,
63 | React.ComponentPropsWithoutRef<"div">
64 | >(({ ...props }, ref) => (
65 |
66 |
67 |
68 | ));
69 | InputOTPSeparator.displayName = "InputOTPSeparator";
70 |
71 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
72 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dumsor-tracker-frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next dev",
8 | "lint": "next lint",
9 | "prepare": "husky install",
10 | "start": "next start",
11 | "test": "jest"
12 | },
13 | "prettier": {
14 | "tabWidth": 2
15 | },
16 | "dependencies": {
17 | "@hookform/resolvers": "^3.3.4",
18 | "@radix-ui/react-alert-dialog": "^1.0.5",
19 | "@radix-ui/react-avatar": "^1.0.4",
20 | "@radix-ui/react-checkbox": "^1.0.4",
21 | "@radix-ui/react-dialog": "^1.0.5",
22 | "@radix-ui/react-dropdown-menu": "^2.0.6",
23 | "@radix-ui/react-hover-card": "^1.0.7",
24 | "@radix-ui/react-icons": "^1.3.0",
25 | "@radix-ui/react-label": "^2.0.2",
26 | "@radix-ui/react-separator": "^1.0.3",
27 | "@radix-ui/react-slot": "^1.0.2",
28 | "@radix-ui/react-tabs": "^1.0.4",
29 | "@radix-ui/react-toast": "^1.1.5",
30 | "@supabase/ssr": "^0.3.0",
31 | "@supabase/supabase-js": "^2.42.7",
32 | "axios": "^1.6.8",
33 | "boring-avatars": "^1.10.2",
34 | "class-variance-authority": "^0.7.0",
35 | "clsx": "^2.1.0",
36 | "commitlint": "^19.2.2",
37 | "eslint-plugin-jest": "^28.2.0",
38 | "geist": "^1.3.0",
39 | "husky": "^9.0.11",
40 | "input-otp": "^1.2.4",
41 | "lucide-react": "^0.372.0",
42 | "next": "14.2.2",
43 | "next-themes": "^0.3.0",
44 | "react": "^18",
45 | "react-dom": "^18",
46 | "react-hook-form": "^7.51.3",
47 | "react-icons": "^5.1.0",
48 | "react-loader-spinner": "^6.1.6",
49 | "sonner": "^1.4.41",
50 | "tailwind-merge": "^2.3.0",
51 | "tailwindcss-animate": "^1.0.7",
52 | "use-debounce": "^10.0.0",
53 | "zod": "^3.23.3"
54 | },
55 | "devDependencies": {
56 | "@commitlint/cli": "^19.2.2",
57 | "@commitlint/config-conventional": "^19.2.2",
58 | "@testing-library/jest-dom": "^6.4.2",
59 | "@testing-library/react": "^15.0.2",
60 | "@types/jest": "^29.5.12",
61 | "@types/node": "^20",
62 | "@types/react": "^18",
63 | "@types/react-dom": "^18",
64 | "@typescript-eslint/eslint-plugin": "^7.7.0",
65 | "eslint": "^8",
66 | "eslint-config-next": "14.2.2",
67 | "eslint-config-prettier": "^9.1.0",
68 | "jest": "^29.7.0",
69 | "jest-environment-jsdom": "^29.7.0",
70 | "postcss": "^8",
71 | "tailwindcss": "^3.4.1",
72 | "ts-jest": "^29.1.2",
73 | "ts-node": "^10.9.2",
74 | "typescript": "^5"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config;
79 |
80 | export default config;
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/components/sign-in.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
7 | import { FcGoogle } from "react-icons/fc";
8 | import { toast } from "sonner";
9 | import { continueWithGoogle } from "@/app/actions/auth.actions";
10 | import EmailSignIn from "./email-sign-in";
11 | import PhoneSignIn from "./phone-sign-in";
12 |
13 | function SignInForm() {
14 | function signInWithGoogle() {
15 | toast.success("Redirecting");
16 | continueWithGoogle();
17 | }
18 |
19 | return (
20 |
21 |
22 |
23 |
Sign In
24 |
25 | Don't have an account?{" "}
26 |
27 |
28 | Sign Up
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | Email
37 |
38 |
39 | Phone
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | OR
54 |
55 |
56 |
57 |
58 |
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | export default SignInForm;
74 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/components/sign-up.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
7 | import { FcGoogle } from "react-icons/fc";
8 | import { toast } from "sonner";
9 | import { continueWithGoogle } from "../../../actions/auth.actions";
10 | import EmailSignUp from "./email-sign-up";
11 | import PhoneSignUp from "./phone-sign-up";
12 |
13 | function SignUpForm() {
14 | function signUpWithGoogle() {
15 | toast.success("Redirecting");
16 | continueWithGoogle();
17 | }
18 |
19 | return (
20 |
21 |
22 |
23 |
Sign Up
24 |
25 | Already have an account?{" "}
26 |
27 |
28 | Sign In
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | Email
37 |
38 |
39 | Phone
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | OR
54 |
55 |
56 |
57 |
58 |
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | export default SignUpForm;
74 |
--------------------------------------------------------------------------------
/src/app/(system)/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | import UserProfileDropdown from "@/app/(system)/components/user-profile-dropdown";
2 | import { Button } from "@/components/ui/button";
3 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
4 | import { createClient } from "@/utils/supabase/server";
5 | import { Lightbulb, Menu, Package2 } from "lucide-react";
6 | import Link from "next/link";
7 | import { ReactNode } from "react";
8 |
9 | type Props = {
10 | children: ReactNode;
11 | };
12 |
13 | async function Navbar({ children }: Props) {
14 | const supabase = await createClient();
15 | const user = await supabase.auth.getUser();
16 |
17 | return (
18 |
19 |
74 |
{children}
75 |
76 | );
77 | }
78 |
79 | export default Navbar;
80 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/components/phone-sign-up.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { continueWithPhoneNumber } from "@/app/actions/auth.actions";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Form,
7 | FormControl,
8 | FormDescription,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { Input } from "@/components/ui/input";
15 | import Loader from "@/components/ui/loader";
16 | import { validators } from "@/utils";
17 | import { normalizeSupabaseError } from "@/utils/errors";
18 | import { zodResolver } from "@hookform/resolvers/zod";
19 | import { Phone } from "lucide-react";
20 | import { useRouter } from "next/navigation";
21 | import { useState } from "react";
22 | import { useForm } from "react-hook-form";
23 | import { toast } from "sonner";
24 | import { z } from "zod";
25 |
26 | const FormSchema = z.object({
27 | phone_number: z
28 | .string()
29 | .min(10, {
30 | message: "Phone number must be at least 10 characters.",
31 | })
32 | .regex(validators.PhoneNumberRegex),
33 | });
34 |
35 | function PhoneSignUp() {
36 | const router = useRouter();
37 | const [isSubmitting, setIsSubmitting] = useState(false);
38 | const form = useForm>({
39 | resolver: zodResolver(FormSchema),
40 | defaultValues: {
41 | phone_number: "",
42 | },
43 | });
44 |
45 | async function onSubmit(data: z.infer) {
46 | setIsSubmitting(true);
47 | try {
48 | await continueWithPhoneNumber({ phone: data.phone_number });
49 | form.reset();
50 | router.push(`/verify-otp/${data.phone_number}`);
51 | } catch (e: any) {
52 | toast.error(`${normalizeSupabaseError((e as Error)?.message)}`);
53 | console.error((e as Error)?.message);
54 | } finally {
55 | setIsSubmitting(false);
56 | }
57 | }
58 |
59 | return (
60 |
90 |
91 | );
92 | }
93 |
94 | export default PhoneSignUp;
95 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/components/phone-sign-in.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { continueWithPhoneNumber } from "@/app/actions/auth.actions";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Form,
7 | FormControl,
8 | FormDescription,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { Input } from "@/components/ui/input";
15 | import Loader from "@/components/ui/loader";
16 | import { validators } from "@/utils";
17 | import { normalizeSupabaseError } from "@/utils/errors";
18 | import { zodResolver } from "@hookform/resolvers/zod";
19 | import { Phone } from "lucide-react";
20 | import { useRouter } from "next/navigation";
21 | import { useState } from "react";
22 | import { useForm } from "react-hook-form";
23 | import { toast } from "sonner";
24 | import { z } from "zod";
25 |
26 | const FormSchema = z.object({
27 | phone_number: z
28 | .string()
29 | .regex(validators.PhoneNumberRegex, "Invalid Phone Number"),
30 | });
31 |
32 | function PhoneSignIn() {
33 | const router = useRouter();
34 | const [isSubmitting, setIsSubmitting] = useState(false);
35 | const form = useForm>({
36 | resolver: zodResolver(FormSchema),
37 | defaultValues: {
38 | phone_number: "",
39 | },
40 | });
41 |
42 | async function onSubmit(data: z.infer) {
43 | setIsSubmitting(true);
44 | try {
45 | let phone = data.phone_number;
46 |
47 | if (phone.startsWith("0")) {
48 | phone = phone.replace("0", "233");
49 | }
50 |
51 | await continueWithPhoneNumber({ phone });
52 | router.push(`/verify-otp/${phone}`);
53 | form.reset();
54 | } catch (e: any) {
55 | toast.error(`${normalizeSupabaseError((e as Error)?.message)}`);
56 | console.error((e as Error)?.message);
57 | } finally {
58 | setIsSubmitting(false);
59 | }
60 | }
61 |
62 | return (
63 |
93 |
94 | );
95 | }
96 |
97 | export default PhoneSignIn;
98 |
--------------------------------------------------------------------------------
/src/app/actions/logs.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { createClient } from "@/utils/supabase/server";
4 | import { redirect } from "next/navigation";
5 | import { revalidatePath } from "next/cache";
6 |
7 | const BASE_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}`;
8 |
9 | async function createLog(payload: any) {
10 | const supabase = await createClient();
11 |
12 | const {
13 | data: { session: supabaseSession },
14 | } = await supabase.auth.getSession();
15 |
16 | const url = new URL(`${BASE_URL}/logs`);
17 | const response = await fetch(url, {
18 | method: "POST",
19 | body: JSON.stringify(payload),
20 | headers: {
21 | Authorization: `Bearer ${supabaseSession?.access_token}`,
22 | "Content-Type": "application/json",
23 | },
24 | });
25 |
26 | if (!response.ok) {
27 | const msg = await response.text();
28 | throw new Error(msg);
29 | }
30 |
31 | revalidatePath("/");
32 |
33 | return response.json();
34 | }
35 |
36 | // Get One Log
37 | async function getLog(id: string) {
38 | const url = new URL(`${BASE_URL}/logs/${id}`);
39 | const response = await fetch(url, {
40 | method: "GET",
41 | });
42 | if (!response.ok) {
43 | redirect("/");
44 | }
45 |
46 | return response.json();
47 | }
48 |
49 | // Update Logs
50 | async function updateLogs(payload: any) {
51 | const supabase = await createClient();
52 |
53 | const {
54 | data: { session: supabaseSession },
55 | } = await supabase.auth.getSession();
56 |
57 | const url = new URL(`${BASE_URL}/logs/${payload.id}`);
58 | const response = await fetch(url, {
59 | method: "PATCH",
60 | body: JSON.stringify(payload.values),
61 | headers: {
62 | Authorization: `Bearer ${supabaseSession?.access_token}`,
63 | "Content-Type": "application/json",
64 | },
65 | });
66 | if (!response.ok) {
67 | const msg = await response.text();
68 | throw new Error(msg);
69 | }
70 |
71 | revalidatePath("/");
72 |
73 | return response.json();
74 | }
75 |
76 | type searchQuery = {
77 | location?: string;
78 | page?: string;
79 | };
80 |
81 | async function getLogs({ ...params }: searchQuery) {
82 | let url;
83 | params.location && params.page
84 | ? (url = new URL(
85 | `${BASE_URL}/logs?page=${params.page}&location=${params.location}`,
86 | ))
87 | : (url = new URL(`${BASE_URL}/logs?page=${params.page}`));
88 | const response = await fetch(url, {
89 | method: "GET",
90 | });
91 |
92 | if (!response.ok) {
93 | const msg = await response.text();
94 | throw new Error(msg);
95 | }
96 |
97 | return response.json();
98 | }
99 |
100 | async function getRecentLogs() {
101 | const response = await getLogs({ page: "1", location: "" });
102 |
103 | if (!response || !response.data || !Array.isArray(response.data.logs)) {
104 | throw new Error("An error occurred while fetching recent logs");
105 | }
106 |
107 | const logs = response.data.logs;
108 |
109 | return logs;
110 | }
111 |
112 | export { createLog, getLogs, getLog, getRecentLogs, updateLogs };
113 |
--------------------------------------------------------------------------------
/src/app/(system)/components/user-profile-dropdown.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { signOut } from "@/app/actions/auth.actions";
3 | import {
4 | AlertDialog,
5 | AlertDialogAction,
6 | AlertDialogCancel,
7 | AlertDialogContent,
8 | AlertDialogDescription,
9 | AlertDialogFooter,
10 | AlertDialogHeader,
11 | AlertDialogTitle,
12 | AlertDialogTrigger,
13 | AlertDialogPortal,
14 | AlertDialogOverlay
15 | } from "@/components/ui/alert-dialog";
16 | import {
17 | DropdownMenu,
18 | DropdownMenuContent,
19 | DropdownMenuGroup,
20 | DropdownMenuItem,
21 | DropdownMenuLabel,
22 | DropdownMenuSeparator,
23 | DropdownMenuTrigger,
24 | } from "@/components/ui/dropdown-menu";
25 | import Avatar from "boring-avatars";
26 | import Link from "next/link";
27 | import { toast } from "sonner";
28 |
29 | export default function UserProfileDropdown() {
30 |
31 | const handleSignOut = async () => {
32 | const res = await signOut();
33 | if (res && res.error == "Failure to sign out") {
34 | toast.error("Error signing out. Please try again.");
35 | }
36 | };
37 |
38 | return (
39 |
40 |
41 |
42 |
50 |
51 |
52 | My Account
53 |
54 |
55 |
56 | Settings
57 |
58 |
59 |
60 |
61 |
62 | Logout
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | Are you absolutely sure?
72 |
73 | This action will log you out.
74 |
75 |
76 |
77 | Cancel
78 |
79 | Continue
80 |
81 |
82 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/app/actions/auth.actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import {
3 | EmailCredentialsPayload,
4 | PhoneCredentialsPayload,
5 | VerifyPhoneCredentialsPayload,
6 | } from "@/types";
7 | import { createClient } from "@/utils/supabase/server";
8 | import { redirect } from "next/navigation";
9 |
10 | const BASE_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}`;
11 |
12 | async function continueWithGoogle() {
13 | const supabase = await createClient();
14 |
15 | const { data, error } = await supabase.auth.signInWithOAuth({
16 | provider: "google",
17 | options: {
18 | redirectTo: "http://localhost:3000/auth/callback",
19 | },
20 | });
21 |
22 | if (error) {
23 | throw new Error(`${error}`);
24 | }
25 |
26 | if (data.url) {
27 | redirect(data.url);
28 | }
29 | }
30 |
31 | async function continueWithPhoneNumber(payload: PhoneCredentialsPayload) {
32 | const supabase = await createClient();
33 |
34 | const { data, error } = await supabase.auth.signInWithOtp({
35 | phone: payload.phone,
36 | });
37 |
38 | if (error) {
39 | throw new Error(`${error}`);
40 | }
41 |
42 | if (data.session) {
43 | redirect("/");
44 | }
45 | }
46 |
47 | async function verifyPhoneNumber(payload: VerifyPhoneCredentialsPayload) {
48 | const supabase = await createClient();
49 |
50 | const { data, error } = await supabase.auth.verifyOtp({
51 | phone: payload.phone,
52 | token: payload.pin,
53 | type: "sms",
54 | });
55 |
56 | if (error) {
57 | throw new Error(`${error}`);
58 | }
59 |
60 | if (data.session) {
61 | redirect("/");
62 | }
63 | }
64 |
65 | async function signInWithCredentials(payload: EmailCredentialsPayload) {
66 | const supabase = await createClient();
67 | const { data, error } = await supabase.auth.signInWithPassword({
68 | email: payload.email,
69 | password: payload.password,
70 | });
71 | if (error) {
72 | throw new Error(`${error}`);
73 | }
74 |
75 | return data;
76 | }
77 |
78 | async function signUpWithCredentials(payload: EmailCredentialsPayload) {
79 | const supabase = await createClient();
80 | const { data, error } = await supabase.auth.signUp({
81 | email: payload.email,
82 | password: payload.password,
83 | });
84 | if (error) {
85 | throw new Error(`${error}`);
86 | }
87 |
88 | return data;
89 | }
90 |
91 | async function signOut(): Promise<{ error?: string }> {
92 | // Signout for supabase user
93 | const supabase = await createClient();
94 | const supabaseUser = await supabase.auth.getUser();
95 | if (supabaseUser.data.user) {
96 | const { error } = await supabase.auth.signOut();
97 | if (error) {
98 | return { error: "Failure to sign out" };
99 | }
100 | }
101 |
102 | // Signout for all other users
103 | let success = false;
104 | try {
105 | const res = await fetch(`${BASE_URL}/users/logout`, {
106 | method: "POST",
107 | headers: {
108 | "Content-Type": "application/json",
109 | },
110 | body: JSON.stringify({ email: "", password: "" }),
111 | });
112 | if (res.status == 200) {
113 | success = true;
114 | }
115 | } catch (err) {
116 | throw new Error(`${err}`);
117 | }
118 |
119 | if (success) {
120 | redirect("/sign-in");
121 | } else {
122 | return { error: "Failure to sign out" };
123 | }
124 | }
125 |
126 | export {
127 | continueWithGoogle,
128 | continueWithPhoneNumber,
129 | signOut,
130 | signUpWithCredentials,
131 | signInWithCredentials,
132 | verifyPhoneNumber,
133 | };
134 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/components/email-sign-in.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Checkbox } from "@/components/ui/checkbox";
5 | import {
6 | Form,
7 | FormControl,
8 | FormField,
9 | FormItem,
10 | FormLabel,
11 | FormMessage,
12 | } from "@/components/ui/form";
13 | import { Input } from "@/components/ui/input";
14 | import Loader from "@/components/ui/loader";
15 | import { zodResolver } from "@hookform/resolvers/zod";
16 | import { Mail } from "lucide-react";
17 | import { useState } from "react";
18 | import { useForm } from "react-hook-form";
19 | import { toast } from "sonner";
20 | import { z } from "zod";
21 | import { signInWithCredentials } from "@/app/actions/auth.actions";
22 | import { useRouter } from "next/navigation";
23 | import { normalizeSupabaseError } from "@/utils/errors";
24 |
25 | const FormSchema = z.object({
26 | email: z.string().email({
27 | message: "Email is invalid.",
28 | }),
29 | password: z.string().min(8, {
30 | message: "Password must be at least 8 characters.",
31 | }),
32 | });
33 |
34 | function EmailSignIn() {
35 | const router = useRouter();
36 | const [isSubmitting, setIsSubmitting] = useState(false);
37 | const [isOpen, setIsOpen] = useState(false);
38 |
39 | const form = useForm>({
40 | resolver: zodResolver(FormSchema),
41 | defaultValues: {
42 | email: "",
43 | password: "",
44 | },
45 | });
46 |
47 | async function onSubmit(data: z.infer) {
48 | setIsSubmitting(true);
49 | try {
50 | await signInWithCredentials(data);
51 | router.replace("/?page=1");
52 | } catch (e: any) {
53 | toast.error(`${normalizeSupabaseError((e as Error)?.message)}`);
54 | console.error((e as Error)?.message);
55 | } finally {
56 | setIsSubmitting(false);
57 | }
58 | }
59 |
60 | return (
61 |
111 |
112 | );
113 | }
114 |
115 | export default EmailSignIn;
116 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/components/email-sign-up.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { signUpWithCredentials } from "@/app/actions/auth.actions";
4 | import { Button } from "@/components/ui/button";
5 | import { Checkbox } from "@/components/ui/checkbox";
6 | import {
7 | Form,
8 | FormControl,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { Input } from "@/components/ui/input";
15 | import Loader from "@/components/ui/loader";
16 | import { normalizeSupabaseError } from "@/utils/errors";
17 | import { zodResolver } from "@hookform/resolvers/zod";
18 | import { Mail } from "lucide-react";
19 | import { useRouter } from "next/navigation";
20 | import { useState } from "react";
21 | import { useForm } from "react-hook-form";
22 | import { toast } from "sonner";
23 | import { z } from "zod";
24 |
25 | const FormSchema = z.object({
26 | email: z.string().email({
27 | message: "Email is invalid.",
28 | }),
29 | password: z.string().min(8, {
30 | message: "Password must be at least 8 characters.",
31 | }),
32 | });
33 |
34 | function EmailSignUp() {
35 | const router = useRouter();
36 | const [isSubmitting, setIsSubmitting] = useState(false);
37 | const [isOpen, setIsOpen] = useState(false);
38 |
39 | const form = useForm>({
40 | resolver: zodResolver(FormSchema),
41 | defaultValues: {
42 | email: "",
43 | password: "",
44 | },
45 | });
46 |
47 | async function onSubmit(data: z.infer) {
48 | setIsSubmitting(true);
49 | try {
50 | await signUpWithCredentials(data);
51 | toast.success("A link has been sent to your email.");
52 | form.reset();
53 | router.push("/sign-in");
54 | router;
55 | } catch (e: any) {
56 | toast.error(`${normalizeSupabaseError((e as Error)?.message)}`);
57 | console.error((e as Error)?.message);
58 | } finally {
59 | setIsSubmitting(false);
60 | }
61 | }
62 |
63 | return (
64 |
114 |
115 | );
116 | }
117 |
118 | export default EmailSignUp;
119 |
--------------------------------------------------------------------------------
/src/app/(auth)/verify-otp/[phone]/components/verify.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { toast } from "sonner";
5 | import { verifyPhoneNumber } from "@/app/actions/auth.actions";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { useForm } from "react-hook-form";
8 | import { z } from "zod";
9 | import { useParams } from "next/navigation";
10 |
11 | import {
12 | Form,
13 | FormControl,
14 | FormDescription,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage,
19 | } from "@/components/ui/form";
20 | import {
21 | InputOTP,
22 | InputOTPGroup,
23 | InputOTPSlot,
24 | } from "@/components/ui/input-otp";
25 | import { useState } from "react";
26 | import Loader from "@/components/ui/loader";
27 | import { normalizeSupabaseError } from "@/utils/errors";
28 |
29 | const OTPFormSchema = z.object({
30 | pin: z.string().min(6, {
31 | message: "Your one-time password must be 6 characters.",
32 | }),
33 | });
34 |
35 | export function InputOTPForm() {
36 | const [isSubmitting, setIsSubmitting] = useState(false);
37 | const { phone } = useParams();
38 |
39 | const form = useForm>({
40 | resolver: zodResolver(OTPFormSchema),
41 | defaultValues: {
42 | pin: "",
43 | },
44 | });
45 |
46 | async function onSubmit(data: z.infer) {
47 | setIsSubmitting(true);
48 | try {
49 | await verifyPhoneNumber({
50 | pin: data.pin,
51 | phone: typeof phone === "string" ? phone : phone[0],
52 | });
53 | form.reset();
54 | } catch (e: any) {
55 | toast.error(`${normalizeSupabaseError((e as Error)?.message)}`);
56 | console.error((e as Error)?.message);
57 | } finally {
58 | setIsSubmitting(false);
59 | }
60 | }
61 |
62 | return (
63 |
102 |
103 | );
104 | }
105 |
106 | function VerifyOTPForm() {
107 | return (
108 |
109 |
110 |
111 |
Verify Phone
112 |
113 |
114 |
115 |
116 |
117 |
118 | );
119 | }
120 |
121 | export default VerifyOTPForm;
122 |
--------------------------------------------------------------------------------
/src/app/(system)/(main)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import Search from "@/components/ui/search-bar";
3 | import { PlusIcon } from "lucide-react";
4 | import Link from "next/link";
5 | import { getLogs, getRecentLogs } from "../../actions/logs.actions";
6 | import LogList from "@/app/(system)/(main)/feed/log-feed";
7 | import RecentLogList from "@/app/(system)/(main)/feed/recent-log-feed";
8 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
9 | import React from "react";
10 |
11 | export default async function Home({
12 | searchParams,
13 | }: {
14 | searchParams?: {
15 | page?: string;
16 | location?: string;
17 | };
18 | }) {
19 | const response = await getLogs({
20 | location: searchParams?.location!,
21 | page: searchParams?.page!,
22 | });
23 |
24 | // make all calls for the next page. using this for pagination
25 | const next_page = await getLogs({
26 | location: searchParams?.location!,
27 | page: String(Number(searchParams?.page) + 1)!,
28 | });
29 |
30 | const recent = await getRecentLogs();
31 |
32 | return (
33 |
34 |
35 |
36 |
39 |
40 |
41 |
42 |
43 |
Are your lights out?
44 |
45 | View logs of power outages across the country
46 |
47 |
48 |
49 |
50 |
51 |
52 | Recent
53 |
54 |
55 | All Logs
56 |
57 |
58 |
59 |
64 |
65 | {recent.length > 0 && (
66 |
67 | That's all for now.
68 |
69 | )}
70 |
71 |
72 |
73 |
74 |
75 |
76 |
81 |
82 |
83 |
84 | {Number(searchParams?.page) > 1 && (
85 |
90 | Previous
91 |
92 | )}
93 | {next_page.data.logs.length > 0 && (
94 |
103 | Next
104 |
105 | )}
106 |
107 |
108 | {next_page.data.logs.length === 0 && (
109 |
110 | Oops, you reached the end.
111 |
112 | )}
113 |
114 |
115 |
116 |
117 |
118 |
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SheetPrimitive from "@radix-ui/react-dialog";
5 | import { Cross2Icon } from "@radix-ui/react-icons";
6 | import { cva, type VariantProps } from "class-variance-authority";
7 |
8 | import { cn } from "@/lib/utils";
9 |
10 | const Sheet = SheetPrimitive.Root;
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger;
13 |
14 | const SheetClose = SheetPrimitive.Close;
15 |
16 | const SheetPortal = SheetPrimitive.Portal;
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ));
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | );
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ));
75 | SheetContent.displayName = SheetPrimitive.Content.displayName;
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | );
89 | SheetHeader.displayName = "SheetHeader";
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | );
103 | SheetFooter.displayName = "SheetFooter";
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ));
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ));
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | };
141 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as LabelPrimitive from "@radix-ui/react-label";
3 | import { Slot } from "@radix-ui/react-slot";
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form";
12 |
13 | import { cn } from "@/lib/utils";
14 | import { Label } from "@/components/ui/label";
15 |
16 | const Form = FormProvider;
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName;
23 | };
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | );
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | );
40 | };
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext);
44 | const itemContext = React.useContext(FormItemContext);
45 | const { getFieldState, formState } = useFormContext();
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState);
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ");
51 | }
52 |
53 | const { id } = itemContext;
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | };
63 | };
64 |
65 | type FormItemContextValue = {
66 | id: string;
67 | };
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | );
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId();
78 |
79 | return (
80 |
81 |
82 |
83 | );
84 | });
85 | FormItem.displayName = "FormItem";
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField();
92 |
93 | return (
94 |
100 | );
101 | });
102 | FormLabel.displayName = "FormLabel";
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } =
109 | useFormField();
110 |
111 | return (
112 |
123 | );
124 | });
125 | FormControl.displayName = "FormControl";
126 |
127 | const FormDescription = React.forwardRef<
128 | HTMLParagraphElement,
129 | React.HTMLAttributes
130 | >(({ className, ...props }, ref) => {
131 | const { formDescriptionId } = useFormField();
132 |
133 | return (
134 |
140 | );
141 | });
142 | FormDescription.displayName = "FormDescription";
143 |
144 | const FormMessage = React.forwardRef<
145 | HTMLParagraphElement,
146 | React.HTMLAttributes
147 | >(({ className, children, ...props }, ref) => {
148 | const { error, formMessageId } = useFormField();
149 | const body = error ? String(error?.message) : children;
150 |
151 | if (!body) {
152 | return null;
153 | }
154 |
155 | return (
156 |
162 | {body}
163 |
164 | );
165 | });
166 | FormMessage.displayName = "FormMessage";
167 |
168 | export {
169 | useFormField,
170 | Form,
171 | FormItem,
172 | FormLabel,
173 | FormControl,
174 | FormDescription,
175 | FormMessage,
176 | FormField,
177 | };
178 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { buttonVariants } from "@/components/ui/button";
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root;
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal;
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ));
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | );
60 | AlertDialogHeader.displayName = "AlertDialogHeader";
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | );
74 | AlertDialogFooter.displayName = "AlertDialogFooter";
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ));
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName;
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ));
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | };
142 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/new/components/create-log.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { createLog } from "@/app/actions/logs.actions";
3 | import BackButton from "@/components/ui/back-button";
4 | import { Button } from "@/components/ui/button";
5 | import { Checkbox } from "@/components/ui/checkbox";
6 | import {
7 | Form,
8 | FormControl,
9 | FormDescription,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "@/components/ui/form";
15 | import { Input } from "@/components/ui/input";
16 | import Loader from "@/components/ui/loader";
17 | import {
18 | getCurrentDate,
19 | getCurrentFormattedTime,
20 | getCurrentTime,
21 | } from "@/utils/date";
22 | import { zodResolver } from "@hookform/resolvers/zod";
23 | import { useRouter } from "next/navigation";
24 | import { useState } from "react";
25 | import { useForm } from "react-hook-form";
26 | import { toast } from "sonner";
27 | import { z } from "zod";
28 |
29 | const formSchema = z.object({
30 | location: z.string().min(1, {
31 | message: "This is a required field.",
32 | }),
33 | timeOff: z.string().min(1, {
34 | message: "This is a required field.",
35 | }),
36 | });
37 |
38 | type Props = {
39 | user_id: string;
40 | };
41 |
42 | function CreateLogForm({ ...props }: Props) {
43 | const router = useRouter();
44 | const [isSubmitting, setIsSubmitting] = useState(false);
45 | const [currentTime, setCurrentTime] = useState(false);
46 | const date = getCurrentDate();
47 | const time = getCurrentTime();
48 | const formattedTime = getCurrentFormattedTime();
49 |
50 | const form = useForm>({
51 | resolver: zodResolver(formSchema),
52 | defaultValues: {
53 | location: "",
54 | timeOff: "",
55 | },
56 | });
57 |
58 | const handleCheckboxChange = () => {
59 | const newUseCurrentTime = !currentTime;
60 | setCurrentTime(newUseCurrentTime);
61 | if (newUseCurrentTime) {
62 | form.setValue("timeOff", formattedTime);
63 | }
64 | };
65 |
66 | async function onSubmit(values: z.infer) {
67 | const payload = { ...values, timeBackOn: "0", userId: props.user_id };
68 | setIsSubmitting(true);
69 | try {
70 | await createLog(payload);
71 | form.reset();
72 | toast.success("Log Created!");
73 | router.push("/?page=1");
74 | } catch (e) {
75 | toast.error("Failed to create log");
76 | console.error((e as Error)?.message);
77 | } finally {
78 | setIsSubmitting(false);
79 | }
80 | }
81 | return (
82 |
83 |
84 |
85 |
Record power outage
86 |
87 |
{date}
◦
88 |
{time}
89 |
90 |
91 |
154 |
155 |
156 | );
157 | }
158 |
159 | export default CreateLogForm;
160 |
--------------------------------------------------------------------------------
/src/app/(system)/logs/update/[id]/components/update-log.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import BackButton from "@/components/ui/back-button";
3 | import { Button } from "@/components/ui/button";
4 | import { Checkbox } from "@/components/ui/checkbox";
5 | import {
6 | Form,
7 | FormControl,
8 | FormField,
9 | FormItem,
10 | FormLabel,
11 | } from "@/components/ui/form";
12 | import { Input } from "@/components/ui/input";
13 | import {
14 | getCurrentDate,
15 | getCurrentFormattedTime,
16 | getCurrentTime,
17 | } from "@/utils/date";
18 | import { zodResolver } from "@hookform/resolvers/zod";
19 | import { useForm } from "react-hook-form";
20 | import { z } from "zod";
21 | import { toast } from "sonner";
22 | import { useRouter } from "next/navigation";
23 | import { useState } from "react";
24 | import { updateLogs } from "@/app/actions/logs.actions";
25 | import Loader from "@/components/ui/loader";
26 |
27 | const formSchema = z.object({
28 | location: z.string().min(1, {
29 | message: "This is a required field.",
30 | }),
31 | timeOff: z.string().min(1, {
32 | message: "This is a required field.",
33 | }),
34 | timeBackOn: z.string().min(1, {
35 | message: "This is a required field.",
36 | }),
37 | });
38 |
39 | type Props = {
40 | id: string;
41 | location: string;
42 | timeBackOn: any;
43 | timeOff: any;
44 | };
45 |
46 | function UpdateLogForm({ id, location, timeOff, timeBackOn }: Props) {
47 | const router = useRouter();
48 | const [isSubmitting, setIsSubmitting] = useState(false);
49 | const [currentTime, setCurrentTime] = useState(false);
50 | const date = getCurrentDate();
51 | const time = getCurrentTime();
52 | const formattedTime = getCurrentFormattedTime();
53 |
54 | const form = useForm>({
55 | resolver: zodResolver(formSchema),
56 | defaultValues: {
57 | location: location,
58 | timeOff: timeOff,
59 | timeBackOn: timeBackOn,
60 | },
61 | });
62 |
63 | const handleCheckboxChange = () => {
64 | const newUseCurrentTime = !currentTime;
65 | setCurrentTime(newUseCurrentTime);
66 | if (newUseCurrentTime) {
67 | form.setValue("timeBackOn", formattedTime);
68 | }
69 | };
70 |
71 | async function onSubmit(values: z.infer) {
72 | const payload = { values, id };
73 | setIsSubmitting(true);
74 | try {
75 | await updateLogs(payload);
76 | toast.success("Log Updated!");
77 | router.push("/?page=1");
78 | } catch (e) {
79 | toast.error("Failed to update log");
80 | console.error((e as Error)?.message);
81 | } finally {
82 | setIsSubmitting(false);
83 | }
84 | }
85 | return (
86 |
87 |
88 |
89 |
Update power outage
90 |
91 |
{date}
◦
92 |
{time}
93 |
94 |
95 |
173 |
174 |
175 | );
176 | }
177 |
178 | export default UpdateLogForm;
179 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons";
10 |
11 | import { cn } from "@/lib/utils";
12 |
13 | const DropdownMenu = DropdownMenuPrimitive.Root;
14 |
15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
16 |
17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
18 |
19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
20 |
21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
22 |
23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
24 |
25 | const DropdownMenuSubTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef & {
28 | inset?: boolean;
29 | }
30 | >(({ className, inset, children, ...props }, ref) => (
31 |
40 | {children}
41 |
42 |
43 | ));
44 | DropdownMenuSubTrigger.displayName =
45 | DropdownMenuPrimitive.SubTrigger.displayName;
46 |
47 | const DropdownMenuSubContent = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, ...props }, ref) => (
51 |
59 | ));
60 | DropdownMenuSubContent.displayName =
61 | DropdownMenuPrimitive.SubContent.displayName;
62 |
63 | const DropdownMenuContent = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, sideOffset = 4, ...props }, ref) => (
67 |
68 |
78 |
79 | ));
80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
81 |
82 | const DropdownMenuItem = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef & {
85 | inset?: boolean;
86 | }
87 | >(({ className, inset, ...props }, ref) => (
88 |
97 | ));
98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
99 |
100 | const DropdownMenuCheckboxItem = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, children, checked, ...props }, ref) => (
104 |
113 |
114 |
115 |
116 |
117 |
118 | {children}
119 |
120 | ));
121 | DropdownMenuCheckboxItem.displayName =
122 | DropdownMenuPrimitive.CheckboxItem.displayName;
123 |
124 | const DropdownMenuRadioItem = React.forwardRef<
125 | React.ElementRef,
126 | React.ComponentPropsWithoutRef
127 | >(({ className, children, ...props }, ref) => (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | ));
144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
145 |
146 | const DropdownMenuLabel = React.forwardRef<
147 | React.ElementRef,
148 | React.ComponentPropsWithoutRef & {
149 | inset?: boolean;
150 | }
151 | >(({ className, inset, ...props }, ref) => (
152 |
161 | ));
162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
163 |
164 | const DropdownMenuSeparator = React.forwardRef<
165 | React.ElementRef,
166 | React.ComponentPropsWithoutRef
167 | >(({ className, ...props }, ref) => (
168 |
173 | ));
174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
175 |
176 | const DropdownMenuShortcut = ({
177 | className,
178 | ...props
179 | }: React.HTMLAttributes) => {
180 | return (
181 |
185 | );
186 | };
187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
188 |
189 | export {
190 | DropdownMenu,
191 | DropdownMenuTrigger,
192 | DropdownMenuContent,
193 | DropdownMenuItem,
194 | DropdownMenuCheckboxItem,
195 | DropdownMenuRadioItem,
196 | DropdownMenuLabel,
197 | DropdownMenuSeparator,
198 | DropdownMenuShortcut,
199 | DropdownMenuGroup,
200 | DropdownMenuPortal,
201 | DropdownMenuSub,
202 | DropdownMenuSubContent,
203 | DropdownMenuSubTrigger,
204 | DropdownMenuRadioGroup,
205 | };
206 |
--------------------------------------------------------------------------------