initial code
25
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
44
package.json
Normal 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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/images/contrast.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/images/fan.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/images/room.avif
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
src/assets/images/swing.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
1
src/assets/react.svg
Normal 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 |
11
src/components/layout/auth.layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<div className="auth-layout">
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/components/layout/main-navigation.layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/components/layout/main.layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/layout/navigation.layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/bottom-navigation/bottom-navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/ui/card/index.tsx
Normal 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>;
|
||||
}
|
||||
55
src/components/ui/progress-bar/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/ui/switch/index.tsx
Normal 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
@@ -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
@@ -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
|
||||
};
|
||||
101
src/features/analysis/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/features/analysis/utils.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
150
src/features/device/add/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/features/device/add/utils.ts
Normal 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
|
||||
}
|
||||
42
src/features/device/detail/components/configs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/features/device/detail/components/controll.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/features/device/detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/features/device/pairing/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/features/device/pairing/utils.ts
Normal 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
|
||||
}
|
||||
9
src/features/home/hooks/mutations.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
60
src/features/home/hooks/queries.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
11
src/features/home/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Devices from "./sections/devices";
|
||||
import Weather from "./sections/weather";
|
||||
|
||||
export default function HomeFeature() {
|
||||
return (
|
||||
<>
|
||||
<Weather />
|
||||
<Devices />
|
||||
</>
|
||||
);
|
||||
}
|
||||
152
src/features/home/sections/devices.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/features/home/sections/weather.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/features/rooms/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/features/settings/index.tsx
Normal 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
@@ -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
@@ -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>,
|
||||
)
|
||||
18
src/pages/analysis/index.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
19
src/pages/device/detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/pages/device/pairing.tsx
Normal 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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
15
src/pages/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/repositories/device/index.ts
Normal 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
@@ -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,
|
||||
}
|
||||
24
src/router/app-navigation.route.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal 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
@@ -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()],
|
||||
});
|
||||