initial code

This commit is contained in:
2026-01-09 13:17:13 +07:00
commit d56d1c193b
68 changed files with 6529 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>smart-home</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4396
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "smart-home",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"host": "vite --host",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-router": "^1.144.0",
"@tanstack/react-router-devtools": "^1.144.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"framer-motion": "^12.24.0",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/crypto-js": "^4.2.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

65
src/App.css Normal file
View File

@@ -0,0 +1,65 @@
@keyframes ringStep1 {
0% {
border-color: transparent;
}
25%,
100% {
border-color: rgba(255, 255, 255, 0.4);
}
}
@keyframes ringStep2 {
0%,
25% {
border-color: transparent;
}
50%,
100% {
border-color: rgba(255, 255, 255, 0.4);
}
}
@keyframes ringStep3 {
0%,
50% {
border-color: transparent;
}
75%,
100% {
border-color: rgba(255, 255, 255, 0.4);
}
}
/* Chrome, Edge, Safari */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.25);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.4);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.25) transparent;
}
.scrollbar-none {
-ms-overflow-style: none; /* IE & Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-none::-webkit-scrollbar {
display: none; /* Chrome, Safari */
}

22
src/App.tsx Normal file
View File

@@ -0,0 +1,22 @@
import "./App.css";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
export default App;

BIN
src/assets/images/auto.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
src/assets/images/fan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
src/assets/images/room.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
src/assets/images/swing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,11 @@
import { Outlet } from '@tanstack/react-router'
export default function AuthLayout() {
return (
<div className="auth-layout">
<main>
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Outlet } from "@tanstack/react-router";
import BottomNavigation from "../ui/bottom-navigation/bottom-navigation";
export default function MainNavigationLayout() {
return (
<div className="relative h-screen w-full overflow-hidden">
<Outlet />
<BottomNavigation />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from "@tanstack/react-router";
export default function MainLayout() {
return (
<div className="relative h-screen w-full overflow-hidden">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { ChevronLeft } from "lucide-react";
import { useRouter } from "@tanstack/react-router";
import { cn } from "../../utils/classname";
interface NavigationLayoutProps {
children: React.ReactNode;
isBack?: boolean;
backText?: string;
backClassName?: string;
className?: string;
mainClassName?: string;
title?: string | React.ReactNode;
isBottomNav?: boolean;
action?: React.ReactNode;
bgImage?: string;
}
export default function NavigationLayout({
children,
isBack,
className,
backClassName,
isBottomNav,
title,
action,
mainClassName,
bgImage,
backText = 'back'
}: NavigationLayoutProps) {
const router = useRouter();
return (
<div
className={cn(
"bg-linear-to-b from-gray-1 to-gray-2 h-full flex flex-col relative overflow-y-auto",
className,
bgImage && 'bg-cover bg-top-left'
)}
style={{
...(bgImage ? { backgroundImage: `url(${bgImage})` } : {})
}}
>
{ bgImage && <div className="absolute left-0 top-0 h-full w-full bg-dark-1 opacity-20"/> }
{title && (
<div className="text-base font-bold text-neutral-900 w-full text-center p-4">
{title}
</div>
)}
<div className={cn('flex items-center justify-between relative w-full', title && 'absolute w-full left-0 top-0')}>
{isBack && (
<span
onClick={() => router.history.back()}
className={cn(
"flex justify-start shrink-0 items-center gap-2 text-base font-semibold cursor-pointer w-fit p-4",
backClassName
)}
>
<ChevronLeft size={24} />
{backText}
</span>
)}
{action && <div className="p-4">{action}</div>}
</div>
<main
className={cn(
"h-full relative pt-18",
isBottomNav && "overflow-y-auto",
mainClassName
)}
>
{children}
</main>
<div className="h-20 w-full opacity-0" />
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Link, useRouterState } from "@tanstack/react-router";
import { BarChart3, DoorOpen, Home, Settings } from "lucide-react";
import { cn } from "../../../utils/classname";
export default function BottomNavigation() {
const location = useRouterState({
select: (state) => state.location,
})
console.log(location.pathname)
const tabs = [
{ path: "/", icon: Home, label: "Dashboard" },
{ path: "/analysis", icon: BarChart3, label: "analysis" },
{ path: "/rooms", icon: DoorOpen, label: "Rooms" },
{ path: "/settings", icon: Settings, label: "Settings" },
];
return (
<div className="shrink-0 bg-white border-t border-gray-100 h-20 flex items-center w-full fixed bottom-0 left-0">
<div className="max-w-md mx-auto flex items-center justify-around w-full">
{tabs.map((tab, key) => (
<Link
key={key}
to={tab.path}
className={cn('flex-1 py-4 flex flex-col items-center gap-1 transition-colors text-neutral-300', tab.path === location.pathname && 'text-neutral-900')}
>
<tab.icon size={24} />
<span className="text-xs font-medium">{tab.label}</span>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { Link } from "@tanstack/react-router";
import { cn } from "../../../utils/classname";
import { ChevronRight } from "lucide-react";
interface CardProps {
children: React.ReactNode;
className?: string;
title?: string;
to?: string;
cardTo?: string;
}
export default function Card({
children,
className,
title,
to,
cardTo,
}: CardProps) {
const containerClassName = cn(
"bg-white rounded-2xl p-3 shadow-sm shadow-neutral-200 border border-gray-100",
className
);
const content = (
<>
<div className="flex justify-between items-center">
{title && (
<h1 className="text-sm font-bold text-neutral-800">{title}</h1>
)}
{to && (
<Link to={to}>
{" "}
<ChevronRight size={20} />{" "}
</Link>
)}
</div>
{children}
</>
);
if (cardTo) {
return (
<Link to={cardTo} className={containerClassName}>
{content}
</Link>
);
}
return <div className={containerClassName}>{content}</div>;
}

View File

@@ -0,0 +1,55 @@
interface ProgressBarProps {
title?: string;
subTitle?: string;
color?: string;
icon?: React.ReactNode;
value?: number;
className?: string;
}
export default function ProgressBar({
title,
subTitle,
color = "#ff6900",
icon,
className,
value = 0,
}: ProgressBarProps) {
return (
<div className={className}>
<div className="flex gap-4">
{icon && (
<div
className="shrink-0 h-12 w-12 rounded-lg flex justify-center items-center"
style={{
background: `${color}33`,
color: color
}}
>
{icon}
</div>
)}
<div className="w-full">
<div className="flex justify-between items-center">
{title && (
<h3 className="text-sm text-neutral-800 mb-2 font-medium">
{title}
</h3>
)}
{subTitle && (
<h3 className="text-sm text-neutral-800 font-semibold mb-2">
{subTitle}
</h3>
)}
</div>
<div className="bg-neutral-100 h-2.5 w-full rounded relative overflow-hidden">
<div
className="h-2.5 left-0 top-0"
style={{ background: color, width: `${value}%` }}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from 'react'
import { cn } from '../../../utils/classname'
interface SwitchProps {
checked?: boolean
defaultChecked?: boolean
onChange?: (checked: boolean) => void
disabled?: boolean
className?: string
}
export function Switch({
checked,
defaultChecked = false,
onChange,
disabled,
className,
}: SwitchProps) {
const [internal, setInternal] = React.useState(defaultChecked)
const isControlled = checked !== undefined
const value = isControlled ? checked : internal
const toggle = () => {
if (disabled) return
const next = !value
if (!isControlled) setInternal(next)
onChange?.(next)
}
return (
<button
type="button"
role="switch"
aria-checked={value}
disabled={disabled}
onClick={toggle}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
value ? 'bg-orange-500' : 'bg-gray-300',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
value ? 'translate-x-5' : 'translate-x-1'
)}
/>
</button>
)
}

5
src/constants/env.ts Normal file
View File

@@ -0,0 +1,5 @@
export const ENV = {
apiUrl: import.meta.env.VITE_API_URL,
apiKey: import.meta.env.VITE_SIGN_KEY,
secretKey: import.meta.env.VITE_SIGN_SECRET_KEY,
}

13
src/constants/images.ts Normal file
View File

@@ -0,0 +1,13 @@
import Sun from "../assets/images/contrast.png";
import Rooms from "../assets/images/room.avif";
import ModeAuto from "../assets/images/auto.png";
import ModeFan from "../assets/images/fan.png";
import ModeSwing from "../assets/images/swing.png";
export const IMAGES = {
Sun,
Rooms,
ModeAuto,
ModeFan,
ModeSwing
};

View File

@@ -0,0 +1,101 @@
import { motion } from "framer-motion";
import { useState } from "react";
import Card from "../../components/ui/card";
import { BarChart2, RefreshCwIcon, Thermometer } from "lucide-react";
import ProgressBar from "../../components/ui/progress-bar";
const tabs = [
{ id: 0, label: "January" },
{ id: 1, label: "February" },
{ id: 2, label: "March" },
{ id: 3, label: "April" },
{ id: 4, label: "May" },
{ id: 5, label: "June" },
{ id: 6, label: "July" },
{ id: 7, label: "August" },
{ id: 8, label: "September" },
{ id: 9, label: "October" },
{ id: 10, label: "November" },
{ id: 11, label: "December" },
];
export default function AnalysisFeature() {
const [active, setActive] = useState(0);
const handleChange = (index: number) => {
setActive(index);
};
return (
<div className="w-full">
{/* TABS */}
<div className="relative flex items-center overflow-y-auto gap-10 mx-4 scrollbar-none ">
{tabs.map((tab, i) => (
<button
key={tab.id}
onClick={() => handleChange(i)}
className="relative py-2 text-sm text-neutral-900"
>
{active === i && (
<motion.div
layoutId="tab-indicator"
className="absolute inset-x-0 bottom-0 h-0.5 bg-orange-500"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
)}
<span className={active === i ? "text-neut" : ""}>{tab.label}</span>
</button>
))}
</div>
<div className="relative p-4 mt-2">
<Card title="Consumed" to="/" className="mb-4">
<div className="mt-4 flex justify-start items-center gap-4">
<div className="h-12 w-12 flex items-center justify-center bg-neutral-100 rounded-full">
<BarChart2 size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900 mb-1">
385 kWh
</h2>
<p className="text-sm text-neutral-400">Costs: $22.31</p>
</div>
</div>
</Card>
<Card title="Compared the most" to="/" className="mb-4">
<ProgressBar
title="Most efficient homes"
subTitle="292 kWh"
className="mt-4 bg-ne"
color="#d4d4d4"
value={80}
/>
<ProgressBar title="Your Home" subTitle="292 kWh" className="mt-4" value={50} />
<ProgressBar
title="Average Homes"
subTitle="292 kWh"
className="mt-4 bg-ne"
color="#d4d4d4"
value={80}
/>
</Card>
<Card title="Compared to other" to="/" className="mb-4">
<ProgressBar
title="Always on"
subTitle="292 kWh"
className="mt-4"
color="#00c950"
value={80}
icon={<RefreshCwIcon />}
/>
<ProgressBar
title="Heating / Cooling"
subTitle="292 kWh"
className="mt-4"
value={90}
icon={<Thermometer />}
/>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
export const contentVariants = {
enter: (direction: number) => ({
x: direction > 0 ? "100%" : "-100%",
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction > 0 ? "-100%" : "100%",
opacity: 0,
}),
};

View File

@@ -0,0 +1,150 @@
import { useState } from "react";
import {
getDevices,
saveDevices,
isDuplicateDevice,
} from "../../../utils/storage";
import { useNavigate } from "@tanstack/react-router";
const initialState: DeviceData = {
towerNumber: "",
floorName: "",
unitNumber: "",
roomName: "",
deviceName: "",
deviceType: "",
code: "",
payload: "",
active: true,
status: false,
};
export default function AddDeviceFeature() {
const [form, setForm] = useState<DeviceData>(initialState);
const navigate = useNavigate();
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const devices = getDevices();
const deviceToSave: DeviceData = {
...form,
deviceLabel: form.deviceLabel || `${form.roomName} ${form.deviceName}`,
};
if (isDuplicateDevice(devices, deviceToSave)) {
return;
}
saveDevices([...devices, deviceToSave]);
setForm(initialState);
navigate({ to: '/' });
}
return (
<form onSubmit={handleSubmit} className="p-4">
<h2 className="text-lg font-semibold mb-4">Add Device</h2>
<div className="flex flex-col gap-4">
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Tower
</label>
<input
name="towerNumber"
value={form.towerNumber}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Floor
</label>
<input
name="floorName"
value={form.floorName}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Room
</label>
<input
name="roomName"
value={form.roomName}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Unit
</label>
<input
name="unitNumber"
value={form.unitNumber}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Device Name
</label>
<input
name="deviceName"
value={form.deviceName}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Device Type
</label>
<input
name="deviceType"
value={form.deviceType}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Device Label
</label>
<input
name="deviceLabel"
value={form.deviceLabel}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
</div>
<button
type="submit"
className="w-full bg-orange-500 text-white py-2 rounded-lg mt-8"
>
Save Device
</button>
</form>
);
}

View File

@@ -0,0 +1,39 @@
type Point = { x: number; y: number }
function distance(a: Point, b: Point) {
return Math.hypot(a.x - b.x, a.y - b.y)
}
export function generateUniquePoints(
count: number,
minRadius: number,
maxRadius: number,
minDistance: number
): Point[] {
const points: Point[] = []
let attempts = 0
while (points.length < count && attempts < 500) {
attempts++
const angle = Math.random() * Math.PI * 2
const r =
minRadius +
Math.sqrt(Math.random()) * (maxRadius - minRadius)
const point = {
x: Math.cos(angle) * r,
y: Math.sin(angle) * r,
}
const isTooClose = points.some(
(p) => distance(p, point) < minDistance
)
if (!isTooClose) {
points.push(point)
}
}
return points
}

View File

@@ -0,0 +1,42 @@
import { Calendar, LineChart, MoreHorizontal, Timer } from "lucide-react";
export default function Configs() {
const configList = [
{
id: 1,
icon: <Timer size={20} className="text-white" />,
name: "Timer",
},
{
id: 2,
icon: <Calendar size={20} className="text-white" />,
name: "Schedule",
},
{
id: 3,
icon: <LineChart size={20} className="text-white" />,
name: "Graph",
},
{
id: 3,
icon: <MoreHorizontal size={20} className="text-white" />,
name: "More",
},
];
return (
<div className=" flex justify-center gap-8">
{configList?.map((item, index) => (
<div
key={index}
className="flex flex-col items-center justify-center gap-1"
>
<div className="h-11 w-11 rounded-full bg-white/20 flex items-center justify-center">
{item.icon}
</div>
<p className="text-xs text-white">{item.name}</p>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { ChevronRight, Minus, Plug, Plus } from "lucide-react";
import { IMAGES } from "../../../../constants/images";
import { useState } from "react";
import { cn } from "../../../../utils/classname";
export default function Controll() {
const [suhu, setSuhu] = useState(23);
const [mode, setMode] = useState(1);
const modeList = [
{
id: 1,
icon: IMAGES.ModeAuto,
name: "Mode",
},
{
id: 2,
icon: IMAGES.ModeFan,
name: "Fan",
},
{
id: 3,
icon: IMAGES.ModeSwing,
name: "Swing",
},
];
return (
<div className="flex flex-col justify-center flex-1 items-center gap-4 pt-20">
<div className="flex justify-center items-center gap-4">
<button
onClick={() => setSuhu((prev) => prev - 1)}
className="h-14 w-14 flex justify-center items-center bg-white rounded-full shadow-sm shadow-neutral-100 active:bg-neutral-200 transition-all duration-200 ease-in disabled:text-neutral-900/30"
disabled={suhu <= 0}
>
<Minus size={24} />
</button>
<div className="h-38 w-38 bg-white/30 rounded-full flex justify-center items-center flex-col">
<h1 className="text-neutral-900 font-bold text-5xl">{suhu}°</h1>
<p className="text-sm font-medium text-neutral-500">Turn On</p>
</div>
<button
onClick={() => setSuhu((prev) => prev + 1)}
className="h-14 w-14 flex justify-center items-center bg-white rounded-full shadow-sm shadow-neutral-100 active:bg-neutral-200 transition-all duration-200 ease-in disabled:text-neutral-900/30"
disabled={suhu >= 25}
>
<Plus size={24} />
</button>
</div>
<div className="flex justify-center items-center gap-4">
{modeList?.map((item, index) => (
<button onClick={() => setMode(item.id)} className="flex flex-col justify-center items-center gap-2">
<div
key={index}
className="h-11 w-11 bg-white rounded-full shadow-sm shadow-neutral-100 active:bg-neutral-200 transition-all duration-200 ease-in flex justify-center items-center"
>
<img src={item.icon} className={cn('max-h-5 max-w-5 opacity-100', mode !== item.id && 'opacity-30')} />
</div>
<p className="text-xs text-white">{item.name}</p>
</button>
))}
</div>
<div className="bg-linear-to-r from-dark-2 to-dark-3 rounded-4xl py-3 px-4 flex justify-center items-center gap-2 mt-8">
<Plug size={24} className="text-orange-500" />
<p className="text-base font-bold text-white">220 kWh</p>
<ChevronRight size={24} className="text-white" />
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Wind } from "lucide-react";
import Card from "../../../components/ui/card";
import { Switch } from "../../../components/ui/switch";
import Controll from "./components/controll";
import Configs from "./components/configs";
export default function DetailDeviceFeature() {
return (
<div className="pt-4 px-6 h-full flex flex-col">
<Card className="shrink-0">
<div className="flex items-center justify-between">
<div className="flex gap-4 items-center">
<div className="h-12 w-12 bg-neutral-100 rounded-full flex justify-center items-center">
<Wind size={24} />
</div>
<div>
<h3 className="font-semibold text-gray-800 text-sm mb-1">
Air Conditioner
</h3>
<p className="text-xs text-gray-500">Entrance - Off</p>
</div>
</div>
<Switch />
</div>
</Card>
<Controll/>
<Configs/>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { Bluetooth, Lightbulb, SpeakerIcon, Tv, Wind } from "lucide-react";
import { useMemo } from "react";
import { generateUniquePoints } from "./utils";
import { useNavigate } from "@tanstack/react-router";
export default function AddDevice() {
const navigate = useNavigate();
const devices = [
{
id: 1,
icon: <Wind size={18} />,
name: "Air Conditioner",
},
{
id: 2,
icon: <SpeakerIcon size={18} />,
name: "Speaker",
},
{
id: 3,
icon: <Tv size={18} />,
name: "Smart TV",
},
{
id: 4,
icon: <Lightbulb size={18} />,
name: "Ceiling Light",
},
];
const positions = useMemo(
() =>
generateUniquePoints(
5, // jumlah device
60, // radius area orange (inner)
160, // batas spiral (outer)
80 // ↔ minimal jarak antar device
),
[]
);
return (
<div className="h-full flex flex-col">
<div className="flex flex-col items-center justify-center shrink-0">
<h1 className="text-2xl text-white font-semibold mb-2">
Add new device
</h1>
<p className="text-white/60 text-sm">Searching devices...</p>
</div>
<div className="flex h-full justify-center items-center relative">
<div className="p-12 rounded-full border border-dashed animate-[ringStep3_1.6s_linear_infinite]">
<div className="p-12 rounded-full border border-dashed animate-[ringStep2_1.6s_linear_infinite]">
<div className="p-12 rounded-full border border-dashed animate-[ringStep1_1.6s_linear_infinite]">
<div className="bg-orange-500 h-12 w-12 rounded-full text-white flex items-center justify-center">
<Bluetooth size={24} />
</div>
</div>
</div>
</div>
{devices.map((device, i) => (
<button
key={device.id}
onClick={() => {
navigate({
to: "/",
state: {},
});
}}
className="absolute text-xs text-center flex flex-col items-center justify-center"
style={{
transform: `translate(${positions[i].x}px, ${positions[i].y}px)`,
}}
>
<div className="h-10 w-10 bg-white rounded-full flex items-center justify-center text-shadow-neutral-900">
{device.icon}
</div>
<p className="text-xs text-white/60 py-2">{device.name}</p>
</button>
))}
</div>
<div className="shrink-0 flex justify-center">
<p className="text-white/50 text-sm">Tap on device to add</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
type Point = { x: number; y: number }
function distance(a: Point, b: Point) {
return Math.hypot(a.x - b.x, a.y - b.y)
}
export function generateUniquePoints(
count: number,
minRadius: number,
maxRadius: number,
minDistance: number
): Point[] {
const points: Point[] = []
let attempts = 0
while (points.length < count && attempts < 500) {
attempts++
const angle = Math.random() * Math.PI * 2
const r =
minRadius +
Math.sqrt(Math.random()) * (maxRadius - minRadius)
const point = {
x: Math.cos(angle) * r,
y: Math.sin(angle) * r,
}
const isTooClose = points.some(
(p) => distance(p, point) < minDistance
)
if (!isTooClose) {
points.push(point)
}
}
return points
}

View File

@@ -0,0 +1,9 @@
import { useMutation } from "@tanstack/react-query";
import { postCommandStatus } from "../../../repositories/device";
export function useDeviceCommand() {
return useMutation({
mutationFn: (payload: DevicePayload) =>
postCommandStatus(payload),
});
}

View File

@@ -0,0 +1,60 @@
import { useQueries } from "@tanstack/react-query";
import { getDeviceStatus } from "../../../repositories/device";
import { DEVICES_DATA } from "../../../utils/data";
import { getMerchant } from "../../../utils/storage";
export function useDevices() {
const queries = useQueries({
queries: DEVICES_DATA.map((device) => ({
queryKey: [
"device-status",
device.code,
device.roomName,
device.deviceType,
],
queryFn: async () => {
const merchant = getMerchant();
const params = {
merchantName: merchant,
floorName: device.floorName,
unitNumber: device.unitNumber,
deviceName: device.code,
roomName: device.roomName,
deviceType: device.deviceType,
commandType: "S",
towerNumber: device.towerNumber,
};
const data = await getDeviceStatus(params);
return {
...device,
code: data.data.code,
deviceName: device.code,
status:
data.data.payload?.toLowerCase().includes("on") ||
data.data.payload?.toLowerCase().includes("lock") ||
data.data.payload?.toLowerCase().includes("Open")
? true
: false,
} as unknown as Device;
},
staleTime: 10_000,
retry: 1,
})),
});
const devices = queries
.map((q, index) => ({
...q.data,
isLoading: q.isLoading || q.isFetching,
isError: q.isError,
refetch: q.refetch, // 👈 per device
key: DEVICES_DATA[index].code,
}))
.filter((d) => !d?.isError);
return {
data: devices as unknown as DeviceData[],
isLoading: queries.some((q) => q.isLoading),
};
}

View File

@@ -0,0 +1,11 @@
import Devices from "./sections/devices";
import Weather from "./sections/weather";
export default function HomeFeature() {
return (
<>
<Weather />
<Devices />
</>
);
}

View File

@@ -0,0 +1,152 @@
import { Lightbulb, Minus, Plus, SpeakerIcon, Tv, Wind } from "lucide-react";
import { Switch } from "../../../components/ui/switch";
import { useNavigate } from "@tanstack/react-router";
import Card from "../../../components/ui/card";
import { useDevices } from "../hooks/queries";
import { getDevices, getMerchant } from "../../../utils/storage";
import { useDeviceCommand } from "../hooks/mutations";
export default function Devices() {
const navigate = useNavigate();
const dataStorage = getDevices();
const merchant = getMerchant();
const { data, isLoading } = useDevices();
const { isPending, mutate } = useDeviceCommand();
const onSubmit = (payload: DeviceData, action = "") => {
mutate(
{
commandType: "C",
deviceName: payload.deviceName,
deviceType: payload.deviceType,
floorName: payload.floorName,
merchantName: merchant,
payload: { action },
roomName: payload.roomName,
towerNumber: payload.towerNumber,
unitNumber: payload.unitNumber,
},
{
onSuccess: () => {
payload?.refetch?.();
},
onError: () => {
payload?.refetch?.();
},
}
);
};
const icons = [
{ icon: <Lightbulb size={24} />, label: "light" },
{ icon: <Tv size={24} />, label: "tv" },
{ icon: <SpeakerIcon size={24} />, label: "power" },
{ icon: <Wind size={24} />, label: "ac" },
{ icon: <Tv size={24} />, label: "device" },
];
const datas = [...data, ...dataStorage];
console.log(data);
return (
<>
<div className="py-4 px-6">
<h1 className="text-base text-neutral-900 font-bold mb-4">
Your Devices
</h1>
<div className="flex items-center gap-3 flex-wrap justify-around">
<button
onClick={() =>
navigate({
to: "/device/add",
})
}
className="shrink-0 w-12 h-12 rounded-full flex items-center justify-center transition-all bg-orange-500 text-white"
>
<Plus size={24} />
</button>
{icons.map((item, idx) => (
<button
key={idx}
className="shrink-0 w-12 h-12 rounded-full flex items-center justify-center transition-all bg-white text-gray-700 hover:bg-gray-200"
>
{item.icon}
</button>
))}
</div>
</div>
<div className="py-4 px-6">
<div className="grid grid-cols-2 auto-rows-fr gap-4 grid-flow-dense">
{!isLoading &&
datas?.map((item, index) => (
<Card
key={index}
className={`${
item.deviceName === "AC" && "row-span-2 col-span-1"
}`}
>
<div className="flex items-start justify-between mb-3">
<div className="h-12 w-12 bg-neutral-100 rounded-full flex justify-center items-center">
icon
</div>
{!["FT1"].includes(item.deviceName) && (
<Switch
disabled={isPending}
defaultChecked={item.status}
checked={item.status}
onChange={() => {
if (item.deviceName === "DL") {
onSubmit(item, item.status ? "Unlock" : "Lock");
} else if (item.deviceName === "BL") {
onSubmit(item, item.status ? "Closed" : "Open");
} else {
onSubmit(item, item.status ? "Off" : "On");
}
}}
/>
)}
</div>
<h1 className="font-semibold text-gray-800 text-sm mb-1">
{item?.deviceLabel}
</h1>
<p className="text-xs text-gray-500">
T{item?.towerNumber}/L{item?.floorName}/R{item?.roomName}
</p>
{item?.deviceName === "AC" && (
<>
<div className="mb-3 border-t border-neutral-200 py-4 mt-4">
<p className="text-2xl font-bold text-gray-800">
{item.deviceName}
</p>
<p className="text-xs text-gray-500">
Unit {item.unitNumber}
</p>
</div>
<div className="flex gap-2 items-center justify-center">
<button
onClick={() => onSubmit(item, "Down")}
className="flex-1 flex bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 rounded-lg items-center justify-center"
>
<Minus size={24} />
</button>
<button
onClick={() => onSubmit(item, "Up")}
className="flex-1 flex bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 rounded-lg items-center justify-center"
>
<Plus size={24} />
</button>
</div>
</>
)}
</Card>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,15 @@
import { IMAGES } from "../../../constants/images";
export default function Weather() {
return (
<div className="px-6 py-8">
<div className="flex items-center gap-4">
<img src={IMAGES.Sun} className="w-12 h-12" />
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Sunny, 24°C</h2>
<p className="text-sm text-gray-600">Stockholm, Sweden</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { Lightbulb, SpeakerIcon, Tv, Wind } from "lucide-react";
import Card from "../../components/ui/card";
import { Switch } from "../../components/ui/switch";
import { cn } from "../../utils/classname";
import { Link } from "@tanstack/react-router";
export default function RoomsFeature() {
const deviceData = [
{
id: 1,
icon: <Wind size={18} />,
name: "Air Conditioner",
device: 2,
},
{
id: 2,
icon: <SpeakerIcon size={18} />,
name: "Speaker",
device: 2,
},
{
id: 3,
icon: <Tv size={18} />,
name: "Smart TV",
device: 2,
},
{
id: 4,
icon: <Lightbulb size={18} />,
name: "Lamp",
device: 4,
},
];
return (
<div className="flex flex-col justify-end h-full w-full">
<div className="p-4">
<h1 className="text-2xl text-white font-bold">Living Room</h1>
<p className="text-sm text-white">4 Devices</p>
</div>
<div className="flex gap-4 overflow-x-auto scrollbar-none">
{deviceData?.map((item, index) => {
return (
<Card
key={index}
className={cn(
"p-2",
index === 0 && "ml-4",
index + 1 === deviceData.length && "mr-4"
)}
>
<div className="flex items-center justify-between mb-3 gap-6">
<div className="h-10 w-10 bg-neutral-100 rounded-full flex justify-center items-center">
{item.icon}
</div>
<Switch />
</div>
<Link
to={`/device/${item.id}`}
className="font-semibold text-gray-800 text-sm mb-1"
>
{item.name}
</Link>
<p className="text-xs text-gray-500">{item.device} Devices</p>
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useEffect, useState } from "react";
import {
saveMerchant,
getMerchant,
} from "../../utils/storage";
export default function SettingsFeature() {
const [form, setForm] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const merchant = getMerchant();
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) {
const { value } = e.target;
setForm(value);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
saveMerchant(form)
setTimeout(() => {
setLoading(false);
}, 200);
}
useEffect(() => {
setForm(merchant || 'SAVY');
},[merchant]);
return (
<form onSubmit={handleSubmit} className="p-4">
<div className="flex flex-col gap-4">
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Merchant Name
</label>
<input
name="merchantName"
value={form}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
</div>
<button
type="submit"
className="w-full bg-orange-500 text-white py-2 rounded-lg mt-8 disabled:bg-neutral-500"
disabled={loading}
>
Save Device
</button>
</form>
);
}

13
src/index.css Normal file
View File

@@ -0,0 +1,13 @@
@import "tailwindcss";
@theme {
--color-gray-1: #f0f4f9;
--color-gray-2: #e2e7ed;
--color-gray-3: #f5f5f5;
--color-gray-4: #ecf1f5;
--color-gray-5: #8893a4;
--color-dark-1: #242d3e;
--color-dark-2: #4c5261;
--color-dark-3: #273041;
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,18 @@
import { Info } from "lucide-react";
import NavigationLayout from "../../components/layout/navigation.layout";
import AnalysisFeature from "../../features/analysis";
export default function AnalysisPage() {
return (
<NavigationLayout
isBottomNav
isBack
title="Analysis"
action={<Info size={24} />}
className="bg-linear-to-b from-gray-3 to-gray-3"
mainClassName="pt-2"
>
<AnalysisFeature />
</NavigationLayout>
);
}

15
src/pages/device/add.tsx Normal file
View File

@@ -0,0 +1,15 @@
import NavigationLayout from "../../components/layout/navigation.layout";
import AddDeviceFeature from "../../features/device/add";
export default function AddDevicePage() {
return (
<NavigationLayout
isBack
title="Tambah Device"
mainClassName="pt-2"
className="from-white to-white"
>
<AddDeviceFeature />
</NavigationLayout>
);
}

View File

@@ -0,0 +1,19 @@
import { MoreVertical } from "lucide-react";
import NavigationLayout from "../../components/layout/navigation.layout";
import DetailDeviceFeature from "../../features/device/detail";
export default function DetailDevicdePage() {
return (
<NavigationLayout
isBottomNav
backText=""
isBack
title="Analysis"
action={<MoreVertical size={24} />}
className="from-gray-4 to-gray-5"
mainClassName="pt-2"
>
<DetailDeviceFeature />
</NavigationLayout>
);
}

View File

@@ -0,0 +1,14 @@
import NavigationLayout from "../../components/layout/navigation.layout";
import AddDevice from "../../features/device/add";
export default function AddDevicePage() {
return (
<NavigationLayout
isBack
className="bg-[radial-gradient(circle_at_center,#2e3955_0%,#242d3e_40%,#242d3e_100%)]"
backClassName="text-white"
>
<AddDevice />
</NavigationLayout>
);
}

10
src/pages/home/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
import NavigationLayout from "../../components/layout/navigation.layout";
import HomeFeature from "../../features/home";
export default function HomePage() {
return (
<NavigationLayout isBottomNav>
<HomeFeature />
</NavigationLayout>
);
}

19
src/pages/rooms/index.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { MoreVertical } from "lucide-react";
import NavigationLayout from "../../components/layout/navigation.layout";
import { IMAGES } from "../../constants/images";
import RoomsFeature from "../../features/rooms";
export default function RoomsPage() {
return (
<NavigationLayout
isBack
action={<MoreVertical size={24} className="text-white" />}
mainClassName="pt-2"
backClassName="text-white"
bgImage={IMAGES.Rooms}
>
<RoomsFeature />
</NavigationLayout>
);
}

View File

@@ -0,0 +1,15 @@
import NavigationLayout from "../../components/layout/navigation.layout";
import SettingsFeature from "../../features/settings";
export default function SettingsPage() {
return (
<NavigationLayout
isBack
title="Setup Merchant"
mainClassName="pt-2"
className="from-white to-white"
>
<SettingsFeature />
</NavigationLayout>
);
}

View File

@@ -0,0 +1,11 @@
import api from "../../utils/axios";
export const getDeviceStatus = async (params: DeviceParams): Promise<Device> => {
const res = await api.get('/device/v1/status', { params });
return res.data;
};
export const postCommandStatus = async (payload: DevicePayload): Promise<Device> => {
const res = await api.post('/device/v1/command', payload);
return res.data;
};

46
src/repositories/device/types.d.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
type Device = {
status: string;
message: string;
data: DeviceData;
};
type DeviceData = {
floorName: string;
unitNumber: string;
deviceName: string;
roomName: string;
deviceType: string;
code: string;
towerNumber: string;
payload?: string;
deviceLabel?: string
deviceName?: string
active?: boolean
status?: boolean
refetch?: () => void
};
type DeviceParams = {
merchantName: string;
floorName: string;
unitNumber: string;
deviceName: string;
roomName: string;
deviceType: string;
commandType: string;
towerNumber: string;
};
type DevicePayload = {
commandType: string,
deviceName: string,
deviceType: string,
floorName: string,
merchantName: string,
payload: {
action: string
},
roomName: string,
towerNumber: string,
unitNumber: string,
}

View File

@@ -0,0 +1,24 @@
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
import { Route as RootRoute } from "./root";
export const AppNavigationRoute = createRoute({
getParentRoute: () => RootRoute,
id: "app",
component: lazyRouteComponent(
() => import("../components/layout/main-navigation.layout")
),
});
export const HomeRoute = createRoute({
getParentRoute: () => AppNavigationRoute,
path: "/",
component: lazyRouteComponent(() => import("../pages/home")),
});
export const AnalysisRoute = createRoute({
getParentRoute: () => AppNavigationRoute,
path: "/analysis",
component: lazyRouteComponent(() => import("../pages/analysis")),
});

34
src/router/app.route.ts Normal file
View File

@@ -0,0 +1,34 @@
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
import { Route as RootRoute } from "./root";
export const AppRoute = createRoute({
getParentRoute: () => RootRoute,
id: "general",
component: lazyRouteComponent(
() => import("../components/layout/main.layout")
),
});
export const AddDeviceRoute = createRoute({
getParentRoute: () => AppRoute,
path: "/device/add",
component: lazyRouteComponent(() => import("../pages/device/add")),
});
export const RoomsRoute = createRoute({
getParentRoute: () => AppRoute,
path: "/rooms",
component: lazyRouteComponent(() => import("../pages/rooms")),
});
export const DetailDeviceRoute = createRoute({
getParentRoute: () => AppRoute,
path: "/device/$id",
component: lazyRouteComponent(() => import("../pages/device/detail")),
});
export const SettingsRoute = createRoute({
getParentRoute: () => AppRoute,
path: "/settings",
component: lazyRouteComponent(() => import("../pages/settings")),
});

12
src/router/auth.route.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { createRoute } from '@tanstack/react-router'
import { Route as RootRoute } from './root'
import AuthLayout from '../components/layout/auth.layout'
// import AuthLayout from '@/app/layouts/auth.layout'
// import LoginPage from '@/features/auth/login.page'
export const AuthRoute = createRoute({
getParentRoute: () => RootRoute,
path: '/login',
component: AuthLayout,
})

28
src/router/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createRouter } from "@tanstack/react-router";
import { Route as RootRoute } from "./root";
import { AuthRoute } from "./auth.route";
import {
AnalysisRoute,
AppNavigationRoute,
HomeRoute,
} from "./app-navigation.route";
import {
AddDeviceRoute,
AppRoute,
DetailDeviceRoute,
RoomsRoute,
SettingsRoute,
} from "./app.route";
const routeTree = RootRoute.addChildren([
AuthRoute.addChildren([]),
AppNavigationRoute.addChildren([HomeRoute, AnalysisRoute]),
AppRoute.addChildren([
AddDeviceRoute,
RoomsRoute,
DetailDeviceRoute,
SettingsRoute,
]),
]);
export const router = createRouter({ routeTree });

9
src/router/root.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: RootLayout,
})
function RootLayout() {
return <Outlet />
}

7
src/types/router.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import '@tanstack/react-router'
declare module '@tanstack/react-router' {
interface HistoryState {
direction?: 'forward' | 'back'
}
}

68
src/utils/axios.ts Normal file
View File

@@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/lib/axios.ts
import axios from "axios";
import { generateSignature, parseQueryParamsManual } from "./signer";
import { ENV } from "../constants/env";
import type { InternalAxiosRequestConfig } from "axios";
interface SignedAxiosRequestConfig
extends InternalAxiosRequestConfig {
signed?: boolean;
}
const api = axios.create({
baseURL: ENV.apiUrl,
timeout: 15000,
});
api.interceptors.request.use(
(config: SignedAxiosRequestConfig) => {
if (config.signed === false) {
return config;
}
const method = config.method?.toLowerCase() ?? "get";
let body: any = {};
if (method === "get" || method === "delete") {
// 🔥 SAMA PERSIS DENGAN POSTMAN
const fullUrl =
(config.baseURL ?? "") +
(config.url ?? "") +
(config.params
? `?${new URLSearchParams(config.params as any).toString()}`
: "");
body = parseQueryParamsManual(fullUrl);
} else {
body = config.data ?? {};
}
const { timestamp, nonce, signature } = generateSignature(body);
config.headers.set("X-API-Key", ENV.apiKey);
config.headers.set("X-Timestamp", timestamp.toString());
config.headers.set("X-Nonce", nonce);
config.headers.set("X-Signature", signature);
return config;
},
(error) => Promise.reject(error)
);
// RESPONSE INTERCEPTOR (Optional)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.warn("Unauthorized");
}
return Promise.reject(error);
}
);
export default api;

6
src/utils/classname.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

13
src/utils/data.ts Normal file
View File

@@ -0,0 +1,13 @@
export const DEVICES_DATA: DeviceData[] = [
{ deviceLabel: "Living Room Light", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "L", roomName: "LV", deviceType: "S" },
{ deviceLabel: "Master Bedroom Light", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "L", roomName: "MS", deviceType: "S" },
{ deviceLabel: "Master Bathroom Light", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "L", roomName: "BT", deviceType: "S" },
{ deviceLabel: "Wardrobe Light", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "L", roomName: "WIC", deviceType: "S" },
{ deviceLabel: "Living Room Curtain", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "BL", roomName: "LV", deviceType: "S" },
{ deviceLabel: "Master Bedroom Curtain", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "BL", roomName: "MS", deviceType: "S" },
{ deviceLabel: "Kitchen AC", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "AC", roomName: "KN", deviceType: "S" },
{ deviceLabel: "Kitchen AC", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "AC", roomName: "KN", deviceType: "A" },
{ deviceLabel: "Living Room IAQ", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "FT1", roomName: "LV", deviceType: "A" },
{ deviceLabel: "Living Room IAQ", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "FT2", roomName: "LV", deviceType: "S" },
{ deviceLabel: "Living ERV", towerNumber: "T2", floorName: "L3", unitNumber: "01", code: "ERV", roomName: "LV", deviceType: "S" },
]

81
src/utils/signer.ts Normal file
View File

@@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { v4 as uuidv4 } from "uuid";
import CryptoJS from "crypto-js";
import { ENV } from "../constants/env";
/* =========================
HELPERS (COPY POSTMAN)
========================= */
export function parseQueryParamsManual(url: string) {
const result: Record<string, any> = {};
const queryStartIndex = url.indexOf("?");
if (queryStartIndex === -1 || queryStartIndex === url.length - 1) {
return result;
}
const queryString = url.substring(queryStartIndex + 1);
const pairs = queryString.split("&");
for (const pair of pairs) {
const parts = pair.split("=");
if (parts.length === 2) {
const key = decodeURIComponent(parts[0].replace(/\+/g, " "));
const value = decodeURIComponent(parts[1].replace(/\+/g, " "));
result[key] = value;
}
}
return result;
}
export function sortKeysDeep(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(sortKeysDeep);
}
if (obj !== null && typeof obj === "object") {
return Object.keys(obj)
.sort()
.reduce((acc: any, key) => {
acc[key] = sortKeysDeep(obj[key]);
return acc;
}, {});
}
return obj;
}
/* =========================
SIGNATURE
========================= */
export interface SignatureResult {
timestamp: number;
nonce: string;
signature: string;
}
export function generateSignature(rawBody: any): SignatureResult {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = uuidv4();
const body = sortKeysDeep(rawBody ?? {});
const payload = JSON.stringify({
timestamp,
nonce,
body: JSON.stringify(body), // ⚠️ STRINGIFY GANDA (WAJIB)
});
const hash = CryptoJS.HmacSHA256(payload, ENV.secretKey);
return {
timestamp,
nonce,
signature: CryptoJS.enc.Hex.stringify(hash),
};
}

41
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,41 @@
const STORAGE_KEY = "devices";
const MERCHANT_KEY = "merchant";
export function getDevices(): DeviceData[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
} catch {
return [];
}
}
export function saveDevices(devices: DeviceData[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(devices));
}
export function isDuplicateDevice(
devices: DeviceData[],
newDevice: DeviceData
) {
return devices.some(d =>
d.towerNumber === newDevice.towerNumber &&
d.floorName === newDevice.floorName &&
d.unitNumber === newDevice.unitNumber &&
d.roomName === newDevice.roomName &&
d.code === newDevice.code &&
d.deviceType === newDevice.deviceType
);
}
export function getMerchant(): string {
try {
return JSON.parse(localStorage.getItem(MERCHANT_KEY) || "");
} catch {
return '';
}
}
export function saveMerchant(val: string) {
localStorage.setItem(MERCHANT_KEY, JSON.stringify(val));
}

9
src/utils/types.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
type DeviceCommand = {
deviceLabel: string
tower: string
floor: string
unit: string
deviceCode: string
room: string
type: string
}

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});