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

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>
);
}