diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 5d1588da..43aab81e 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -39,6 +39,32 @@ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", diff --git a/package-lock.json b/package-lock.json index 299e5124..5087174b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@iconify/react": "^6.0.2", + "@tanstack/react-query": "^5.101.0", "styled-components": "^6.4.2" }, "devDependencies": { @@ -48,6 +49,32 @@ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", diff --git a/package.json b/package.json index e3647331..f0843252 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@iconify/react": "^6.0.2", + "@tanstack/react-query": "^5.101.0", "styled-components": "^6.4.2" }, "devDependencies": { diff --git a/web/.deprecated/app/components/login/Login.tsx b/web/.deprecated/app/components/login/Login.tsx index c2947563..db663671 100644 --- a/web/.deprecated/app/components/login/Login.tsx +++ b/web/.deprecated/app/components/login/Login.tsx @@ -16,7 +16,7 @@ import { GoogleBtnContainer, FormWrapper, } from "./Login.styles"; -import Notification from "../otherComponents/helpers/Notification"; +import Notification from "../../../../components/helpers/notification/Notification"; const Login = () => { const router = useRouter(); diff --git a/web/.deprecated/app/components/otherComponents/helpers/Notification.tsx b/web/.deprecated/app/components/otherComponents/helpers/Notification.tsx deleted file mode 100644 index 3b75c726..00000000 --- a/web/.deprecated/app/components/otherComponents/helpers/Notification.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; -import React from "react"; -import { styled } from "styled-components"; - -const Notification = ({ msg, type }: { msg: string; type: string }) => { - return {msg}; -}; - -const NotificationBox = styled.span<{ type: string }>` - padding: 8px 10px; - background: #fff; - position: fixed; - top: 15px; - z-index: 99999; - display: flex; - align-items: center; - border-radius: 5px; - - color: ${({ type }) => (type === "error" ? "#E63751" : "rgba(0, 224, 158)")}; -`; -export default Notification; diff --git a/web/.deprecated/app/components/signup/Signup.tsx b/web/.deprecated/app/components/signup/Signup.tsx index 4befc35c..dfb2fa20 100644 --- a/web/.deprecated/app/components/signup/Signup.tsx +++ b/web/.deprecated/app/components/signup/Signup.tsx @@ -16,7 +16,7 @@ import { } from "./Signup.styles"; import { useState, ChangeEvent } from "react"; import { useRouter } from "next/navigation"; -import Notification from "../otherComponents/helpers/Notification"; +import Notification from "../../../../components/helpers/notification/Notification"; import axios from "axios"; //import { useAuth } from "@/contexts/signupContext"; //import { signupAPI } from "@/apis/signup"; diff --git a/web/.env.local.example b/web/.env.local.example new file mode 100644 index 00000000..12827403 --- /dev/null +++ b/web/.env.local.example @@ -0,0 +1,12 @@ +# NextAuth Configuration +NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32 +NEXTAUTH_URL=http://localhost:3000 + +# Google OAuth Configuration +# Get these from https://console.cloud.google.com/ +NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id-here +GOOGLE_CLIENT_SECRET=your-google-client-secret-here + +# Backend Configuration +# Points to your Envoy proxy that routes to gRPC backend +NEXT_PUBLIC_ENVOY_BASE_URL=http://localhost:8081 diff --git a/web/.gitignore b/web/.gitignore index 5ef6a520..4f9d8d2b 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -32,7 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* - +todo # vercel .vercel diff --git a/web/app/api/auth/[...nextauth]/route.ts b/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..86c9f3da --- /dev/null +++ b/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/web/app/api/dashboard/stats/route.ts b/web/app/api/dashboard/stats/route.ts new file mode 100644 index 00000000..2402024e --- /dev/null +++ b/web/app/api/dashboard/stats/route.ts @@ -0,0 +1,17 @@ + +import { NextRequest, NextResponse } from "next/server"; +import { StatCardData } from "@/components/types/stats.types"; + +export async function GET(req: NextRequest) { + const range = req.nextUrl.searchParams.get("range") ?? "today"; + + const stats: StatCardData[] = [ + { id: "web-views", label: "Web Views", value: 658000, change: 3.05, format: "compact" }, + { id: "unique-visitors", label: "Unique Visitors", value: 246000, change: -8.36, format: "compact" }, + { id: "new-users", label: "New Users", value: 1352, change: 2.65, format: "number" }, + { id: "page-views", label: "Page view", value: 438, change: -0.37, format: "number" }, + { id: "active-users", label: "Active Users", value: 294000, change: 5.12, format: "compact" }, + ]; + + return NextResponse.json(stats); +} \ No newline at end of file diff --git a/web/app/api/dashboard/total-users/route.ts b/web/app/api/dashboard/total-users/route.ts new file mode 100644 index 00000000..9b07300e --- /dev/null +++ b/web/app/api/dashboard/total-users/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { TotalUsersResponse } from "@/components/types/totalUsers.types"; + +export async function GET(req: NextRequest) { + const range = req.nextUrl.searchParams.get("range") ?? "today"; + + const labels = ["Mon", "Tue", "Wed", "Thur", "Fri", "Sat", "Sun"]; + + const points = labels.map((label, i) => ({ + label, + current: Math.round(1000000 + i * 1800000 + Math.random() * 800000), + previous: Math.round(1500000 + i * 1600000 + Math.random() * 600000), + })); + + const response: TotalUsersResponse = { + points, + latest: { label: "Sat", value: points[5].current }, + }; + + return NextResponse.json(response); +} \ No newline at end of file diff --git a/web/app/dashboard/dashboard.styles.ts b/web/app/dashboard/dashboard.styles.ts new file mode 100644 index 00000000..cfb65599 --- /dev/null +++ b/web/app/dashboard/dashboard.styles.ts @@ -0,0 +1,15 @@ +import styled from "styled-components"; +import { theme } from "@/components/libs/theme"; + +export const Container = styled.section` +width: 100%; +display: flex; +flex-wrap: wrap; +background: red; +color: white; +word-break: break-word; +overflow-wrap: anywhere; +padding: 1rem; +overflow-wrap: anywhere; + +` \ No newline at end of file diff --git a/web/app/dashboard/layout.tsx b/web/app/dashboard/layout.tsx new file mode 100644 index 00000000..94f1bd68 --- /dev/null +++ b/web/app/dashboard/layout.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { SessionProvider } from "next-auth/react"; +import { ThemeProvider } from "styled-components"; +import { AppProvider } from "@/components/contexts/AppContext"; +import { QueryProvider } from "@/components/contexts/QueryProvider"; +import MenuBar from "@/components/layout/menuBar/MenuBar"; +import DashboardHeader from "@/components/layout/header/DashboardHeader"; +import { darkTheme } from "@/components/libs/theme2"; +import styled from "styled-components"; +import { Icon } from "@iconify/react"; +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { menudata } from "@/components/constants/menuBar.data"; + +const DashboardLayoutWrapper = styled.div` + display: flex; + width: 100%; + height: 100vh; + overflow: hidden; + background-color: ${(props) => props.theme.colors.surface.main}; + transition: ${(props) => props.theme.transitions.themeShift}; + position: relative; +`; + +const MainContentCanvas = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + height: 100%; + width: 0; + overflow: hidden; +`; + +const ScrollableDashboardBody = styled.main` +width: 100%; +min-width: 0; + flex-grow: 1; + overflow-y: auto; + overflow-x: hidden; + padding: ${(props) => props.theme.spacing.xl}; + padding-bottom: 100px; + + @media (max-width: 768px) { + padding: ${(props) => props.theme.spacing.lg}; + padding-bottom: 90px; + } +`; + +const MobileBottomNavBar = styled.nav` + display: none; + position: fixed; + bottom: 0; + left: 0; + width: 100%; + height: 65px; + background: ${(props) => props.theme.colors.surface.sidebar}; + border-top: 1px solid ${(props) => props.theme.colors.border.subtle}; + justify-content: space-around; + align-items: center; + z-index: 99; + + @media (max-width: 768px) { + display: flex; + } +`; + +const BottomNavItem = styled(Link)<{ $active: boolean }>` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: ${({ $active, theme }) => ($active ? theme.colors.brand : theme.colors.text.muted)}; + font-size: 24px; + transition: ${(props) => props.theme.transitions.default}; +`; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const pathname = usePathname(); + + return ( + + + + + + setIsMobileMenuOpen(false)} /> + + + setIsMobileMenuOpen(!isMobileMenuOpen)} /> + + {children} + + + + + {menudata.slice(0, 4).map((item) => ( + + + + ))} + + + + + + + ); +} \ No newline at end of file diff --git a/web/app/dashboard/page.tsx b/web/app/dashboard/page.tsx new file mode 100644 index 00000000..6867cdf9 --- /dev/null +++ b/web/app/dashboard/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useDateRange } from "@/components/hooks/useDateRange"; +import DateRangeFilter from "@/components/pages/dashboard/DateRangeFilter"; +import StatsOverview from "@/components/pages/dashboard/StatsOverview"; +import TotalUsersChart from "@/components/pages/dashboard/TotalUsersChart"; +import { ChartsRow } from "@/components/pages/dashboard/ChartsLayout.styles"; + +export default function Dashboard() { + const dateRange = useDateRange(); + + return ( +
+ + + + + + +
+ ); +} \ No newline at end of file diff --git a/web/app/globals.css b/web/app/globals.css index cfca0596..d1fbe5e5 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -54,4 +54,11 @@ svg { html, body { overflow-x: hidden; max-width: 100%; + width: 100%; + margin: 0; + box-sizing: border-box; +} + +*, *::before, *::after { + box-sizing: border-box; } \ No newline at end of file diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 4accaf43..7c92c7f8 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Poppins } from "next/font/google"; import "./globals.css"; import StyledComponentsRegistry from "@/components/libs/registry"; -import LayoutShell from "@/components/layout/LayoutShell"; const poppins = Poppins({ variable: "--font-poppins", @@ -13,19 +12,15 @@ const poppins = Poppins({ export const metadata: Metadata = { title: "Upstat", description: "The Upstat Project", - viewport: { - width: "device-width", - initialScale: 1, - maximumScale: 1, - }, }; + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + {children} diff --git a/web/app/login/Login.styles.ts b/web/app/login/Login.styles.ts new file mode 100644 index 00000000..afa66135 --- /dev/null +++ b/web/app/login/Login.styles.ts @@ -0,0 +1,3 @@ +import styled from "styled-components"; +import { SignupContainer, FormHeading, FormSection, GoogleBtn } from '../signup/Signup.styles' +export { SignupContainer, FormHeading, FormSection, GoogleBtn }; \ No newline at end of file diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx new file mode 100644 index 00000000..f8d20ec8 --- /dev/null +++ b/web/app/login/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { ThemeProvider } from "styled-components"; +import { darkTheme } from "@/components/libs/theme2"; +import { Icon } from "@iconify/react"; +import Image from "next/image"; +import women from "../../components/assets/images/women.png"; +import Link from "next/link"; +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Notification from "@/components/helpers/notification/Notification"; +import { + SignupContainer, + FormSection, + FormHeading, + GoogleBtn, +} from "./Login.styles"; + +export default function SignupPage() { + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleSignup = async (): Promise => { + if (loading) return; + setLoading(true); + setError(""); + try { + const result = await signIn("google", { + redirect: false, + callbackUrl: "/dashboard", + }); + + if (result?.error) { + setError(result.error || "Authentication failed"); + setLoading(false); + return; + } + + if (result?.ok) { + router.push("/dashboard"); + } else { + setError("Unexpected error during authentication"); + setLoading(false); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Google login failed"; + setError(errorMessage); + setLoading(false); + } + }; + + return ( + + + + women-talking + + + +

Continue with Upstat

+

+ Don't have an account? +  Sign up +

+ {error !== "" && } +
+ + + + {loading ? "Connecting..." : "Continue with Google"} + +
+
+
+ ); +} \ No newline at end of file diff --git a/web/app/not-found.tsx b/web/app/not-found.tsx new file mode 100644 index 00000000..b5bbf9dd --- /dev/null +++ b/web/app/not-found.tsx @@ -0,0 +1,17 @@ +"use client"; + +import {SignupContainer, + TextContainer, + BorderedText +} from '../app/not-found/not-found.styles' + +export default function NotFound() { + return ( + + + 404 +  Page Not Found + + + ); +} \ No newline at end of file diff --git a/web/app/not-found/not-found.styles.ts b/web/app/not-found/not-found.styles.ts new file mode 100644 index 00000000..37c1641a --- /dev/null +++ b/web/app/not-found/not-found.styles.ts @@ -0,0 +1,28 @@ +import styled from "styled-components"; +import { theme } from "@/components/libs/theme"; + +export const SignupContainer = styled.section` + display: flex; + color: ${theme.colors.text}; + background: ${theme.colors.background}; + width: 100%; + height: 100vh; +`; + +export const TextContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: ${theme.spacing.md}; + font-family: ${theme.fonts.family}; + font-size: ${theme.fonts.sizes.md}; + margin: auto; +`; + +export const BorderedText = styled.p` + display: flex; + padding-right: ${theme.spacing.md}; + border-right: 1px solid ${theme.colors.white.light}; + font-size: ${theme.fonts.sizes.lg}; +`; \ No newline at end of file diff --git a/web/app/signup/Signup.styles.ts b/web/app/signup/Signup.styles.ts new file mode 100644 index 00000000..e9430998 --- /dev/null +++ b/web/app/signup/Signup.styles.ts @@ -0,0 +1,144 @@ +import styled from "styled-components"; + +export const SignupContainer = styled.section` + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background: ${(props) => props.theme.colors.surface.main}; + color: ${(props) => props.theme.colors.text.primary}; + width: 100%; + padding: 0 ${(props) => props.theme.spacing.md}; + transition: ${(props) => props.theme.transitions.themeShift}; + + img { + display: block; + max-width: 50%; + height: auto !important; + object-fit: contain; + } + + @media (max-width: 1024px) { + img { + max-width: 45%; + } + } + + @media (max-width: 768px) { + flex-direction: column; + padding: ${(props) => props.theme.spacing.lg} ${(props) => props.theme.spacing.md}; + justify-content: space-around; + + img { + max-width: 280px; + order: 2; + align-self: center !important; + } + } + + @media (max-width: 480px) { + justify-content: center; + gap: ${(props) => props.theme.spacing.xl}; + + img { + display: none; + } + } +`; + +export const FormSection = styled.div` + display: flex; + width: 40%; + flex-direction: column; + justify-content: center; + align-items: center; + gap: ${(props) => props.theme.spacing.lg}; + + @media (max-width: 1024px) { + width: 50%; + } + + @media (max-width: 768px) { + width: 100%; + order: 1; + } +`; + +export const FormHeading = styled.div` + display: flex; + flex-direction: column; + gap: ${(props) => props.theme.spacing.xs}; + text-align: center; + font-family: ${(props) => props.theme.typography.fontFamily}; + + h1 { + font-weight: ${(props) => props.theme.typography.weights.bold}; + font-size: ${(props) => props.theme.typography.sizes.display}; + } + + p { + font-weight: ${(props) => props.theme.typography.weights.regular}; + font-size: ${(props) => props.theme.typography.sizes.base}; + color: ${(props) => props.theme.colors.text.muted}; + } + + a { + color: ${(props) => props.theme.colors.brand}; + font-weight: ${(props) => props.theme.typography.weights.medium}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + @media (max-width: 480px) { + h1 { + font-size: ${(props) => props.theme.typography.sizes.xl}; + } + p { + font-size: ${(props) => props.theme.typography.sizes.sm}; + } + } +`; + +export const GoogleBtn = styled.button` + gap: ${(props) => props.theme.spacing.sm}; + font-family: ${(props) => props.theme.typography.fontFamily}; + font-size: ${(props) => props.theme.typography.sizes.lg}; + font-weight: ${(props) => props.theme.typography.weights.regular}; + + /* Adapts high-contrast button styling cleanly across mode themes */ + color: ${(props) => props.theme.colors.text.primary}; + background: transparent; + display: flex; + justify-content: center; + align-items: center; + padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.md}; + border: 1px solid ${(props) => props.theme.colors.text.primary}; + border-radius: ${(props) => props.theme.borderRadius.sm}; + cursor: pointer; + transition: ${(props) => props.theme.transitions.default}; + width: 100%; + max-width: 320px; + + &:hover { + background: ${(props) => props.theme.isDark ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.05)"}; + } + + &:active { + transform: scale(0.98); + background: ${(props) => props.theme.isDark ? "rgba(255, 255, 255, 0.15)" : "rgba(0, 0, 0, 0.1)"}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + @media (max-width: 480px) { + font-size: ${(props) => props.theme.typography.sizes.base}; + padding: ${(props) => props.theme.spacing.sm}; + } +`; \ No newline at end of file diff --git a/web/app/signup/page.tsx b/web/app/signup/page.tsx new file mode 100644 index 00000000..4f358a11 --- /dev/null +++ b/web/app/signup/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { ThemeProvider } from "styled-components"; +import { darkTheme } from "@/components/libs/theme2"; +import { Icon } from "@iconify/react"; +import Image from "next/image"; +import women from "../../components/assets/images/women.png"; +import Link from "next/link"; +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Notification from "@/components/helpers/notification/Notification"; +import { + SignupContainer, + FormSection, + FormHeading, + GoogleBtn, +} from "./Signup.styles"; + +export default function SignupPage() { + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleSignup = async (): Promise => { + if (loading) return; + setLoading(true); + setError(""); + try { + const result = await signIn("google", { + redirect: false, + callbackUrl: "/dashboard", + }); + + if (result?.error) { + setError(result.error || "Authentication failed"); + setLoading(false); + return; + } + + if (result?.ok) { + router.push("/dashboard"); + } else { + setError("Unexpected error during authentication"); + setLoading(false); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Google signup failed"; + setError(errorMessage); + setLoading(false); + } + }; + + return ( + + + women-talking + + + +

Sign up with Upstat

+

+ Have an account? +  Login +

+ {error !== "" && } +
+ + + + {loading ? "Connecting..." : "Continue with Google"} + +
+
+
+ ); +} \ No newline at end of file diff --git a/web/auth.ts b/web/auth.ts new file mode 100644 index 00000000..e9869c94 --- /dev/null +++ b/web/auth.ts @@ -0,0 +1,53 @@ +import NextAuth from "next-auth"; +import GoogleProvider from "next-auth/providers/google"; +import { authenticateWithBackend } from "@/lib/auth-service"; + +export const { auth, handlers, signIn, signOut } = NextAuth({ + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID || "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", + }), + ], + callbacks: { + async signIn({ user, account }) { + if (account?.provider === "google" && account.id_token) { + try { + const backendUser = await authenticateWithBackend(account.id_token); + if (backendUser && backendUser.token) { + user.id = backendUser.id; + user.email = backendUser.email; + user.name = backendUser.name; + (user as any).backendToken = backendUser.token; + return true; + } + return false; + } catch (error) { + console.error("Backend authentication failed:", error); + return false; + } + } + return true; + }, + async jwt({ token, user }) { + if (user) { + token.backendToken = (user as any).backendToken; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + (session.user as any).backendToken = token.backendToken; + } + return session; + }, + }, + pages: { + signIn: "/login", + error: "/login", + }, + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, + }, +}); diff --git a/web/components/assets/images/women.png b/web/components/assets/images/women.png new file mode 100644 index 00000000..66eae91b Binary files /dev/null and b/web/components/assets/images/women.png differ diff --git a/web/components/assets/logos/upstat-vector.png b/web/components/assets/logos/upstat-vector.png new file mode 100644 index 00000000..f7ec5720 Binary files /dev/null and b/web/components/assets/logos/upstat-vector.png differ diff --git a/web/components/constants/dateRange.constants.ts b/web/components/constants/dateRange.constants.ts new file mode 100644 index 00000000..98b59055 --- /dev/null +++ b/web/components/constants/dateRange.constants.ts @@ -0,0 +1,9 @@ +import { DateRangeOption } from "@/components/types/dateRange.types"; + +export const dateRangeOptions: DateRangeOption[] = [ + { id: "today", label: "Today" }, + { id: "week", label: "This Week" }, + { id: "month", label: "This Month" }, + { id: "year", label: "This Year" }, + { id: "custom", label: "Custom" }, +]; \ No newline at end of file diff --git a/web/components/constants/menuBar.data.ts b/web/components/constants/menuBar.data.ts index 7c7675cf..3d62ffa6 100644 --- a/web/components/constants/menuBar.data.ts +++ b/web/components/constants/menuBar.data.ts @@ -5,7 +5,7 @@ export const menudata: menudataTypes = [ id: 0, icon: "material-symbols:dashboard", name: "Dashboard", - path:"/" + path:"/dashboard" }, { id: 1, diff --git a/web/components/contexts/AppContext.tsx b/web/components/contexts/AppContext.tsx new file mode 100644 index 00000000..0c6eafb2 --- /dev/null +++ b/web/components/contexts/AppContext.tsx @@ -0,0 +1,71 @@ +"use client"; + +import React, { createContext, useContext, useReducer } from "react"; +import { ThemeProvider } from "styled-components"; +import { darkTheme, lightTheme } from "@/components/libs/theme2"; + +interface UserProfile { + name: string; + role: string; + avatarUrl?: string; +} + +interface AppState { + isDarkMode: boolean; + user: UserProfile; +} + +type AppAction = + | { type: "TOGGLE_THEME" } + | { type: "SET_USER"; payload: Partial } + | { type: "RESET_USER" }; + +const initialState: AppState = { + isDarkMode: true, + user: { + name: "User", + role: "Job Role", + avatarUrl: "", + }, +}; + +function appReducer(state: AppState, action: AppAction): AppState { + switch (action.type) { + case "TOGGLE_THEME": + return { ...state, isDarkMode: !state.isDarkMode }; + case "SET_USER": + return { ...state, user: { ...state.user, ...action.payload } }; + case "RESET_USER": + return { ...state, user: initialState.user }; + default: + return state; + } +} + +interface AppContextType { + state: AppState; + dispatch: React.Dispatch; +} + +const AppContext = createContext(undefined); + +export function AppProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(appReducer, initialState); + const activeTheme = state.isDarkMode ? darkTheme : lightTheme; + + return ( + + + {children} + + + ); +} + +export function useApp() { + const context = useContext(AppContext); + if (!context) { + throw new Error("useApp must be used within an AppProvider"); + } + return context; +} \ No newline at end of file diff --git a/web/components/contexts/QueryProvider.tsx b/web/components/contexts/QueryProvider.tsx new file mode 100644 index 00000000..4048a754 --- /dev/null +++ b/web/components/contexts/QueryProvider.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +export function QueryProvider({ children }: { children: React.ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + retry: 1, + }, + }, + }) + ); + + return {children}; +} \ No newline at end of file diff --git a/web/components/helpers/notification/Notification.styles.ts b/web/components/helpers/notification/Notification.styles.ts new file mode 100644 index 00000000..f53b764b --- /dev/null +++ b/web/components/helpers/notification/Notification.styles.ts @@ -0,0 +1,37 @@ +import styled from "styled-components"; +import { theme } from "@/components/libs/theme"; + +export const NotificationBox = styled.span<{ type: "error" | "success" | "info" }>` + padding: ${theme.spacing.xs} ${theme.spacing.sm}; + background: ${theme.colors.backgroundSecondary}; + top: ${theme.spacing.sm}; + right: ${theme.spacing.sm}; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: ${theme.borderRadius.sm}; + border: 1px solid ${theme.colors.border}; + font-family: ${theme.fonts.family}; + font-size: ${theme.fonts.sizes.sm}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + width: auto; + max-width: calc(100% - 32px); + box-sizing: border-box; + + color: ${({ type }) => { + if (type === "error") return "#E63751"; + if (type === "success") return "rgba(0, 224, 158)"; + return theme.colors.text || "#ffffff"; + }}; + + @media (max-width: 480px) { + top: auto; + bottom: ${theme.spacing.sm}; + left: ${theme.spacing.sm}; + right: ${theme.spacing.sm}; + width: calc(100% - 32px); + max-width: none; + font-size: ${theme.fonts.sizes.md}; + padding: ${theme.spacing.sm}; + } +`; \ No newline at end of file diff --git a/web/components/helpers/notification/Notification.tsx b/web/components/helpers/notification/Notification.tsx new file mode 100644 index 00000000..92f2c153 --- /dev/null +++ b/web/components/helpers/notification/Notification.tsx @@ -0,0 +1,22 @@ +"use client"; +import React from "react"; +import { NotificationBox } from "./Notification.styles"; + +export interface NotificationProps { + msg?: string; + type?: "error" | "success" | "info"; + className?: string; + children?: React.ReactNode; +} + +const Notification = ({ msg, type = "info", className, children }: NotificationProps) => { + if (!msg && !children) return null; + + return ( + + {msg || children} + + ); +}; + +export default Notification; \ No newline at end of file diff --git a/web/components/hooks/useDateRange.ts b/web/components/hooks/useDateRange.ts new file mode 100644 index 00000000..49991b67 --- /dev/null +++ b/web/components/hooks/useDateRange.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useState } from "react"; +import { DateRangeId, CustomDateRange } from "@/components/types/dateRange.types"; +import { dateRangeOptions } from "@/components/constants/dateRange.constants"; + +export function useDateRange(initial: DateRangeId = "today") { + const [selected, setSelected] = useState(initial); + const [customRange, setCustomRange] = useState({ + from: null, + to: null, + }); + + const selectedOption = dateRangeOptions.find((opt) => opt.id === selected); + + return { + options: dateRangeOptions, + selected, + selectedOption, + setSelected, + customRange, + setCustomRange, + }; +} \ No newline at end of file diff --git a/web/components/hooks/useStatsOverview.ts b/web/components/hooks/useStatsOverview.ts new file mode 100644 index 00000000..7891d8e2 --- /dev/null +++ b/web/components/hooks/useStatsOverview.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { DateRangeId } from "@/components/types/dateRange.types"; +import { fetchStatsOverview } from "@/components/libs/api/stats.api"; + +export function useStatsOverview(range: DateRangeId) { + return useQuery({ + queryKey: ["dashboard-stats", range], + queryFn: () => fetchStatsOverview(range), + }); +} \ No newline at end of file diff --git a/web/components/hooks/useTotalUsers.ts b/web/components/hooks/useTotalUsers.ts new file mode 100644 index 00000000..92bca184 --- /dev/null +++ b/web/components/hooks/useTotalUsers.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { DateRangeId } from "@/components/types/dateRange.types"; +import { fetchTotalUsers } from "@/components/libs/api/totalUsers.api"; + +export function useTotalUsers(range: DateRangeId) { + return useQuery({ + queryKey: ["dashboard-total-users", range], + queryFn: () => fetchTotalUsers(range), + }); +} \ No newline at end of file diff --git a/web/components/layout/LayoutShell.tsx b/web/components/layout/LayoutShell.tsx index ef6a50a0..ea9b822f 100644 --- a/web/components/layout/LayoutShell.tsx +++ b/web/components/layout/LayoutShell.tsx @@ -2,7 +2,7 @@ import MenuBar from "./menuBar/MenuBar"; import { usePathname } from "next/navigation"; -const noMenuRoutes = ["/", "/login", "/signup"]; +const noMenuRoutes = ["/", "/login", "/signup", "/not-found"]; export default function LayoutShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); diff --git a/web/components/layout/header/DashboardHeader.styles.ts b/web/components/layout/header/DashboardHeader.styles.ts new file mode 100644 index 00000000..815bc844 --- /dev/null +++ b/web/components/layout/header/DashboardHeader.styles.ts @@ -0,0 +1,198 @@ +import styled from "styled-components"; + +export const HeaderContainer = styled.header` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 25px ${(props) => props.theme.spacing.section}; + + background: transparent; + border-bottom: 1px solid ${(props) => props.theme.colors.border.subtle}; + transition: ${(props) => props.theme.transitions.themeShift}; + gap: 20px; + + @media (max-width: 768px) { + flex-direction: column; + align-items: stretch; + padding: 20px; + gap: 16px; + background: ${(props) => props.theme.colors.surface.main}; + } +`; + +export const MobileHeaderTopRow = styled.div` + display: none; + + @media (max-width: 768px) { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } +`; + +export const HamburgerButton = styled.button` + display: none; + background: transparent; + border: none; + color: ${(props) => props.theme.colors.text.primary}; + font-size: 28px; + cursor: pointer; + + @media (max-width: 768px) { + display: block; + } +`; + +export const SearchWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + background: ${(props) => props.theme.colors.surface.sidebar}; + border: 0.5px solid ${(props) => props.theme.colors.border.subtle}; + border-radius: ${(props) => props.theme.borderRadius.md}; + padding: ${(props) => props.theme.spacing.md} ${(props) => props.theme.spacing.sm}; + flex-grow: 1; + gap: ${(props) => props.theme.spacing.lg}; + margin: 0 30px; + transition: ${(props) => props.theme.transitions.default}; + + &:focus-within { + border-color: ${(props) => props.theme.colors.brand}; + box-shadow: 0 0 0 1px ${(props) => props.theme.colors.brand}; + } + + svg { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.typography.sizes.lg}; + flex-shrink: 0; + } + + @media (max-width: 768px) { + margin: 0; + width: 100%; + } +`; + +export const SearchInput = styled.input` + background: transparent; + border: none; + outline: none; + width: 100%; + padding-left: ${(props) => props.theme.spacing.sm}; + color: ${(props) => props.theme.colors.text.primary}; + font-family: ${(props) => props.theme.typography.fontFamily}; + font-size: ${(props) => props.theme.typography.sizes.sm}; + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } +`; + +export const ActionControlsSection = styled.div` + display: flex; + flex-shrink: 0; + align-items: center; + gap: ${(props) => props.theme.spacing.xl}; + + @media (max-width: 768px) { + display: none; + } +`; + +export const MobileRightActions = styled.div` + display: flex; + align-items: center; + gap: 16px; + + svg { + font-size: 24px; + color: ${(props) => props.theme.colors.text.primary}; + } +`; + +export const UtilityButtonGroup = styled.div` + display: flex; + align-items: center; + gap: ${(props) => props.theme.spacing.lg}; + border-right: 2px solid ${(props) => props.theme.colors.border.subtle}; + border-left: 2px solid ${(props) => props.theme.colors.border.subtle}; + padding: 0px ${(props) => props.theme.spacing.lg}; + + svg { + color: ${(props) => props.theme.colors.text.primary}; + font-size: 22px; + cursor: pointer; + transition: ${(props) => props.theme.transitions.default}; + + &:hover { + color: ${(props) => props.theme.colors.brandAccent}; + transform: translateY(-1px); + } + } +`; + +export const ProfileMenuWrapper = styled.div` + display: flex; + align-items: center; + gap: ${(props) => props.theme.spacing.md}; + cursor: pointer; + user-select: none; + position: relative; + + img { + border-radius: ${(props) => props.theme.borderRadius.full}; + object-fit: cover; + background: ${(props) => props.theme.colors.border.subtle}; + } +`; + +export const MetaIdentityText = styled.div` + display: flex; + white-space: nowrap; + flex-direction: column; + font-family: ${(props) => props.theme.typography.fontFamily}; + + h4 { + color: ${(props) => props.theme.colors.text.primary}; + font-size: ${(props) => props.theme.typography.sizes.base}; + font-weight: ${(props) => props.theme.typography.weights.semibold}; + line-height: ${(props) => props.theme.typography.lineHeights.none}; + margin: 0 0 4px 0; + } + + p { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.typography.sizes.base}; + font-weight: ${(props) => props.theme.typography.weights.regular}; + line-height: ${(props) => props.theme.typography.lineHeights.none}; + margin: 0; + } +`; + +export const ArrowDropdownIcon = styled.div` + svg { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.typography.sizes.xl}; + transition: ${(props) => props.theme.transitions.default}; + } + + &:hover svg { + color: ${(props) => props.theme.colors.text.primary}; + } +`; + +export const FallbackAvatar = styled.div` + width: 40px; + height: 40px; + border-radius: ${(props) => props.theme.borderRadius.full}; + background: ${(props) => props.theme.colors.brand}; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-weight: ${(props) => props.theme.typography.weights.bold}; + font-size: ${(props) => props.theme.typography.sizes.sm}; + text-transform: uppercase; +`; \ No newline at end of file diff --git a/web/components/layout/header/DashboardHeader.tsx b/web/components/layout/header/DashboardHeader.tsx new file mode 100644 index 00000000..4dc6134f --- /dev/null +++ b/web/components/layout/header/DashboardHeader.tsx @@ -0,0 +1,104 @@ +"use client"; + +import React, { useState } from "react"; +import Image from "next/image"; +import { Icon } from "@iconify/react"; +import { useApp } from "@/components/contexts/AppContext"; +import { + HeaderContainer, + MobileHeaderTopRow, + HamburgerButton, + SearchWrapper, + SearchInput, + ActionControlsSection, + MobileRightActions, + UtilityButtonGroup, + ProfileMenuWrapper, + MetaIdentityText, + ArrowDropdownIcon, + FallbackAvatar, +} from "./DashboardHeader.styles"; + +interface DashboardHeaderProps { + onMenuToggle: () => void; +} + +export default function DashboardHeader({ onMenuToggle }: DashboardHeaderProps) { + const { state, dispatch } = useApp(); + const [searchQuery, setSearchQuery] = useState(""); + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + }; + + const userInitial = state.user.name.charAt(0) || "U"; + + // Reusable Identity rendering block + const identityBlock = ( + + {state.user.avatarUrl ? ( + {state.user.name} + ) : ( + {userInitial} + )} + + +

{state.user.name}

+

{state.user.role}

+
+
+ ); + + return ( + + {/* 1. MOBILE RESPONSIVE TOP ROW (Shows under 768px matching iPhone Mini mockups) */} + + {identityBlock} + + dispatch({ type: "TOGGLE_THEME" })} + /> + + + + + + + {/* 2. DYNAMIC SEARCH COMPONENT (Grows on desktop, full-width on mobile) */} + + + + + + {/* 3. DESKTOP ACTION SECTION (Hidden on mobile) */} + + + + dispatch({ type: "TOGGLE_THEME" })} + /> + + +
+ {identityBlock} + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/web/components/layout/menuBar/MenuBar.styles.ts b/web/components/layout/menuBar/MenuBar.styles.ts index 7125b712..433a010f 100644 --- a/web/components/layout/menuBar/MenuBar.styles.ts +++ b/web/components/layout/menuBar/MenuBar.styles.ts @@ -1,91 +1,155 @@ import { styled } from "styled-components"; import Link from "next/link"; -const MenuBarContainer = styled.section` - color: #fff; - background: #3c3c3c; +export const MenuBarContainer = styled.section<{ $isMobileOpen: boolean; $isOpen: boolean }>` + color: ${(props) => props.theme.colors.text.primary}; + background: ${(props) => props.theme.colors.surface.sidebar}; display: flex; flex-direction: column; + height: 100vh; + flex-shrink: 0; + width: ${({ $isOpen }) => ($isOpen ? "260px" : "78px")}; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s ease; + overflow: hidden; + border-right: 1px solid ${(props) => props.theme.colors.border.subtle}; + z-index: 100; + + @media (max-width: 768px) { + position: fixed; + left: 0; + top: 0; + width: 280px; + transform: ${({ $isMobileOpen }) => ($isMobileOpen ? "translateX(0)" : "translateX(-100%)")}; + box-shadow: ${({ $isMobileOpen }) => ($isMobileOpen ? "10px 0 30px rgba(0,0,0,0.25)" : "none")}; + } `; -const HeadSection = styled.section` +export const HeadSection = styled.div<{ $isOpen: boolean }>` display: flex; - gap: 24px; align-items: center; - padding: 40px 16px 32px; + //justify-content: ${({ $isOpen }) => ($isOpen ? 'space-between' : 'center')}; + justify-content: space-between; + //flex-direction: ${({ $isOpen }) => ($isOpen ? 'row' : 'column')}; + flex-direction: row; + gap: ${({ $isOpen }) => ($isOpen ? '0' : '12px')}; + padding: 35px 20px; + width: 100%; + + svg, img { + flex-shrink: 0; + } - span { - font-size: 20px; - font-weight: 600; + span { + font-size: ${(props) => props.theme.typography.sizes.lg}; + font-weight: ${(props) => props.theme.typography.weights.semibold}; + white-space: nowrap; } - svg:hover { - transform: scale(1.1); + svg { + transition: ${(props) => props.theme.transitions.default}; + font-size: 20px; + &:hover { + transform: scale(1.1); + } + + @media (max-width: 768px) { +display: none; +`; + +export const ScrollableMenuContent = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 0 ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.lg}; + + &::-webkit-scrollbar { + display: none; } + -ms-overflow-style: none; + scrollbar-width: none; `; -const MenuSection = styled.section` +export const MenuSection = styled.section` display: flex; flex-direction: column; - gap: 16px; - padding: 20px; - border-top: 1px solid #fff; + gap: ${(props) => props.theme.spacing.sm}; + padding: ${(props) => props.theme.spacing.sm} 0; + border-top: 1px solid ${(props) => props.theme.colors.border.subtle}; + flex-shrink: 0; `; -const MenuTitle = styled.p` - color: #fff; - opacity: 0.5; - text-align: center; - font-weight: 700; +export const MenuTitle = styled.p` + color: ${(props) => props.theme.colors.text.muted}; + text-align: left; + font-weight: ${(props) => props.theme.typography.weights.bold}; + font-size: ${(props) => props.theme.typography.sizes.sm}; + padding-left: 12px; + white-space: nowrap; + overflow: hidden; `; -const MenuItem = styled(Link)<{ isActive: boolean }>` +export const MenuItem = styled(Link)<{ $isActive: boolean }>` display: flex; align-items: center; - gap: 16px; + gap: ${(props) => props.theme.spacing.md}; justify-content: start; - padding: 15px 30px; - border-radius: 10px; - background: ${({ isActive }) => (isActive ? "rgba(0, 224, 158, 0.62)" : "")}; - + padding: 15px 12px; + border-radius: ${(props) => props.theme.borderRadius.md}; + background: ${({ $isActive, theme }) => ($isActive ? theme.colors.menu.itemActive : "transparent")}; + transition: ${(props) => props.theme.transitions.default}; + white-space: nowrap; + &:hover { - background: rgba(0, 224, 158, 0.62); + background: ${(props) => props.theme.colors.menu.itemHover}; } p, svg { - color: white; + color: ${(props) => props.theme.colors.text.primary}; white-space: nowrap; + flex-shrink: 0; + } + + svg { + font-size: 20px; } `; -const LogoutItem = styled.button` - background: rgba(0, 224, 158, 0.62); - justify-content: center; +export const LogoutItem = styled.button` + background: ${(props) => props.theme.colors.surface.actionable || "#e11d48"}; display: flex; align-items: center; + justify-content: center; gap: 16px; border: none; outline: none; - padding: 15px 30px; - border-radius: 10px; + padding: 15px; + width: 100%; + border-radius: ${(props) => props.theme.borderRadius.md}; + cursor: pointer; + transition: ${(props) => props.theme.transitions.default}; &:hover { - background: rgba(0, 224, 158, 0.62); + filter: brightness(1.1); } - p, svg { - color: white; - white-space: nowrap; + color: #ffffff; + font-size: 20px; + flex-shrink: 0; } `; -export { - MenuBarContainer, - MenuSection, - HeadSection, - LogoutItem, - MenuTitle, - MenuItem, -}; \ No newline at end of file +export const BackdropOverlay = styled.div<{ $visible: boolean }>` + display: ${({ $visible }) => ($visible ? "block" : "none")}; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: 90; +`; \ No newline at end of file diff --git a/web/components/layout/menuBar/MenuBar.tsx b/web/components/layout/menuBar/MenuBar.tsx index d2d7fd54..35095fe9 100644 --- a/web/components/layout/menuBar/MenuBar.tsx +++ b/web/components/layout/menuBar/MenuBar.tsx @@ -1,74 +1,90 @@ "use client"; + import Image from "next/image"; -import logo from '../../assets/logos/upstat-green.png' +import logo from '../../assets/logos/upstat-vector.png'; import { Icon } from "@iconify/react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { usePathname } from "next/navigation"; import { MenuBarContainer, HeadSection, + ScrollableMenuContent, MenuSection, LogoutItem, MenuTitle, MenuItem, + BackdropOverlay } from "./MenuBar.styles"; - import { accountData, menudata } from "@/components/constants/menuBar.data"; import { useRouter } from "next/navigation"; -const MenuBar = () => { - const [isOpen, setIsOpen] = useState(false); - const pathname = usePathname(); +interface MenuBarProps { + isMobileOpen: boolean; + closeMobileMenu: () => void; +} +const MenuBar = ({ isMobileOpen, closeMobileMenu }: MenuBarProps) => { + const [isOpen, setIsOpen] = useState(true); + const pathname = usePathname(); const router = useRouter(); - const menuJsx = menudata.map((el) => ( - - - {isOpen &&

{el.name}

} -
- )); - - const accountJsx = accountData.map((el) => ( - - - {isOpen &&

{el.name}

} -
- )); + useEffect(() => { + closeMobileMenu(); + }, [pathname]); const handleLogout = () => { localStorage.removeItem("token"); localStorage.removeItem("user"); router.push("/login"); }; + return ( - - - logo - {isOpen && Upstat} - setIsOpen(!isOpen)} - style={{ cursor: "pointer" }} - /> - + <> + + + + +
+ logo + {(isOpen || isMobileOpen) && Upstat} +
+ setIsOpen(!isOpen)} + style={{ cursor: "pointer", flexShrink: 0 }} + /> +
- - MENU - {menuJsx} - + + + {(isOpen || isMobileOpen) ? "MENU" : "•"} + {menudata.map((el) => ( + + + {(isOpen || isMobileOpen) &&

{el.name}

} +
+ ))} +
- - ACCOUNTS - {accountJsx} - + + {(isOpen || isMobileOpen) ? "ACCOUNTS" : "•"} + {accountData.map((el) => ( + + + {(isOpen || isMobileOpen) &&

{el.name}

} +
+ ))} +
- - - - - -
+ + + + {/* {(isOpen || isMobileOpen) &&

Log out

} */} +
+
+ +
+ ); }; diff --git a/web/components/libs/api.ts b/web/components/libs/api.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/web/components/libs/api/stats.api.ts b/web/components/libs/api/stats.api.ts new file mode 100644 index 00000000..e429a372 --- /dev/null +++ b/web/components/libs/api/stats.api.ts @@ -0,0 +1,14 @@ +import { DateRangeId } from "@/components/types/dateRange.types"; +import { StatCardData } from "@/components/types/stats.types"; + +export async function fetchStatsOverview( + range: DateRangeId +): Promise { + const res = await fetch(`/api/dashboard/stats?range=${range}`); + + if (!res.ok) { + throw new Error("Failed to fetch stats overview"); + } + + return res.json(); +} \ No newline at end of file diff --git a/web/components/libs/api/totalUsers.api.ts b/web/components/libs/api/totalUsers.api.ts new file mode 100644 index 00000000..c51046ab --- /dev/null +++ b/web/components/libs/api/totalUsers.api.ts @@ -0,0 +1,12 @@ +import { DateRangeId } from "@/components/types/dateRange.types"; +import { TotalUsersResponse } from "@/components/types/totalUsers.types"; + +export async function fetchTotalUsers(range: DateRangeId): Promise { + const res = await fetch(`/api/dashboard/total-users?range=${range}`); + + if (!res.ok) { + throw new Error("Failed to fetch total users chart data"); + } + + return res.json(); +} \ No newline at end of file diff --git a/web/components/libs/theme.ts b/web/components/libs/theme.ts index 055bd4af..af8346b7 100644 --- a/web/components/libs/theme.ts +++ b/web/components/libs/theme.ts @@ -7,6 +7,7 @@ export const theme = { text: "#ffffff", textMuted: "rgba(255, 255, 255, 0.5)", border: "#3c3c3c", + red: "#E63751", green: { light: "#e6f6f4", diff --git a/web/components/libs/theme2.ts b/web/components/libs/theme2.ts new file mode 100644 index 00000000..d4a6a520 --- /dev/null +++ b/web/components/libs/theme2.ts @@ -0,0 +1,165 @@ +const coreScales = { + typography: { + fontFamily: "'Poppins', sans-serif", + sizes: { + xs: "0.75rem", + sm: "0.875rem", + base: "1rem", + lg: "1.125rem", + xl: "1.25rem", + xxl: "1.5rem", + display: "2rem", + hero: "3.5rem", + }, + weights: { + regular: 400, + medium: 500, + semibold: 600, + bold: 700, + }, + lineHeights: { + none: 1, + tight: 1.25, + snug: 1.375, + normal: 1.5, + relaxed: 1.625, + } + }, + + spacing: { + none: "0px", + xs: "4px", + sm: "8px", + md: "12px", + lg: "16px", + xl: "24px", + xxl: "32px", + layout: "48px", + section: "64px", + }, + + borderRadius: { + none: "0px", + sm: "4px", + md: "8px", + lg: "12px", + xl: "16px", + full: "9999px", + }, + + transitions: { + default: "all 0.2s ease-in-out", + themeShift: "background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease", + }, +}; + +const palette = { + primary: "#00A991", + accent: "#00e5a0", + red: "#E63751", + + green: { + light: "#e6f6f4", + lightHover: "#d9f2ef", + lightActive: "#b0e4dd", + normal: "#00a991", + normalHover: "#009883", + normalActive: "#008774", + dark: "#007f6d", + darkHover: "#006557", + darkActive: "#004c41", + darker: "#003b33", + }, + + white: { + light: "#ffffff", + lightHover: "#ffffff", + lightActive: "#ffffff", + normal: "#ffffff", + normalHover: "#e6e6e6", + normalActive: "#cccccc", + dark: "#bfbfbf", + darkHover: "#999999", + darkActive: "#737373", + darker: "#595959", + }, + + grey: { + light: "#ececec", + lightHover: "#e2e2e2", + lightActive: "#c3c3c3", + normal: "#3c3c3c", + normalHover: "#363636", + normalActive: "#303030", + dark: "#2d2d2d", + darkHover: "#242424", + darkActive: "#1b1b1b", + darker: "#151515", + }, +}; + +export const darkTheme = { + ...coreScales, + isDark: true, + colors: { + brand: palette.primary, + brandAccent: palette.accent, + error: palette.red, + + surface: { + main: "#16151C", + sidebar: "#1E1D26", + card: "#1E1D26", + actionable: palette.green.normal, + }, + + text: { + primary: "#ffffff", + secondary: "rgba(255, 255, 255, 0.7)", + muted: "rgba(255, 255, 255, 0.5)", + }, + + border: { + subtle: "#3c3c3c", + }, + + menu: { + itemActive: palette.green.darkActive, + itemHover: palette.green.darkHover, + } + }, +}; + +export const lightTheme = { + ...coreScales, + isDark: false, + colors: { + brand: palette.primary, + brandAccent: palette.accent, + error: palette.red, + + surface: { + main: "#F9FAFB", + sidebar: "#ffffff", + card: "#ffffff", + actionable: palette.green.normal, + }, + + text: { + primary: "#16151C", + secondary: "#4B5563", + muted: "#9CA3AF", + }, + + border: { + subtle: "#E5E7EB", + }, + + menu: { + itemActive: palette.green.lightActive, + itemHover: palette.green.lightHover, + } + }, +}; + +export type AppTheme = typeof darkTheme; \ No newline at end of file diff --git a/web/components/pages/dashboard/ChartsLayout.styles.ts b/web/components/pages/dashboard/ChartsLayout.styles.ts new file mode 100644 index 00000000..627ad98c --- /dev/null +++ b/web/components/pages/dashboard/ChartsLayout.styles.ts @@ -0,0 +1,21 @@ +import styled from "styled-components"; + +export const ChartsRow = styled.div` + display: flex; + gap: ${(props) => props.theme.spacing.xl}; + align-items: flex-start; + + @media (max-width: 1024px) { + flex-direction: column; + } +`; + +export const SecondaryRow = styled.div` + display: flex; + gap: ${(props) => props.theme.spacing.xl}; + margin-top: ${(props) => props.theme.spacing.xl}; + + @media (max-width: 1024px) { + flex-direction: column; + } +`; \ No newline at end of file diff --git a/web/components/pages/dashboard/DateRangeFilter/DateRangeFilter.styles.ts b/web/components/pages/dashboard/DateRangeFilter/DateRangeFilter.styles.ts new file mode 100644 index 00000000..578253dd --- /dev/null +++ b/web/components/pages/dashboard/DateRangeFilter/DateRangeFilter.styles.ts @@ -0,0 +1,74 @@ +import styled from "styled-components"; + +export const Wrapper = styled.div` + position: relative; + display: inline-block; +`; + +export const Trigger = styled.button` + display: flex; + align-items: center; + gap: ${(props) => props.theme.spacing.sm}; + background: ${(props) => props.theme.colors.surface.card}; + border: 1px solid ${(props) => props.theme.colors.border.subtle}; + border-radius: ${(props) => props.theme.borderRadius.md}; + padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.lg}; + color: ${(props) => props.theme.colors.text.primary}; + font-family: ${(props) => props.theme.typography.fontFamily}; + font-size: ${(props) => props.theme.typography.sizes.sm}; + font-weight: ${(props) => props.theme.typography.weights.medium}; + cursor: pointer; + transition: ${(props) => props.theme.transitions.default}; + + &:hover { + border-color: ${(props) => props.theme.colors.brand}; + } + + @media (max-width: 480px) { + padding: ${(props) => props.theme.spacing.xs} ${(props) => props.theme.spacing.md}; + font-size: ${(props) => props.theme.typography.sizes.xs}; + } +`; + +export const ChevronIcon = styled.span<{ $isOpen: boolean }>` + display: flex; + transition: ${(props) => props.theme.transitions.default}; + transform: rotate(${({ $isOpen }) => ($isOpen ? "180deg" : "0deg")}); +`; + +export const Menu = styled.ul` + position: absolute; + top: calc(100% + ${(props) => props.theme.spacing.xs}); + left: 0; + min-width: 160px; + background: ${(props) => props.theme.colors.surface.card}; + border: 1px solid ${(props) => props.theme.colors.border.subtle}; + border-radius: ${(props) => props.theme.borderRadius.md}; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + padding: ${(props) => props.theme.spacing.xs}; + list-style: none; + z-index: 20; + + @media (max-width: 480px) { + min-width: 140px; + left: auto; + right: 0; + } +`; + +export const MenuItem = styled.li<{ $active: boolean }>` + padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.md}; + border-radius: ${(props) => props.theme.borderRadius.sm}; + font-size: ${(props) => props.theme.typography.sizes.sm}; + color: ${({ $active, theme }) => + $active ? theme.colors.brand : theme.colors.text.secondary}; + background: ${({ $active, theme }) => + $active ? theme.colors.menu.itemActive : "transparent"}; + cursor: pointer; + transition: ${(props) => props.theme.transitions.default}; + + &:hover { + background: ${(props) => props.theme.colors.menu.itemHover}; + color: ${(props) => props.theme.colors.text.primary}; + } +`; \ No newline at end of file diff --git a/web/components/pages/dashboard/DateRangeFilter/DateRangeFilter.tsx b/web/components/pages/dashboard/DateRangeFilter/DateRangeFilter.tsx new file mode 100644 index 00000000..c4853d22 --- /dev/null +++ b/web/components/pages/dashboard/DateRangeFilter/DateRangeFilter.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import { Icon } from "@iconify/react"; +import { DateRangeId } from "@/components/types/dateRange.types"; +import { useDateRange } from "@/components/hooks/useDateRange"; +import { + Wrapper, + Trigger, + ChevronIcon, + Menu, + MenuItem, +} from "./DateRangeFilter.styles"; + +interface DateRangeFilterProps { + dateRange: ReturnType; +} + +export default function DateRangeFilter({ dateRange }: DateRangeFilterProps) { + const [isOpen, setIsOpen] = useState(false); + const wrapperRef = useRef(null); + const { options, selected, selectedOption, setSelected } = dateRange; + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + function handleSelect(id: DateRangeId) { + setSelected(id); + setIsOpen(false); + // todo: include date picker modal for "custom" option + + } + + return ( + + setIsOpen((prev) => !prev)} aria-haspopup="listbox" aria-expanded={isOpen}> + + {selectedOption?.label} + + + + + + {isOpen && ( + + {options.map((option) => ( + handleSelect(option.id)} + > + {option.label} + + ))} + + )} + + ); +} \ No newline at end of file diff --git a/web/components/pages/dashboard/DateRangeFilter/index.ts b/web/components/pages/dashboard/DateRangeFilter/index.ts new file mode 100644 index 00000000..59f5fe77 --- /dev/null +++ b/web/components/pages/dashboard/DateRangeFilter/index.ts @@ -0,0 +1 @@ +export { default } from "./DateRangeFilter"; \ No newline at end of file diff --git a/web/components/pages/dashboard/StatsOverview/StatsOverview.styles.ts b/web/components/pages/dashboard/StatsOverview/StatsOverview.styles.ts new file mode 100644 index 00000000..b892ceea --- /dev/null +++ b/web/components/pages/dashboard/StatsOverview/StatsOverview.styles.ts @@ -0,0 +1,95 @@ +import styled from "styled-components"; + +export const Row = styled.div` + display: flex; + flex-direction: row; + width: 100%; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + margin: ${(props) => props.theme.spacing.xl} 0; + @media (max-width: 480px) { + gap: 16px; + } +`; + +export const Card = styled.div` +display: flex; +flex-direction: column; +gap: ${(props) => props.theme.spacing.md}; +padding: ${(props) => props.theme.spacing.xl} ${(props) => props.theme.spacing.lg}; +height: 110px; +justify-content: space-between; +flex: 1 1 201px; +border-radius: 10px; +background: ${(props) => props.theme.colors.surface.card}; + + @media (max-width: 480px) { + flex: 1 1 calc(50% - 8px); + min-width: 0; + height: 100px; + padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.md}; + } +`; + + +export const Label = styled.span` + font-family: ${(props) => props.theme.typography.fontFamily}; + font-weight: ${(props) => props.theme.typography.weights.medium}; + font-size: ${(props) => props.theme.typography.sizes.base}; + line-height: 100%; + color: ${(props) => props.theme.colors.text.primary}; + + @media (max-width: 480px) { + font-size: ${(props) => props.theme.typography.sizes.sm}; + } +`; + +export const ValueRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${(props) => props.theme.spacing.sm}; +`; + +export const Value = styled.span` + font-family: ${(props) => props.theme.typography.fontFamily}; + font-weight: ${(props) => props.theme.typography.weights.semibold}; + font-size: ${(props) => props.theme.typography.sizes.xl}; + line-height: 100%; + color: ${(props) => props.theme.colors.text.primary}; + + @media (max-width: 480px) { + font-size: ${(props) => props.theme.typography.sizes.lg}; + } +`; + +export const Change = styled.span<{ $positive: boolean }>` + display: flex; + align-items: center; + gap: 2px; + font-family: ${(props) => props.theme.typography.fontFamily}; + font-weight: ${(props) => props.theme.typography.weights.medium}; + font-size: ${(props) => props.theme.typography.sizes.base}; + line-height: 100%; + color: ${({ $positive, theme }) => + $positive ? theme.colors.brand : theme.colors.error}; + + @media (max-width: 480px) { + font-size: ${(props) => props.theme.typography.sizes.sm}; + } +`; + +export const SkeletonBlock = styled.div<{ $height: string; $width?: string }>` + height: ${({ $height }) => $height}; + width: ${({ $width }) => $width ?? "100%"}; + border-radius: ${(props) => props.theme.borderRadius.sm}; + background: ${(props) => props.theme.colors.border.subtle}; + opacity: 0.4; + animation: pulse 1.5s ease-in-out infinite; + + @keyframes pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.7; } + } +`; \ No newline at end of file diff --git a/web/components/pages/dashboard/StatsOverview/StatsOverview.tsx b/web/components/pages/dashboard/StatsOverview/StatsOverview.tsx new file mode 100644 index 00000000..afcad6e1 --- /dev/null +++ b/web/components/pages/dashboard/StatsOverview/StatsOverview.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Icon } from "@iconify/react"; +import { DateRangeId } from "@/components/types/dateRange.types"; +import { useStatsOverview } from "@/components/hooks/useStatsOverview"; +import { formatCompactNumber, formatSignedPercent } from "@/components/utils/formatNumber"; +import { + Row, + Card, + Label, + ValueRow, + Value, + Change, + SkeletonBlock, +} from "./StatsOverview.styles"; + +interface StatsOverviewProps { + range: DateRangeId; +} + +export default function StatsOverview({ range }: StatsOverviewProps) { + const { data, isLoading, isError } = useStatsOverview(range); + + if (isLoading) { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + + + + + ))} + + ); + } + + if (isError || !data) { + return ; + } + + return ( + + {data.map((stat) => { + const isPositive = stat.change >= 0; + const displayValue = + stat.format === "compact" ? formatCompactNumber(stat.value) : stat.value.toLocaleString(); + + return ( + + + + {displayValue} + + {formatSignedPercent(stat.change)} + + + + + ); + })} + + ); +} \ No newline at end of file diff --git a/web/components/pages/dashboard/StatsOverview/index.ts b/web/components/pages/dashboard/StatsOverview/index.ts new file mode 100644 index 00000000..cd0539d6 --- /dev/null +++ b/web/components/pages/dashboard/StatsOverview/index.ts @@ -0,0 +1 @@ +export { default } from "./StatsOverview"; \ No newline at end of file diff --git a/web/components/pages/dashboard/TotalUsersChart/TotalUsersChart.styles.ts b/web/components/pages/dashboard/TotalUsersChart/TotalUsersChart.styles.ts new file mode 100644 index 00000000..dcbda6f9 --- /dev/null +++ b/web/components/pages/dashboard/TotalUsersChart/TotalUsersChart.styles.ts @@ -0,0 +1,56 @@ +import styled from "styled-components"; + +export const Card = styled.div` +flex: 1 1 65%; + background: ${(props) => props.theme.colors.surface.card}; + border: 1px solid ${(props) => props.theme.colors.border.subtle}; + border-radius: ${(props) => props.theme.borderRadius.lg}; + padding: ${(props) => props.theme.spacing.xl}; + min-width: 0; + + @media (max-width: 1024px) { + flex: 1 1 100%; + } +`; + +export const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: ${(props) => props.theme.spacing.lg}; +`; + +export const Title = styled.h3` + font-family: ${(props) => props.theme.typography.fontFamily}; + font-weight: ${(props) => props.theme.typography.weights.semibold}; + font-size: ${(props) => props.theme.typography.sizes.lg}; + color: ${(props) => props.theme.colors.text.primary}; + margin: 0; +`; + +export const Legend = styled.div` + display: flex; + align-items: center; + gap: ${(props) => props.theme.spacing.sm}; + font-size: ${(props) => props.theme.typography.sizes.sm}; + color: ${(props) => props.theme.colors.text.secondary}; +`; + +export const ChartWrapper = styled.div` + width: 100%; + height: 320px; + + @media (max-width: 480px) { + height: 240px; + } +`; + +export const TooltipBadge = styled.div` + background: ${(props) => props.theme.colors.brand}; + color: ${(props) => props.theme.colors.text.primary}; + font-size: ${(props) => props.theme.typography.sizes.xs}; + font-weight: ${(props) => props.theme.typography.weights.medium}; + padding: 4px 10px; + border-radius: ${(props) => props.theme.borderRadius.full}; + white-space: nowrap; +`; \ No newline at end of file diff --git a/web/components/pages/dashboard/TotalUsersChart/TotalUsersChart.tsx b/web/components/pages/dashboard/TotalUsersChart/TotalUsersChart.tsx new file mode 100644 index 00000000..1e79ce56 --- /dev/null +++ b/web/components/pages/dashboard/TotalUsersChart/TotalUsersChart.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useTheme } from "styled-components"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import { DateRangeId } from "@/components/types/dateRange.types"; +import { useTotalUsers } from "@/components/hooks/useTotalUsers"; +import { formatCompactNumber } from "@/components/utils/formatNumber"; +import { + Card, + Header, + Title, + Legend, + ChartWrapper, + TooltipBadge, +} from "./TotalUsersChart.styles"; + +interface TotalUsersChartProps { + range: DateRangeId; +} + +export default function TotalUsersChart({ range }: TotalUsersChartProps) { + const theme = useTheme(); + const { data, isLoading, isError } = useTotalUsers(range); + + if (isLoading) { + return ( + +
+ Total Users +
+ +
+ ); + } + + if (isError || !data) { + return ( + + Couldn't load chart data. + + ); + } + + return ( + +
+ Total Users + Current Views +
+ + + + + + + formatCompactNumber(value)} + /> + { + if (!active || !payload?.length) return null; + return ( + + {label} {formatCompactNumber(payload[0].value as number)} + + ); + }} + /> + + + + + +
+ ); +} \ No newline at end of file diff --git a/web/components/pages/dashboard/TotalUsersChart/index.ts b/web/components/pages/dashboard/TotalUsersChart/index.ts new file mode 100644 index 00000000..84acd2fe --- /dev/null +++ b/web/components/pages/dashboard/TotalUsersChart/index.ts @@ -0,0 +1 @@ +export { default } from "./TotalUsersChart"; \ No newline at end of file diff --git a/web/components/types/dashboardHeader.types.ts b/web/components/types/dashboardHeader.types.ts new file mode 100644 index 00000000..f5189616 --- /dev/null +++ b/web/components/types/dashboardHeader.types.ts @@ -0,0 +1,10 @@ +export interface UserData { + name: string; + role: string; + avatarUrl: string; +} + +export interface HeaderProps { + onThemeToggle: () => void; + isDarkMode: boolean; +} \ No newline at end of file diff --git a/web/components/types/dateRange.types.ts b/web/components/types/dateRange.types.ts new file mode 100644 index 00000000..9a14b317 --- /dev/null +++ b/web/components/types/dateRange.types.ts @@ -0,0 +1,11 @@ +export type DateRangeId = "today" | "week" | "month" | "year" | "custom"; + +export interface DateRangeOption { + id: DateRangeId; + label: string; +} + +export interface CustomDateRange { + from: Date | null; + to: Date | null; +} \ No newline at end of file diff --git a/web/components/types/stats.types.ts b/web/components/types/stats.types.ts new file mode 100644 index 00000000..a0b74756 --- /dev/null +++ b/web/components/types/stats.types.ts @@ -0,0 +1,8 @@ + +export interface StatCardData { + id: string; + label: string; + value: number; + change: number; + format?: "number" | "compact"; +} \ No newline at end of file diff --git a/web/components/types/styled.d.ts b/web/components/types/styled.d.ts index dda47b65..d4d1a40c 100644 --- a/web/components/types/styled.d.ts +++ b/web/components/types/styled.d.ts @@ -1,5 +1,6 @@ import "styled-components"; -import { AppTheme } from "@/libs/theme"; + +import { AppTheme } from "@/components/libs/theme2"; declare module "styled-components" { export interface DefaultTheme extends AppTheme {} diff --git a/web/components/types/totalUsers.types.ts b/web/components/types/totalUsers.types.ts new file mode 100644 index 00000000..8fd703dc --- /dev/null +++ b/web/components/types/totalUsers.types.ts @@ -0,0 +1,14 @@ + +export interface TotalUsersPoint { + label: string; + current: number; + previous: number; +}; + +export interface TotalUsersResponse { + points: TotalUsersPoint[]; + latest: { + label: string; + value: number; + }; +} \ No newline at end of file diff --git a/web/components/utils/formatNumber.ts b/web/components/utils/formatNumber.ts new file mode 100644 index 00000000..166a681b --- /dev/null +++ b/web/components/utils/formatNumber.ts @@ -0,0 +1,8 @@ +export function formatCompactNumber(value: number): string { + return new Intl.NumberFormat("en-US", { notation: "compact" }).format(value); +} + +export function formatSignedPercent(value: number): string { + const sign = value > 0 ? "+" : ""; + return `${sign}${value.toFixed(2)}%`; +} \ No newline at end of file diff --git a/web/lib/auth-service.ts b/web/lib/auth-service.ts new file mode 100644 index 00000000..b9fe1f3d --- /dev/null +++ b/web/lib/auth-service.ts @@ -0,0 +1,40 @@ +const ENVOY_BASE_URL = process.env.NEXT_PUBLIC_ENVOY_BASE_URL || "http://localhost:8081"; + +interface GoogleAuthResponse { + id: string; + name: string; + email: string; + token: string; + status: string; + data: string; +} + +/** + * Calls the backend's GoogleAuth RPC endpoint via Envoy + * Sends the Google id_token and receives user data + backend token + */ +export async function authenticateWithBackend(idToken: string): Promise { + try { + const response = await fetch(`${ENVOY_BASE_URL}/proto.UserService/GoogleAuth`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-User-Agent": "grpc-web-javascript/0.1", + }, + body: JSON.stringify({ + id_token: idToken, + }), + }); + + if (!response.ok) { + console.error(`Backend auth failed with status ${response.status}`); + return null; + } + + const data: GoogleAuthResponse = await response.json(); + return data; + } catch (error) { + console.error("Error calling backend auth service:", error); + return null; + } +} diff --git a/web/next.config.ts b/web/next.config.ts index e9ffa308..d2dfc5ff 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -2,6 +2,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + compiler: { + // This forces class names to match on both SSR and client hydration + styledComponents: true, + }, }; export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json index eafc6e92..d983ea63 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,9 +8,15 @@ "name": "upstat", "version": "0.1.0", "dependencies": { + "@iconify/react": "^4.1.1", + "@tanstack/react-query": "^5.101.0", + "google-protobuf": "^3.21.2", "next": "16.2.6", + "next-auth": "^4.24.14", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "recharts": "^3.8.1", + "styled-components": "^6.1.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -228,6 +234,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -309,6 +324,21 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -519,6 +549,27 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/react": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@iconify/react/-/react-4.1.1.tgz", + "integrity": "sha512-jed14EjvKjee8mc0eoscGxlg7mSQRkwQG3iX3cPBCO7UlOjz0DtlvTqxqEcHUJGh+z1VJ31Yhu5B9PxfO0zbdg==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1246,6 +1297,51 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1253,6 +1349,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1533,6 +1641,32 @@ "tailwindcss": "4.3.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1544,6 +1678,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1579,7 +1776,7 @@ "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1595,6 +1792,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", @@ -2626,6 +2829,15 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -2669,6 +2881,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2703,6 +2924,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2718,13 +2948,153 @@ "node": ">= 8" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2804,6 +3174,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3090,6 +3466,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3519,6 +3905,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3834,6 +4226,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3975,6 +4373,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4017,6 +4425,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4474,6 +4891,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5076,6 +5502,38 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.14", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz", + "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5133,6 +5591,12 @@ "node": ">=18" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5143,6 +5607,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5256,6 +5729,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5422,6 +5937,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5432,6 +5975,12 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5500,9 +6049,76 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5547,6 +6163,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -6069,6 +6691,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/styled-components": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.2.tgz", + "integrity": "sha512-xZBhBJsMtGqb+aKcwKgaT+BtuFums9VynX2JRvXJGTx5UfZzN12rk5r4nVdhXYvRw+hE7yiYxVrOqJZaK2+Txg==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.4.0", + "css-to-react-native": "3.2.0", + "csstype": "3.2.3", + "stylis": "4.3.6" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "css-to-react-native": ">= 3.2.0", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0", + "react-native": ">= 0.68.0" + }, + "peerDependenciesMeta": { + "css-to-react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6092,6 +6750,12 @@ } } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6139,6 +6803,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6479,6 +7149,47 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index 0fe4852a..34eedef4 100644 --- a/web/package.json +++ b/web/package.json @@ -9,9 +9,15 @@ "lint": "eslint" }, "dependencies": { + "@iconify/react": "^4.1.1", + "@tanstack/react-query": "^5.101.0", + "google-protobuf": "^3.21.2", "next": "16.2.6", + "next-auth": "^4.24.14", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "recharts": "^3.8.1", + "styled-components": "^6.1.0" }, "devDependencies": { "@tailwindcss/postcss": "^4",