diff --git a/apps/clients/attendance/src/App.tsx b/apps/clients/attendance/src/App.tsx index a1c34c1..e354e0a 100644 --- a/apps/clients/attendance/src/App.tsx +++ b/apps/clients/attendance/src/App.tsx @@ -23,7 +23,7 @@ const router = createBrowserRouter([ { path: "settings", lazy: async () => { - const { SettingsPage } = await import("@/components/settings-page"); + const { SettingsPage } = await import("@/routes/setting-page"); return { Component: SettingsPage }; }, diff --git a/apps/clients/attendance/src/components/settings-page.tsx b/apps/clients/attendance/src/components/settings-page.tsx deleted file mode 100644 index 0b82965..0000000 --- a/apps/clients/attendance/src/components/settings-page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { successTimeoutAtom } from "@/utils/atom"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useAtom } from "jotai"; -import { ArrowLeft } from "lucide-react"; -import { useForm } from "react-hook-form"; -import { NavLink, useNavigate } from "react-router-dom"; -import { z } from "zod"; - -import { Button } from "@sora-vp/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@sora-vp/ui/form"; -import { Input } from "@sora-vp/ui/input"; -import { toast } from "@sora-vp/ui/toast"; - -const formSchema = z.object({ - timeout: z.coerce.number().min(500, { - message: "Durasi minimal adalah 500 milidetik (setengah detik).", - }), -}); - -export function SettingsPage() { - const [timeoutDuration, setDuration] = useAtom(successTimeoutAtom); - const navigate = useNavigate(); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - timeout: timeoutDuration, - }, - }); - - return ( -
-
-
-

- Halaman Pengaturan -

-

- Pada halaman ini, anda dapat mengatur sebarapa lama durasi tampilnya - warna hijau ketika sukses melakukan presensi kehadiran. Nilai durasi - bawaan yaitu 5000 milidetik (5 detik). -

-
- -
- { - setDuration(data.timeout); - toast.success("Berhasil mengubah durasi waktu tampil", { - description: `Berhasil di ubah menjadi ${data.timeout / 1000} detik`, - }); - - setTimeout(() => navigate("/"), 500); - })} - className="space-y-8" - > - ( - - Durasi Waktu Tunggu - -
- - - {Number.isNaN(parseInt(field.value)) - ? "N/A" - : parseInt(field.value) / 1000}{" "} - detik - -
-
- - Tetapkan berapa lama waktu berhasil akan transisi kembali ke - halaman pindai QR. - - -
- )} - /> -
- - {() => ( - - )} - - -
- - -
-
- ); -} diff --git a/apps/clients/attendance/src/routes/setting-page.tsx b/apps/clients/attendance/src/routes/setting-page.tsx index e69de29..c6079f5 100644 --- a/apps/clients/attendance/src/routes/setting-page.tsx +++ b/apps/clients/attendance/src/routes/setting-page.tsx @@ -0,0 +1,106 @@ +import { successTimeoutAtom } from "@/utils/atom"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAtom } from "jotai"; +import { ArrowLeft } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { NavLink, useNavigate } from "react-router-dom"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { toast } from "@sora-vp/ui/toast"; + +const formSchema = z.object({ + timeout: z.coerce.number().min(500, { + message: "Durasi minimal adalah 500 milidetik (setengah detik).", + }), +}); + +export function SettingsPage() { + const [timeoutDuration, setDuration] = useAtom(successTimeoutAtom); + const navigate = useNavigate(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + timeout: timeoutDuration, + }, + }); + + return ( +
+
+
+

+ Halaman Pengaturan +

+

+ Pada halaman ini, anda dapat mengatur sebarapa lama durasi tampilnya + warna hijau ketika sukses melakukan presensi kehadiran. Nilai durasi + bawaan yaitu 5000 milidetik (5 detik). +

+
+ +
+ { + setDuration(data.timeout); + toast.success("Berhasil mengubah durasi waktu tampil", { + description: `Berhasil di ubah menjadi ${data.timeout / 1000} detik`, + }); + + setTimeout(() => navigate("/"), 500); + })} + className="space-y-8" + > + ( + + Durasi Waktu Tunggu + +
+ + + {Number.isNaN(field.value) ? "N/A" : field.value / 1000}{" "} + detik + +
+
+ + Tetapkan berapa lama waktu berhasil akan transisi kembali ke + halaman pindai QR. + + +
+ )} + /> +
+ + {() => ( + + )} + + +
+ + +
+
+ ); +} diff --git a/apps/clients/chooser/package.json b/apps/clients/chooser/package.json index 5c9a308..b9e498e 100644 --- a/apps/clients/chooser/package.json +++ b/apps/clients/chooser/package.json @@ -30,6 +30,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0", + "react-use-websocket": "^4.8.1", "superjson": "2.2.1", "zod": "^3.23.8" }, diff --git a/apps/clients/chooser/src/App.tsx b/apps/clients/chooser/src/App.tsx index e44b6fe..3e786a4 100644 --- a/apps/clients/chooser/src/App.tsx +++ b/apps/clients/chooser/src/App.tsx @@ -11,6 +11,7 @@ import superjson from "superjson"; import { ClientNotFound } from "@sora-vp/ui/client-not-found"; +import { KeyboardWebsocketProvider } from "./context/keyboard-websocket"; import { env } from "./env"; const router = createBrowserRouter([ @@ -29,7 +30,7 @@ const router = createBrowserRouter([ { path: "settings", lazy: async () => { - const { SettingsPage } = await import("@/components/settings-page"); + const { SettingsPage } = await import("@/routes/setting-page"); return { Component: SettingsPage }; }, @@ -77,11 +78,13 @@ export default function App() { return ( - - - - - + + + + + + + ); diff --git a/apps/clients/chooser/src/components/scanner/index.tsx b/apps/clients/chooser/src/components/scanner/index.tsx index a4b8cdd..159df2d 100644 --- a/apps/clients/chooser/src/components/scanner/index.tsx +++ b/apps/clients/chooser/src/components/scanner/index.tsx @@ -1,4 +1,5 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useKeyboardWebsocket } from "@/context/keyboard-websocket"; import { useParticipant } from "@/context/participant-context"; import { api } from "@/utils/api"; import { Navigate } from "react-router-dom"; @@ -9,6 +10,7 @@ import { MainScanner } from "./main-scanner"; export function ScannerComponent() { const { qrId, setQRCode } = useParticipant(); + const { wsEnabled, lastMessage } = useKeyboardWebsocket(); const [isQrInvalid, setInvalidQr] = useState(false); @@ -19,6 +21,23 @@ export function ScannerComponent() { }, }); + useEffect(() => { + if (wsEnabled && lastMessage) { + // Precheck before consuming command + if (lastMessage.data.startsWith("SORA-KEYBIND-")) { + const actualCommand = lastMessage.data.replace("SORA-KEYBIND-", ""); + + switch (actualCommand) { + case "RELOAD": { + if (isQrInvalid || participantAttended.isError) location.reload(); + + break; + } + } + } + } + }, [isQrInvalid, participantAttended.isError, wsEnabled, lastMessage]); + const setIsQrValid = useCallback( (invalid: boolean) => setInvalidQr(invalid), [], diff --git a/apps/clients/chooser/src/components/settings-page.tsx b/apps/clients/chooser/src/components/settings-page.tsx deleted file mode 100644 index 7bcd2a1..0000000 --- a/apps/clients/chooser/src/components/settings-page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { successTimeoutAtom } from "@/utils/atom"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useAtom } from "jotai"; -import { ArrowLeft } from "lucide-react"; -import { useForm } from "react-hook-form"; -import { NavLink, useNavigate } from "react-router-dom"; -import { z } from "zod"; - -import { Button } from "@sora-vp/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@sora-vp/ui/form"; -import { Input } from "@sora-vp/ui/input"; -import { toast } from "@sora-vp/ui/toast"; - -const formSchema = z.object({ - timeout: z.coerce.number().min(500, { - message: "Durasi minimal adalah 500 milidetik (setengah detik).", - }), -}); - -export function SettingsPage() { - const [timeoutDuration, setDuration] = useAtom(successTimeoutAtom); - const navigate = useNavigate(); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - timeout: timeoutDuration, - }, - }); - - return ( -
-
-
-

- Halaman Pengaturan -

-

- Pada halaman ini, anda dapat mengatur sebarapa lama durasi tampilnya - warna hijau ketika sukses melakukan pemilihan kandidat. Nilai durasi - bawaan yaitu 12000 milidetik (12 detik). -

-
- -
- { - setDuration(data.timeout); - toast.success("Berhasil mengubah durasi waktu tampil", { - description: `Berhasil di ubah menjadi ${data.timeout / 1000} detik`, - }); - - setTimeout(() => navigate("/"), 500); - })} - className="space-y-8" - > - ( - - Durasi Waktu Tunggu - -
- - - {Number.isNaN(parseInt(field.value)) - ? "N/A" - : parseInt(field.value) / 1000}{" "} - detik - -
-
- - Tetapkan berapa lama waktu berhasil akan transisi kembali ke - halaman pindai QR. - - -
- )} - /> -
- - {() => ( - - )} - - -
- - -
-
- ); -} diff --git a/apps/clients/chooser/src/context/keyboard-websocket.tsx b/apps/clients/chooser/src/context/keyboard-websocket.tsx new file mode 100644 index 0000000..131b982 --- /dev/null +++ b/apps/clients/chooser/src/context/keyboard-websocket.tsx @@ -0,0 +1,85 @@ +import { createContext, useContext, useEffect, useMemo } from "react"; +import { defaultWSPortAtom, enableWSConnectionAtom } from "@/utils/atom"; +import { useAtomValue } from "jotai"; +import useWebSocket, { ReadyState } from "react-use-websocket"; + +import { toast } from "@sora-vp/ui/toast"; + +export interface IKeyboardWebsocket { + wsEnabled: boolean; + lastMessage: MessageEvent | null; +} + +export const KeyboardWebsocketContext = createContext( + {} as IKeyboardWebsocket, +); + +export const KeyboardWebsocketProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const wsPortNumber = useAtomValue(defaultWSPortAtom); + const wsEnabled = useAtomValue(enableWSConnectionAtom); + + const { lastMessage, readyState } = useWebSocket( + wsEnabled ? `ws://127.0.0.1:${wsPortNumber}/ws` : null, + { + share: true, + shouldReconnect: () => true, + retryOnError: true, + reconnectInterval: 5000, + reconnectAttempts: Infinity, + onError(event) { + console.log(event); + }, + }, + ); + + const contextValue = useMemo( + () => ({ + wsEnabled, + lastMessage, + }), + [wsEnabled, lastMessage], + ); + + useEffect(() => { + if (wsEnabled) { + switch (readyState) { + case ReadyState.CONNECTING: { + toast.info("Sedang menghubungkan dengan modul tombol..."); + + break; + } + + case ReadyState.CLOSED: { + toast.error("Koneksi ditutup oleh modul tombol"); + + break; + } + + case ReadyState.CLOSING: { + toast.info("Menutup koneksi tombol..."); + + break; + } + + case ReadyState.OPEN: { + toast.success("Berhasil terhubung ke modul tombol!"); + + break; + } + } + } + }, [readyState, wsEnabled]); + + return ( + + {children} + + ); +}; + +export const useKeyboardWebsocket = () => + useContext(KeyboardWebsocketContext) as IKeyboardWebsocket; diff --git a/apps/clients/chooser/src/context/participant-context.tsx b/apps/clients/chooser/src/context/participant-context.tsx index b9a0fc1..fcbdf76 100644 --- a/apps/clients/chooser/src/context/participant-context.tsx +++ b/apps/clients/chooser/src/context/participant-context.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, useState, } from "react"; @@ -10,6 +11,8 @@ import { api } from "@/utils/api"; import { motion } from "framer-motion"; import { Navigate } from "react-router-dom"; +import { useKeyboardWebsocket } from "./keyboard-websocket"; + export interface IParticipantContext { name: string | null; subpart: string | null; @@ -27,6 +30,8 @@ export const ParticipantProvider = ({ }: { children: React.ReactNode; }) => { + const { wsEnabled, lastMessage } = useKeyboardWebsocket(); + const [qrId, setQrId] = useState(null); const [votedSuccessfully, setVoted] = useState(false); @@ -44,6 +49,35 @@ export const ParticipantProvider = ({ [], ); + useEffect(() => { + if (wsEnabled && lastMessage) { + // Precheck before consuming command + if (lastMessage.data.startsWith("SORA-KEYBIND-")) { + const actualCommand = lastMessage.data.replace("SORA-KEYBIND-", ""); + + switch (actualCommand) { + case "RELOAD": { + if ( + !!qrId && + participantQuery.isFetched && + (!participantQuery.data?.alreadyAttended || + participantQuery.data?.alreadyChoosing) + ) + location.reload(); + + break; + } + } + } + } + }, [ + qrId, + participantQuery.isFetched, + participantQuery.data, + wsEnabled, + lastMessage, + ]); + const propsValue = useMemo(() => { if (!qrId) return { diff --git a/apps/clients/chooser/src/context/server-setting.tsx b/apps/clients/chooser/src/context/server-setting.tsx index 333f956..289ed26 100644 --- a/apps/clients/chooser/src/context/server-setting.tsx +++ b/apps/clients/chooser/src/context/server-setting.tsx @@ -4,6 +4,7 @@ import { api } from "@/utils/api"; import { motion } from "framer-motion"; import { Loader } from "lucide-react"; +import { useKeyboardWebsocket } from "./keyboard-websocket"; import { useParticipant } from "./participant-context"; interface ISettingContext { @@ -19,6 +20,7 @@ export const ServerSettingProvider = ({ }: { children: React.ReactNode; }) => { + const { wsEnabled, lastMessage } = useKeyboardWebsocket(); const { qrId, setQRCode } = useParticipant(); const [errorMessage, setErrorMessage] = useState(""); @@ -28,6 +30,23 @@ export const ServerSettingProvider = ({ refetchIntervalInBackground: true, }); + useEffect(() => { + if (wsEnabled && lastMessage) { + // Precheck before consuming command + if (lastMessage.data.startsWith("SORA-KEYBIND-")) { + const actualCommand = lastMessage.data.replace("SORA-KEYBIND-", ""); + + switch (actualCommand) { + case "RELOAD": { + if (settingsQuery.errorUpdateCount > 0) location.reload(); + + break; + } + } + } + } + }, [settingsQuery.errorUpdateCount, wsEnabled, lastMessage]); + useEffect(() => { if (settingsQuery.error) setErrorMessage(settingsQuery.error.message); }, [settingsQuery.error]); diff --git a/apps/clients/chooser/src/routes/setting-page.tsx b/apps/clients/chooser/src/routes/setting-page.tsx index e69de29..e8fd61a 100644 --- a/apps/clients/chooser/src/routes/setting-page.tsx +++ b/apps/clients/chooser/src/routes/setting-page.tsx @@ -0,0 +1,175 @@ +import { + defaultWSPortAtom, + enableWSConnectionAtom, + successTimeoutAtom, +} from "@/utils/atom"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAtom } from "jotai"; +import { ArrowLeft } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { NavLink, useNavigate } from "react-router-dom"; +import { z } from "zod"; + +import { Button } from "@sora-vp/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@sora-vp/ui/form"; +import { Input } from "@sora-vp/ui/input"; +import { Switch } from "@sora-vp/ui/switch"; +import { toast } from "@sora-vp/ui/toast"; + +const formSchema = z.object({ + timeout: z.coerce.number().min(500, { + message: "Durasi minimal adalah 500 milidetik (setengah detik).", + }), + wsEnabled: z.boolean(), + wsPort: z.coerce.number().min(1000, { + message: "Minimal berjalan di port 100.", + }), +}); + +export function SettingsPage() { + const [timeoutDuration, setDuration] = useAtom(successTimeoutAtom); + const [wsEnabled, setWsEnabled] = useAtom(enableWSConnectionAtom); + const [wsPort, setWsPort] = useAtom(defaultWSPortAtom); + + const navigate = useNavigate(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + timeout: timeoutDuration, + wsEnabled, + wsPort, + }, + }); + + return ( +
+
+
+

+ Halaman Pengaturan +

+

+ Pada halaman ini, anda dapat mengatur durasi waktu tunggu berhasil + dan juga pengaturan modul tombol. Secara bawaan, waktu tunggu + berhasil itu selama 12.000 milidetik (12 detik). +

+
+ +
+ { + setDuration(data.timeout); + setWsEnabled(data.wsEnabled); + setWsPort(data.wsPort); + + toast.success("Berhasil memperbarui pengaturan!", { + description: "Pengaturan berhasil diubah.", + }); + + setTimeout(() => navigate("/"), 500); + })} + className="space-y-5" + > + ( + + Durasi Waktu Tunggu + +
+ + + {Number.isNaN(field.value) ? "N/A" : field.value / 1000}{" "} + detik + +
+
+ + Tetapkan berapa lama waktu berhasil akan transisi kembali ke + halaman pindai QR. + + +
+ )} + /> + + ( + +
+ + Menggunakan Modul Tombol + + + Jika anda menggunakan modul tombol, maka anda harus + mengaktifkan opsi ini,{" "} + + pelajari lebih lanjut + + . + +
+ + + +
+ )} + /> + + ( + + Nomor Port Modul Tombol + + + + + Di nomor port berapa server modul tombol berjalan, nomor + bawaan berada di port 3000. + + + + )} + /> + +
+ + {() => ( + + )} + + +
+ + +
+
+ ); +} diff --git a/apps/clients/chooser/src/routes/vote-page.tsx b/apps/clients/chooser/src/routes/vote-page.tsx index 93af003..87c3262 100644 --- a/apps/clients/chooser/src/routes/vote-page.tsx +++ b/apps/clients/chooser/src/routes/vote-page.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { UniversalError } from "@/components/universal-error"; +import { useKeyboardWebsocket } from "@/context/keyboard-websocket"; import { ensureQRIDExist, useParticipant } from "@/context/participant-context"; import { env } from "@/env"; import { api } from "@/utils/api"; @@ -90,6 +91,7 @@ const CurrentParticipantInfo = (props: { isSuccess?: boolean }) => { function VotePage() { const { qrId, setQRCode, setVotedSuccessfully } = useParticipant(); + const { wsEnabled, lastMessage } = useKeyboardWebsocket(); const successTimeout = useAtomValue(successTimeoutAtom); @@ -144,8 +146,8 @@ function VotePage() { } }, [currentID, qrId, alertOpen, cannotPushKey]); - useEffect(() => { - const triggerOpen = (candidateIndex: number) => { + const triggerOpen = useCallback( + (candidateIndex: number) => { if ( !cannotPushKey && candidateList.data && @@ -158,8 +160,11 @@ function VotePage() { setAlertOpen(true); } } - }; + }, + [cannotPushKey, candidateList.data], + ); + useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { switch (e.key) { case "Escape": { @@ -214,7 +219,78 @@ function VotePage() { return () => { window.removeEventListener("keyup", handleKeydown); }; - }, [candidateList.data, alertOpen, cannotPushKey, chooseCandidate]); + }, [upvoteCandidate.isPending, alertOpen, triggerOpen, chooseCandidate]); + + useEffect(() => { + if (wsEnabled && lastMessage) { + // Precheck before consuming command + if (lastMessage.data.startsWith("SORA-KEYBIND-")) { + const actualCommand = lastMessage.data.replace("SORA-KEYBIND-", ""); + + switch (actualCommand) { + case "ESC": { + if (!upvoteCandidate.isPending) { + setID(null); + setAlertOpen(false); + } + + break; + } + + case "RELOAD": { + if (upvoteCandidate.isError || candidateList.errorUpdateCount > 0) + location.reload(); + + break; + } + + case "1": { + if (!alertOpen) triggerOpen(0); + + break; + } + + case "2": { + if (!alertOpen) triggerOpen(1); + + break; + } + + case "3": { + if (!alertOpen) triggerOpen(2); + + break; + } + + case "4": { + if (!alertOpen) triggerOpen(3); + + break; + } + + case "5": { + if (!alertOpen) triggerOpen(4); + + break; + } + + case "ENTER": { + chooseCandidate(); + + break; + } + } + } + } + }, [ + upvoteCandidate.isPending, + candidateList.errorUpdateCount, + alertOpen, + triggerOpen, + chooseCandidate, + wsEnabled, + lastMessage, + ]); useEffect(() => { if (candidateList.error) setErrorMessage(candidateList.error.message); diff --git a/apps/clients/chooser/src/utils/atom.ts b/apps/clients/chooser/src/utils/atom.ts index 48c1361..8bcec34 100644 --- a/apps/clients/chooser/src/utils/atom.ts +++ b/apps/clients/chooser/src/utils/atom.ts @@ -5,3 +5,16 @@ import { atomWithStorage } from "jotai/utils"; * setelah partisipan memilih kandidat dan berhasil di proses oleh server. */ export const successTimeoutAtom = atomWithStorage("successTimeout", 12_000); + +/** + * Dua atom di bawah ini adalah atom yang akan mengatur apakah perangkat + * ini terdapat modul tombol yang memerlukan koneksi websocket supaya + * modul tombol dapat mengirimkan perintah dari sw2s. + */ + +export const enableWSConnectionAtom = atomWithStorage( + "enableWSConnection", + false, +); + +export const defaultWSPortAtom = atomWithStorage("defaultWSPort", 3000); diff --git a/apps/processor/package.json b/apps/processor/package.json index 01150a0..c79e642 100644 --- a/apps/processor/package.json +++ b/apps/processor/package.json @@ -30,8 +30,8 @@ "devDependencies": { "@sora-vp/api": "*", "@sora-vp/db": "*", - "@sora-vp/id-generator": "*", "@sora-vp/eslint-config": "*", + "@sora-vp/id-generator": "*", "@sora-vp/prettier-config": "*", "@sora-vp/tailwind-config": "*", "@sora-vp/tsconfig": "*", diff --git a/yarn.lock b/yarn.lock index 4c438b8..b85df40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2555,6 +2555,7 @@ __metadata: react: "npm:^18.3.1" react-dom: "npm:^18.3.1" react-router-dom: "npm:^6.24.0" + react-use-websocket: "npm:^4.8.1" superjson: "npm:2.2.1" tailwindcss: "npm:^3.4.4" typescript: "npm:^5.4.5" @@ -9618,6 +9619,16 @@ __metadata: languageName: node linkType: hard +"react-use-websocket@npm:^4.8.1": + version: 4.8.1 + resolution: "react-use-websocket@npm:4.8.1" + peerDependencies: + react: ">= 18.0.0" + react-dom: ">= 18.0.0" + checksum: 10c0/9106947334badc06d8af635328f1420068098130eac8f6da90e7d13cf37037cf1cf0bd528103ca3689a38097a70e10d3b577f143078254e9dd3b9aa429395116 + languageName: node + linkType: hard + "react@npm:^18.3.1": version: 18.3.1 resolution: "react@npm:18.3.1"