diff options
| author | Max Nanis | 2026-09-10 17:46:41 -0700 |
|---|---|---|
| committer | Max Nanis | 2026-09-10 17:46:41 -0700 |
| commit | ed201936813d9a720b51e6cb40a05267b94e7314 (patch) | |
| tree | f675f6a0d91ed1d17f654e40936e8c1ca9ccf141 /jb-ui/src | |
| parent | 0ac38066402762dc5fa24f2ffa2170681a6eddb1 (diff) | |
| download | amt-jb-ed201936813d9a720b51e6cb40a05267b94e7314.tar.gz amt-jb-ed201936813d9a720b51e6cb40a05267b94e7314.zip | |
Typescript prettier
Committing seperate so it's easier to see actual changes.
Diffstat (limited to 'jb-ui/src')
34 files changed, 3963 insertions, 3643 deletions
diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx index ef23b3b..472b976 100644 --- a/jb-ui/src/JBApp.tsx +++ b/jb-ui/src/JBApp.tsx @@ -1,21 +1,26 @@ import { - EventMessage, - EventType, - PingMessage, - PongMessage, - PongMessageKindEnum, - StatsMessage, - StatusApi, - SubscribeMessage, SubscribeMessageKindEnum, - TaskStatusResponse, - UserWalletBalance, - UserWalletBalanceResponse, - WalletApi + EventMessage, + EventType, + PingMessage, + PongMessage, + PongMessageKindEnum, + StatsMessage, + StatusApi, + SubscribeMessage, + SubscribeMessageKindEnum, + TaskStatusResponse, + UserWalletBalance, + UserWalletBalanceResponse, + WalletApi, } from "@/api_fsb"; import Footer from "@/components/Footer"; import { useAppDispatch } from "@/hooks"; import { bpid, routeBasename, tagManager } from "@/lib/utils"; -import { setAssignmentID, setProductUserID, setTurkSubmitTo } from "@/models/appSlice"; +import { + setAssignmentID, + setProductUserID, + setTurkSubmitTo, +} from "@/models/appSlice"; import { addEvent } from "@/models/grlEventsSlice"; import { addStatsData } from "@/models/grlStatsSlice"; import Home from "@/pages/Home"; @@ -23,8 +28,15 @@ import MagicLink from "@/pages/MagicLink"; import Preview from "@/pages/Preview"; import Result from "@/pages/Result"; import Work from "@/pages/Work"; -import { BrowserRouter, Outlet, Route, Routes, useSearchParams } from "react-router-dom"; -import useWebSocket from 'react-use-websocket'; +import Transfer from "@/pages/Transfer"; +import { + BrowserRouter, + Outlet, + Route, + Routes, + useSearchParams, +} from "react-router-dom"; +import useWebSocket from "react-use-websocket"; import NewsTicker from "./components/EventMarquee"; import { useEffect } from "react"; @@ -34,178 +46,195 @@ import "@/index.css"; import { setTaskStatus, setUserWalletBalance } from "@/models/appSlice"; import { Profiling } from "./components/Profiling"; -type Message = PingMessage | PongMessage | SubscribeMessage | EventMessage | StatsMessage; +type Message = + | PingMessage + | PongMessage + | SubscribeMessage + | EventMessage + | StatsMessage; function isStatsMessage(msg: Message): msg is StatsMessage { - return msg.kind === 'stats'; + return msg.kind === "stats"; } function isEventMessage(msg: Message): msg is EventMessage { - return msg.kind === 'event'; + return msg.kind === "event"; } function isPingMessage(msg: Message): msg is PingMessage { - return msg.kind === 'ping'; + return msg.kind === "ping"; } function QueryParamProcessor() { - const dispatch = useAppDispatch() - const [searchParams] = useSearchParams(); + const dispatch = useAppDispatch(); + const [searchParams] = useSearchParams(); - useEffect(() => { - const worker_id = searchParams.get('workerId'); - const assignment_id = searchParams.get('assignmentId'); - const tsid = searchParams.get('tsid'); - const turkSubmitTo = searchParams.get('turkSubmitTo'); - - if (turkSubmitTo) { - dispatch(setTurkSubmitTo(turkSubmitTo)) - } + useEffect(() => { + const worker_id = searchParams.get("workerId"); + const assignment_id = searchParams.get("assignmentId"); + const tsid = searchParams.get("tsid"); + const turkSubmitTo = searchParams.get("turkSubmitTo"); - if (worker_id) { - dispatch(setProductUserID(worker_id)) - - new WalletApi().getUserWalletBalanceProductIdWalletGet( - bpid, // productId - worker_id, // bpuid - ).then(res => { - const response = res.data as UserWalletBalanceResponse; - const balance = response.wallet as UserWalletBalance; - dispatch(setUserWalletBalance(balance)); - }); - } + if (turkSubmitTo) { + dispatch(setTurkSubmitTo(turkSubmitTo)); + } - if (assignment_id) { - dispatch(setAssignmentID(assignment_id)) - } + if (worker_id) { + dispatch(setProductUserID(worker_id)); + + new WalletApi() + .getUserWalletBalanceProductIdWalletGet( + bpid, // productId + worker_id, // bpuid + ) + .then((res) => { + const response = res.data as UserWalletBalanceResponse; + const balance = response.wallet as UserWalletBalance; + dispatch(setUserWalletBalance(balance)); + }); + } - if (tsid) { - new StatusApi().getTaskStatusProductIdStatusTsidGet( - bpid, // productId - tsid).then(res => { - const response: TaskStatusResponse = res.data; - dispatch(setTaskStatus(response)) - }).catch((error) => { - console.error("Error fetching task status:", error); - }); - } + if (assignment_id) { + dispatch(setAssignmentID(assignment_id)); + } - }, [searchParams]); + if (tsid) { + new StatusApi() + .getTaskStatusProductIdStatusTsidGet( + bpid, // productId + tsid, + ) + .then((res) => { + const response: TaskStatusResponse = res.data; + dispatch(setTaskStatus(response)); + }) + .catch((error) => { + console.error("Error fetching task status:", error); + }); + } + }, [searchParams]); - return <Outlet />; + return <Outlet />; } const Layout = () => { - return ( - <div className="flex flex-col min-h-screen"> - <header className="bg-amber-400 text-amber-950 py-2 text-center"> - <a href="https://www.reddit.com/r/Marketresearch/comments/1s2tl0o/open_letter_to_cint_supplier_quality_issues/" target="_blank"> - <h4 className="text-[12px]"> - Letter to Cint: supplier quality issues, and malware-linked panels. - </h4> - </a> - </header> - - <main className="flex flex-1 bg-zinc-200"> - <div className="flex flex-col items-center justify-center flex-1"> - <div className=" + return ( + <div className="flex flex-col min-h-screen"> + <header className="bg-amber-400 text-amber-950 py-2 text-center"> + <a + href="https://www.reddit.com/r/Marketresearch/comments/1s2tl0o/open_letter_to_cint_supplier_quality_issues/" + target="_blank" + > + <h4 className="text-[12px]"> + Letter to Cint: supplier quality issues, and malware-linked panels. + </h4> + </a> + </header> + + <main className="flex flex-1 bg-zinc-200"> + <div className="flex flex-col items-center justify-center flex-1"> + <div + className=" w-full max-w-full p-2 bg-white shadow-lg text-center space-y-4 md:max-w-5/8 md:rounded-lg rounded-none - "> - <Wallet /> - <Profiling /> - <Outlet /> - </div> - </div> - </main > - - <footer className="bg-slate-700 text-zinc-200 p-2 text-center font-mono"> - <NewsTicker /> - <Footer /> - </footer> - - </div > - ); + " + > + <Wallet /> + <Profiling /> + <Outlet /> + </div> + </div> + </main> + + <footer className="bg-slate-700 text-zinc-200 p-2 text-center font-mono"> + <NewsTicker /> + <Footer /> + </footer> + </div> + ); }; function JBApp() { - const dispatch = useAppDispatch() - - const { sendJsonMessage, lastMessage } = useWebSocket( - "wss://fsb.generalresearch.com/ws/events/", { - onOpen: () => { - const subscribe: SubscribeMessage = { - "kind": SubscribeMessageKindEnum.Subscribe, - "product_id": bpid - } - sendJsonMessage(subscribe) + const dispatch = useAppDispatch(); + + const { sendJsonMessage, lastMessage } = useWebSocket( + "wss://fsb.generalresearch.com/ws/events/", + { + onOpen: () => { + const subscribe: SubscribeMessage = { + kind: SubscribeMessageKindEnum.Subscribe, + product_id: bpid, + }; + sendJsonMessage(subscribe); + }, + }, + ); + + useEffect(() => { + if (lastMessage !== null) { + let msg: Message = JSON.parse(lastMessage.data); + + if (isStatsMessage(msg)) { + dispatch(addStatsData(msg.data)); + } else if (isEventMessage(msg)) { + // For simplicity, we just log the event messages here. + // You can extend this to update the UI or trigger notifications as needed. + + switch (msg.data.event_type) { + case EventType.TaskEnter: + case EventType.TaskFinish: + case EventType.UserCreated: + dispatch(addEvent(msg.data)); + break; + + default: + // do nothing for now + break; } + } else if (isPingMessage(msg)) { + const pong: PongMessage = { + kind: PongMessageKindEnum.Pong, + }; + sendJsonMessage(pong); + } } - ); - - useEffect(() => { - if (lastMessage !== null) { - let msg: Message = JSON.parse(lastMessage.data); - - if (isStatsMessage(msg)) { - dispatch(addStatsData(msg.data)) - - } else if (isEventMessage(msg)) { - // For simplicity, we just log the event messages here. - // You can extend this to update the UI or trigger notifications as needed. - - switch (msg.data.event_type) { - case EventType.TaskEnter: - case EventType.TaskFinish: - case EventType.UserCreated: - dispatch(addEvent(msg.data)); - break - - default: - // do nothing for now - break; - } - - } else if (isPingMessage(msg)) { - const pong: PongMessage = { - "kind": PongMessageKindEnum.Pong, - } - sendJsonMessage(pong) - } - } - }, [lastMessage]); - - useEffect(() => { - if (!tagManager) return; - - var _mtm = window._mtm = window._mtm || []; - _mtm.push({ 'mtm.startTime': (new Date().getTime()), 'event': 'mtm.Start' }); - var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0]; - g.async = true; - g.src = tagManager; - if (s.parentNode) { - s.parentNode.insertBefore(g, s); - } - }, [tagManager]) - - return ( - <BrowserRouter basename={routeBasename}> - <Routes> - <Route path="/auth/magic-link/" element={<MagicLink />} /> - <Route element={<QueryParamProcessor />}> - <Route element={<Layout />}> - <Route path="/" element={<Home />} /> - <Route path="/preview/" element={<Preview />} /> - <Route path="/work/" element={<Work />} /> - <Route path="/result/" element={<Result />} /> - </Route> - </Route> - </Routes> - </BrowserRouter> - ) + }, [lastMessage]); + + useEffect(() => { + if (!tagManager) return; + + var _mtm = (window._mtm = window._mtm || []); + _mtm.push({ "mtm.startTime": new Date().getTime(), event: "mtm.Start" }); + var d = document, + g = d.createElement("script"), + s = d.getElementsByTagName("script")[0]; + g.async = true; + g.src = tagManager; + if (s.parentNode) { + s.parentNode.insertBefore(g, s); + } + }, [tagManager]); + + return ( + <BrowserRouter basename={routeBasename}> + <Routes> + <Route path="/auth/magic-link/" element={<MagicLink />} /> + <Route element={<QueryParamProcessor />}> + <Route element={<Layout />}> + <Route path="/" element={<Home />} /> + {/* <Route path="/work/" element={<Work />} /> */} + + <Route path="/preview/" element={<Preview />} /> + <Route path="/work/" element={<Transfer />} /> + <Route path="/result/" element={<Result />} /> + </Route> + </Route> + </Routes> + </BrowserRouter> + ); } export default JBApp; diff --git a/jb-ui/src/components/EventMarquee.tsx b/jb-ui/src/components/EventMarquee.tsx index 4ce4f15..6aab3f8 100644 --- a/jb-ui/src/components/EventMarquee.tsx +++ b/jb-ui/src/components/EventMarquee.tsx @@ -1,230 +1,250 @@ -import { EventEnvelope, EventType, TaskEnterPayload, TaskFinishPayload } from "@/api_fsb"; -import { formatSecondsVerbose, formatSource, formatStatus, truncate } from "@/lib/utils"; +import { + EventEnvelope, + EventType, + TaskEnterPayload, + TaskFinishPayload, +} from "@/api_fsb"; +import { + formatSecondsVerbose, + formatSource, + formatStatus, + truncate, +} from "@/lib/utils"; import { selectAllEvents, selectCurrentSpeed } from "@/models/grlEventsSlice"; -import getUnicodeFlagIcon from 'country-flag-icons/unicode'; +import getUnicodeFlagIcon from "country-flag-icons/unicode"; import { useEffect, useRef, useState } from "react"; import { useSelector } from "react-redux"; // ─── Config ─────────────────────────────────────────────────────────────────── -const SPEED_EASING = 0.025; // how quickly speed transitions (per frame) +const SPEED_EASING = 0.025; // how quickly speed transitions (per frame) interface EventItemProps { - event: EventEnvelope; + event: EventEnvelope; } const EventTaskEnter = ({ event }: EventItemProps) => { - const payload = event.payload as TaskEnterPayload; - - return ( - <> - <span className="px-2 text-lg">{getUnicodeFlagIcon(payload.country_iso)}</span> - <p> - {truncate(event.product_user_id!, 6, "*****")} Entered{" "} - {formatSource(payload.source)}{" "} - (#{truncate(payload.survey_id!, 5, "***")}) - </p> - </> - ) -} + const payload = event.payload as TaskEnterPayload; + + return ( + <> + <span className="px-2 text-lg"> + {getUnicodeFlagIcon(payload.country_iso)} + </span> + <p> + {truncate(event.product_user_id!, 6, "*****")} Entered{" "} + {formatSource(payload.source)} (# + {truncate(payload.survey_id!, 5, "***")}) + </p> + </> + ); +}; const EventTaskFinish = ({ event }: EventItemProps) => { - const payload = event.payload as TaskFinishPayload; - - return ( - <> - <span className="px-2 text-lg">{getUnicodeFlagIcon(event.payload.country_iso)}</span> - <p> - {truncate(event.product_user_id!, 6, "*****")}{" "} - {formatStatus(payload.status)} {formatSource(payload.source)}{" "} - (#{truncate(payload.survey_id!, 5, "***")}) in{" "} - {formatSecondsVerbose(payload.duration_sec!)} - </p> - </> - ) -} + const payload = event.payload as TaskFinishPayload; + + return ( + <> + <span className="px-2 text-lg"> + {getUnicodeFlagIcon(event.payload.country_iso)} + </span> + <p> + {truncate(event.product_user_id!, 6, "*****")}{" "} + {formatStatus(payload.status)} {formatSource(payload.source)} (# + {truncate(payload.survey_id!, 5, "***")}) in{" "} + {formatSecondsVerbose(payload.duration_sec!)} + </p> + </> + ); +}; const EventUserCreated = ({ event }: EventItemProps) => { - - return ( - <> - <span className="px-2 text-lg">{getUnicodeFlagIcon(event.payload.country_iso)}</span> - <p>{truncate(event.product_user_id!, 6, "*****")} Created</p> - </> - ) -} - + return ( + <> + <span className="px-2 text-lg"> + {getUnicodeFlagIcon(event.payload.country_iso)} + </span> + <p>{truncate(event.product_user_id!, 6, "*****")} Created</p> + </> + ); +}; const EventComponent = ({ event }: EventItemProps) => { - - const renderContent = () => { - switch (event.event_type) { - case EventType.TaskEnter: - return <EventTaskEnter event={event} /> - case EventType.TaskFinish: - return <EventTaskFinish event={event} /> - case EventType.UserCreated: - return <EventUserCreated event={event} /> - default: - return <><p>Unknown event</p></> - } + const renderContent = () => { + switch (event.event_type) { + case EventType.TaskEnter: + return <EventTaskEnter event={event} />; + case EventType.TaskFinish: + return <EventTaskFinish event={event} />; + case EventType.UserCreated: + return <EventUserCreated event={event} />; + default: + return ( + <> + <p>Unknown event</p> + </> + ); } + }; - return ( - <div key={event.event_uuid!} - className="inline-flex items-center text-[10px] - font-mono px-2"> - {renderContent()} - - {/* <span className="text-red-500 pl-5">◆</span> */} - </div > - ) -} + return ( + <div + key={event.event_uuid!} + className="inline-flex items-center text-[10px] + font-mono px-2" + > + {renderContent()} + {/* <span className="text-red-500 pl-5">◆</span> */} + </div> + ); +}; // ─── Main Component ─────────────────────────────────────────────────────────── export default function NewsTicker() { - // --- Redux --- - const reduxItems = useSelector(selectAllEvents); - const targetSpeedRedux = useSelector(selectCurrentSpeed); // px/sec - - // --- Scroll state (all refs — no re-renders from animation) --- - const containerRef = useRef<HTMLDivElement | null>(null); // The ticker band - const stripRef = useRef<HTMLDivElement | null>(null); // Scrolling strip - const translateX = useRef(0); - const currentSpeed = useRef(targetSpeedRedux); - const targetSpeed = useRef(targetSpeedRedux); - - // Only state and refs persist across renders, the rest are re-initialized, - // that is why this isn't just a standard variable. - // Request Animation Frame reference (stores the animation frame ID) - const rafRef = useRef<number | null>(null); - const lastTime = useRef<number | null>(null); - - // --- Item tracking --- - // Track which IDs are already in the scroller to only append - // genuinely new ones. - const seenUUIDs = useRef(new Set<string>()); - const pendingQueue = useRef<EventEnvelope[]>([]); - const [scrollItems, setScrollItems] = useState<EventEnvelope[]>([]); - - // --- Sync Redux → pendingQueue (only new items) --- - useEffect(() => { - const newItems = reduxItems.filter((item) => !seenUUIDs.current.has(item.event_uuid!)); - if (newItems.length === 0) return; - - newItems.forEach(item => seenUUIDs.current.add(item.event_uuid!)); - pendingQueue.current.push(...newItems); - }, [reduxItems]); - - // --- Sync target speed from Redux --- - useEffect(() => { - targetSpeed.current = targetSpeedRedux; - }, [targetSpeedRedux]); - - // --- RAF loop --- - useEffect(() => { - // This timestamp is a "high-resolution timestamp" provided by - // the browser, which is more accurate for measuring frame intervals - // than Date.now() - const loop = (timestamp: number) => { - console.log("RAF loop, timestamp:", timestamp, lastTime.current); - - if (!lastTime.current) lastTime.current = timestamp; - const delta = (timestamp - lastTime.current) / 1000; - lastTime.current = timestamp; - - // Slow easement towards targetSpeed - currentSpeed.current += - (targetSpeed.current - currentSpeed.current) * SPEED_EASING; - - // flush pending queue into React state - if (pendingQueue.current.length > 0) { - const incoming = pendingQueue.current.splice(0); - setScrollItems((prev) => [...prev, ...incoming]); - } - - // Advance strip, slides it over left by X pixels - translateX.current -= currentSpeed.current * delta; - if (stripRef.current) { - stripRef.current.style.transform = `translateX(${translateX.current}px)`; - } - - // prune items that have fully exited the left edge - if (stripRef.current && containerRef.current) { - const children = Array.from(stripRef.current.children); - const containerLeft = containerRef.current.getBoundingClientRect().left; - let pruneCount = 0; - let removedWidth = 0; - - for (const child of children) { - const rect = child.getBoundingClientRect(); - if (rect.right < containerLeft - 20) { - pruneCount++; - removedWidth += rect.width; - } else { - break; - } - } - - if (pruneCount > 0) { - translateX.current += removedWidth; - setScrollItems((prev) => prev.slice(pruneCount)); - } - } - - // Assign the next loop - rafRef.current = requestAnimationFrame(loop); - }; - - rafRef.current = requestAnimationFrame(loop); - return () => cancelAnimationFrame(rafRef.current!); - }, []); - - return ( - <div style={{ - minHeight: "3vh", display: "flex", flexDirection: "column", - justifyContent: "center", alignItems: "center", gap: "0" - }}> - - {/* Ticker wrapper */} - <div style={{ width: "100%", position: "relative" }}> - - {/* The ticker band */} - <div - ref={containerRef} - style={{ - overflow: "hidden", - padding: "0", - position: "relative", - }} - > - - {/* Scrolling strip */} - <div - ref={stripRef} - style={{ - display: "inline-flex", - alignItems: "center", - whiteSpace: "nowrap", - padding: "10px 0", - willChange: "transform", - position: "relative", - zIndex: 2, - }} - > - {scrollItems.map(item => ( - <EventComponent event={item} /> - ))} - - {/* Trailing spacer so new content doesn't immediately snap in */} - <span style={{ paddingRight: "100vw", display: "inline-block" }} /> - - </div> - </div> - - </div> + // --- Redux --- + const reduxItems = useSelector(selectAllEvents); + const targetSpeedRedux = useSelector(selectCurrentSpeed); // px/sec + + // --- Scroll state (all refs — no re-renders from animation) --- + const containerRef = useRef<HTMLDivElement | null>(null); // The ticker band + const stripRef = useRef<HTMLDivElement | null>(null); // Scrolling strip + const translateX = useRef(0); + const currentSpeed = useRef(targetSpeedRedux); + const targetSpeed = useRef(targetSpeedRedux); + + // Only state and refs persist across renders, the rest are re-initialized, + // that is why this isn't just a standard variable. + // Request Animation Frame reference (stores the animation frame ID) + const rafRef = useRef<number | null>(null); + const lastTime = useRef<number | null>(null); + + // --- Item tracking --- + // Track which IDs are already in the scroller to only append + // genuinely new ones. + const seenUUIDs = useRef(new Set<string>()); + const pendingQueue = useRef<EventEnvelope[]>([]); + const [scrollItems, setScrollItems] = useState<EventEnvelope[]>([]); + + // --- Sync Redux → pendingQueue (only new items) --- + useEffect(() => { + const newItems = reduxItems.filter( + (item) => !seenUUIDs.current.has(item.event_uuid!), + ); + if (newItems.length === 0) return; + + newItems.forEach((item) => seenUUIDs.current.add(item.event_uuid!)); + pendingQueue.current.push(...newItems); + }, [reduxItems]); + + // --- Sync target speed from Redux --- + useEffect(() => { + targetSpeed.current = targetSpeedRedux; + }, [targetSpeedRedux]); + + // --- RAF loop --- + useEffect(() => { + // This timestamp is a "high-resolution timestamp" provided by + // the browser, which is more accurate for measuring frame intervals + // than Date.now() + const loop = (timestamp: number) => { + console.log("RAF loop, timestamp:", timestamp, lastTime.current); + + if (!lastTime.current) lastTime.current = timestamp; + const delta = (timestamp - lastTime.current) / 1000; + lastTime.current = timestamp; + + // Slow easement towards targetSpeed + currentSpeed.current += + (targetSpeed.current - currentSpeed.current) * SPEED_EASING; + + // flush pending queue into React state + if (pendingQueue.current.length > 0) { + const incoming = pendingQueue.current.splice(0); + setScrollItems((prev) => [...prev, ...incoming]); + } + + // Advance strip, slides it over left by X pixels + translateX.current -= currentSpeed.current * delta; + if (stripRef.current) { + stripRef.current.style.transform = `translateX(${translateX.current}px)`; + } + + // prune items that have fully exited the left edge + if (stripRef.current && containerRef.current) { + const children = Array.from(stripRef.current.children); + const containerLeft = containerRef.current.getBoundingClientRect().left; + let pruneCount = 0; + let removedWidth = 0; + + for (const child of children) { + const rect = child.getBoundingClientRect(); + if (rect.right < containerLeft - 20) { + pruneCount++; + removedWidth += rect.width; + } else { + break; + } + } + if (pruneCount > 0) { + translateX.current += removedWidth; + setScrollItems((prev) => prev.slice(pruneCount)); + } + } + + // Assign the next loop + rafRef.current = requestAnimationFrame(loop); + }; + + rafRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(rafRef.current!); + }, []); + + return ( + <div + style={{ + minHeight: "3vh", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + gap: "0", + }} + > + {/* Ticker wrapper */} + <div style={{ width: "100%", position: "relative" }}> + {/* The ticker band */} + <div + ref={containerRef} + style={{ + overflow: "hidden", + padding: "0", + position: "relative", + }} + > + {/* Scrolling strip */} + <div + ref={stripRef} + style={{ + display: "inline-flex", + alignItems: "center", + whiteSpace: "nowrap", + padding: "10px 0", + willChange: "transform", + position: "relative", + zIndex: 2, + }} + > + {scrollItems.map((item) => ( + <EventComponent event={item} /> + ))} + + {/* Trailing spacer so new content doesn't immediately snap in */} + <span style={{ paddingRight: "100vw", display: "inline-block" }} /> + </div> </div> - ); + </div> + </div> + ); } diff --git a/jb-ui/src/components/Footer.tsx b/jb-ui/src/components/Footer.tsx index 00c0101..eede06c 100644 --- a/jb-ui/src/components/Footer.tsx +++ b/jb-ui/src/components/Footer.tsx @@ -1,41 +1,51 @@ import { useAppSelector } from "@/hooks"; const Footer = () => { - const bpuid = useAppSelector(state => state.app.bpuid) - const assignment_id = useAppSelector(state => state.app.assignment_id) + const bpuid = useAppSelector((state) => state.app.bpuid); + const assignment_id = useAppSelector((state) => state.app.assignment_id); - return ( - <div className="grid gap-1 grid-cols-1 + return ( + <div + className="grid gap-1 grid-cols-1 md:grid-cols-3 - text-xs"> - <div> - <p>help: <a - href={`mailto:support@jamesbillings67.com?subject=AMT Support Request: (${(bpuid ?? '-')}) ${(assignment_id ?? '–')}`}> - support@jamesbillings67.com - </a> - </p> - </div> + text-xs" + > + <div> + <p> + help:{" "} + <a + href={`mailto:support@jamesbillings67.com?subject=AMT Support Request: (${bpuid ?? "-"}) ${assignment_id ?? "–"}`} + > + support@jamesbillings67.com + </a> + </p> + </div> - <div> - <p>made with <span className="text-rose-500">♥</span> by <a - className="hover:cursor-pointer hover:text-pink" - href="https://instagram.com/x0xMaximus" - target="_blank">Max Nanis</a> - </p> - </div> + <div> + <p> + made with <span className="text-rose-500">♥</span> by{" "} + <a + className="hover:cursor-pointer hover:text-pink" + href="https://instagram.com/x0xMaximus" + target="_blank" + > + Max Nanis + </a> + </p> + </div> - <div> - <p> - <a href="https://git.generalresearch.com/panels/amt-jb/" - target="_blank"> - open source - </a> - </p> - </div> + <div> + <p> + <a + href="https://git.generalresearch.com/panels/amt-jb/" + target="_blank" + > + open source + </a> + </p> + </div> + </div> + ); +}; - </div> - ) - -} - -export default Footer
\ No newline at end of file +export default Footer; diff --git a/jb-ui/src/components/Profiling.tsx b/jb-ui/src/components/Profiling.tsx index 9cefdda..96b06b2 100644 --- a/jb-ui/src/components/Profiling.tsx +++ b/jb-ui/src/components/Profiling.tsx @@ -1,536 +1,562 @@ -import { - UpkQuestionChoice -} from "@/api_fsb"; -import { - ChevronRightIcon -} from "lucide-react"; +import { UpkQuestionChoice } from "@/api_fsb"; +import { ChevronRightIcon } from "lucide-react"; import { KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react"; import { useAppDispatch, useAppSelector } from "@/hooks"; import { bpid } from "@/lib/utils"; import { - createColumnHelper, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - RowModel, - RowSelectionState, - useReactTable, -} from '@tanstack/react-table'; + createColumnHelper, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + RowModel, + RowSelectionState, + useReactTable, +} from "@tanstack/react-table"; import { clsx } from "clsx"; import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, } from "@/components/ui/pagination"; import { - addAnswers, - fetchNewQuestions, - goToNextQuestion, - ProfileQuestion, - questionsSelectors, - saveAnswer, - selectActiveQuestion, - selectActiveQuestionValidation, - selectAllQuestions, - setActiveQuestion + addAnswers, + fetchNewQuestions, + goToNextQuestion, + ProfileQuestion, + questionsSelectors, + saveAnswer, + selectActiveQuestion, + selectActiveQuestionValidation, + selectAllQuestions, + setActiveQuestion, } from "@/models/profilingQuestionsSlice"; import { useSelector } from "react-redux"; // import {motion} from "framer-motion" const TextEntry = () => { - const dispatch = useAppDispatch() - - const activeQuestion = useSelector(selectActiveQuestion); - // const selectAnswer = useMemo(() => selectAnswerForQuestion(question), [question]); - // const selectAnswer = useSelector(selectAnswerForQuestion(question)); - const metadata = activeQuestion?._metadata - const validation = activeQuestion?._validation - const bpuid = useAppSelector(state => state.app.bpuid) - - if (!metadata) return null - if (!validation) return null - - const error = validation?.errors.length > 0 - - const handleInputChange = (event: React.KeyboardEvent<HTMLInputElement>) => { - if (!activeQuestion.question_id) return; - - const target = event.target as HTMLInputElement; - dispatch(addAnswers({ questionId: activeQuestion.question_id!, answers: [target.value] as string[] })) - }; - - const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>) => { - if (!activeQuestion.question_id) return; - if (!bpuid) return; - - if (event.key === 'Enter') { - dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid })) - } - }; - - return ( - <div className="px-6 my-6"> - <input type="text" - id="text-entry-input" - aria-describedby="" - // defaultValue={answer?.values.length ? answer?.values[0] : ""} - onKeyUp={handleInputChange} - onKeyDown={handleKeyPress} // Use onKeyDown to listen for the key press - // title={error ? answer?.error_msg : ""} - className={clsx( - 'w-full border-1 rounded px-2 py-1', - error ? - 'border-red-500 focus-visible:ring-red-500' : - 'border-blue-50' - )} - /> - - { - validation.errors.map(e => { - return ( - <p className="text-sm text-red-100 my-2">{e.message}</p> - ) - }) - } - </div> - ) -} - - -export default function useKeyboardMode( - rowModel: RowModel<UpkQuestionChoice>, -) { - // const [keyboardEnabled, setKeyboardEnabled] = useState( - // localStorage.getItem(keyboardMode) === "enabled", - // ); - const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); - - const dispatch = useAppDispatch() - const activeQuestion = useSelector(selectActiveQuestion); - const bpuid = useAppSelector(state => state.app.bpuid) - - const keyboardSelectionIdxRef = useRef(0); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - - if (!activeQuestion) return; - if (!bpuid) return; - - // if (!keyboardEnabled) { - // return; - // } + const dispatch = useAppDispatch(); + + const activeQuestion = useSelector(selectActiveQuestion); + // const selectAnswer = useMemo(() => selectAnswerForQuestion(question), [question]); + // const selectAnswer = useSelector(selectAnswerForQuestion(question)); + const metadata = activeQuestion?._metadata; + const validation = activeQuestion?._validation; + const bpuid = useAppSelector((state) => state.app.bpuid); + + if (!metadata) return null; + if (!validation) return null; + + const error = validation?.errors.length > 0; + + const handleInputChange = (event: React.KeyboardEvent<HTMLInputElement>) => { + if (!activeQuestion.question_id) return; + + const target = event.target as HTMLInputElement; + dispatch( + addAnswers({ + questionId: activeQuestion.question_id!, + answers: [target.value] as string[], + }), + ); + }; + + const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>) => { + if (!activeQuestion.question_id) return; + if (!bpuid) return; + + if (event.key === "Enter") { + dispatch( + saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }), + ); + } + }; + + return ( + <div className="px-6 my-6"> + <input + type="text" + id="text-entry-input" + aria-describedby="" + // defaultValue={answer?.values.length ? answer?.values[0] : ""} + onKeyUp={handleInputChange} + onKeyDown={handleKeyPress} // Use onKeyDown to listen for the key press + // title={error ? answer?.error_msg : ""} + className={clsx( + "w-full border-1 rounded px-2 py-1", + error + ? "border-red-500 focus-visible:ring-red-500" + : "border-blue-50", + )} + /> - const { key } = event; - const totalRows = rowRefs.current.length; - const prevIdx = keyboardSelectionIdxRef.current; - let nextIdx = prevIdx; + {validation.errors.map((e) => { + return <p className="text-sm text-red-100 my-2">{e.message}</p>; + })} + </div> + ); +}; - if (key === "ArrowDown") nextIdx = Math.min(prevIdx + 1, totalRows - 1); - if (key === "ArrowUp") nextIdx = Math.max(prevIdx - 1, 0); - if (key === " ") { - const active = document.activeElement as HTMLElement | null; +export default function useKeyboardMode(rowModel: RowModel<UpkQuestionChoice>) { + // const [keyboardEnabled, setKeyboardEnabled] = useState( + // localStorage.getItem(keyboardMode) === "enabled", + // ); + const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); - if (active?.tabIndex === 0) { - return; - } else { - rowModel.rows[keyboardSelectionIdxRef.current].toggleSelected(); - } - } + const dispatch = useAppDispatch(); + const activeQuestion = useSelector(selectActiveQuestion); + const bpuid = useAppSelector((state) => state.app.bpuid); - if (event.key === 'Enter') { - dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid })) - } + const keyboardSelectionIdxRef = useRef(0); + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!activeQuestion) return; + if (!bpuid) return; - if (nextIdx !== prevIdx) { + // if (!keyboardEnabled) { + // return; + // } - rowRefs.current[prevIdx]?.classList.remove("bg-blue-400"); - rowRefs.current[nextIdx]?.classList.add("bg-blue-400"); + const { key } = event; + const totalRows = rowRefs.current.length; + const prevIdx = keyboardSelectionIdxRef.current; + let nextIdx = prevIdx; - rowRefs.current[nextIdx]?.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - keyboardSelectionIdxRef.current = nextIdx; - } - }; + if (key === "ArrowDown") nextIdx = Math.min(prevIdx + 1, totalRows - 1); + if (key === "ArrowUp") nextIdx = Math.max(prevIdx - 1, 0); + if (key === " ") { + const active = document.activeElement as HTMLElement | null; - // I really don't know, and I don't really care enough to make this - // technically type'd correctly. - Max + if (active?.tabIndex === 0) { + return; + } else { + rowModel.rows[keyboardSelectionIdxRef.current].toggleSelected(); + } + } - // @ts-ignore - window.addEventListener("keydown", handleKeyDown); - // @ts-ignore - return () => window.removeEventListener("keydown", handleKeyDown); + if (event.key === "Enter") { + dispatch( + saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }), + ); + } - }, [rowModel.rows]); + if (nextIdx !== prevIdx) { + rowRefs.current[prevIdx]?.classList.remove("bg-blue-400"); + rowRefs.current[nextIdx]?.classList.add("bg-blue-400"); - return { - // keyboardEnabled, - // setKeyboardEnabled, - rowRefs, - keyboardSelectionIdxRef, + rowRefs.current[nextIdx]?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + keyboardSelectionIdxRef.current = nextIdx; + } }; + + // I really don't know, and I don't really care enough to make this + // technically type'd correctly. - Max + + // @ts-ignore + window.addEventListener("keydown", handleKeyDown); + // @ts-ignore + return () => window.removeEventListener("keydown", handleKeyDown); + }, [rowModel.rows]); + + return { + // keyboardEnabled, + // setKeyboardEnabled, + rowRefs, + keyboardSelectionIdxRef, + }; } const MultipleChoice = () => { - const dispatch = useAppDispatch() - const activeQuestion = useSelector(selectActiveQuestion); - - // const error: Boolean = answer?.error_msg.length > 0 - // console.log("Rendering MultipleChoice with answer:", answer, error) - - // TODO! Retrieve store values to use as the initial state for the table - // This is useful for if they tab around before submitting the answer - const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); - const [globalFilter, setGlobalFilter] = useState(''); - - const handleRowSelectionChange = (updaterOrValue: RowSelectionState | ((old: RowSelectionState) => RowSelectionState)) => { - setRowSelection((oldSelection) => { - // Get the new selection state - const newSelection = typeof updaterOrValue === 'function' - ? updaterOrValue(oldSelection) - : updaterOrValue; - - // Find which row was just selected/deselected - const changedRowId = Object.keys(newSelection).find( - key => newSelection[key] !== oldSelection[key] - ); - - if (!changedRowId) return newSelection; - - // Get the actual row data - const row = table.getRow(changedRowId); - const rowData = row.original; // Your row data object - - // Check if "None of the Above" was selected - const normalized = rowData.choice_text.trim().toLowerCase(); - const isNoneOfTheAbove = (normalized === "none of the above") || (rowData.exclusive ?? false); - if (isNoneOfTheAbove && newSelection[changedRowId]) { - // Clear all other selections, keep only this one - return { [changedRowId]: true }; - } - - // Check if a regular option was selected while "None of the Above" exists - const noneOfTheAboveId = Object.keys(newSelection).find(id => { - const r = table.getRow(id); - const rowData = r.original as UpkQuestionChoice; - - const normalized = rowData.choice_text.trim().toLowerCase(); - const isNoneOfTheAbove = (normalized === "none of the above") || (rowData.exclusive ?? false); - - return isNoneOfTheAbove && newSelection[id]; - }); - - if (noneOfTheAboveId && changedRowId !== noneOfTheAboveId) { - // Remove "None of the Above" selection - const { [noneOfTheAboveId]: _, ...rest } = newSelection; - return rest; - } - - return newSelection; - }); - }; + const dispatch = useAppDispatch(); + const activeQuestion = useSelector(selectActiveQuestion); + + // const error: Boolean = answer?.error_msg.length > 0 + // console.log("Rendering MultipleChoice with answer:", answer, error) + + // TODO! Retrieve store values to use as the initial state for the table + // This is useful for if they tab around before submitting the answer + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const [globalFilter, setGlobalFilter] = useState(""); + + const handleRowSelectionChange = ( + updaterOrValue: + | RowSelectionState + | ((old: RowSelectionState) => RowSelectionState), + ) => { + setRowSelection((oldSelection) => { + // Get the new selection state + const newSelection = + typeof updaterOrValue === "function" + ? updaterOrValue(oldSelection) + : updaterOrValue; + + // Find which row was just selected/deselected + const changedRowId = Object.keys(newSelection).find( + (key) => newSelection[key] !== oldSelection[key], + ); + + if (!changedRowId) return newSelection; + + // Get the actual row data + const row = table.getRow(changedRowId); + const rowData = row.original; // Your row data object + + // Check if "None of the Above" was selected + const normalized = rowData.choice_text.trim().toLowerCase(); + const isNoneOfTheAbove = + normalized === "none of the above" || (rowData.exclusive ?? false); + if (isNoneOfTheAbove && newSelection[changedRowId]) { + // Clear all other selections, keep only this one + return { [changedRowId]: true }; + } + + // Check if a regular option was selected while "None of the Above" exists + const noneOfTheAboveId = Object.keys(newSelection).find((id) => { + const r = table.getRow(id); + const rowData = r.original as UpkQuestionChoice; + + const normalized = rowData.choice_text.trim().toLowerCase(); + const isNoneOfTheAbove = + normalized === "none of the above" || (rowData.exclusive ?? false); + + return isNoneOfTheAbove && newSelection[id]; + }); + + if (noneOfTheAboveId && changedRowId !== noneOfTheAboveId) { + // Remove "None of the Above" selection + const { [noneOfTheAboveId]: _, ...rest } = newSelection; + return rest; + } + + return newSelection; + }); + }; + + useEffect(() => { + if (!activeQuestion?.question_id) return; + // Run this anytime a rowSelection changes, to update + // the answer in the store + const selectedChoices = Object.keys(rowSelection); + dispatch( + addAnswers({ + questionId: activeQuestion.question_id, + answers: selectedChoices, + }), + ); + }, [rowSelection]); + + // Define columns + const columnHelper = createColumnHelper<UpkQuestionChoice>(); + const columns = [ + columnHelper.display({ + id: "select", + cell: ({ row }) => { + const canSelected = row.getCanSelect(); - useEffect(() => { - if (!activeQuestion?.question_id) return; - // Run this anytime a rowSelection changes, to update - // the answer in the store - const selectedChoices = Object.keys(rowSelection) - dispatch(addAnswers({ questionId: activeQuestion.question_id, answers: selectedChoices })) - }, [rowSelection]) - - // Define columns - const columnHelper = createColumnHelper<UpkQuestionChoice>() - const columns = [ - columnHelper.display({ - id: 'select', - cell: ({ row }) => { - const canSelected = row.getCanSelect() - - return ( - <input - type="checkbox" - checked={row.getIsSelected()} - disabled={!row.getCanSelect()} - onChange={row.getToggleSelectedHandler()} - className={`w-4 h-4 rounded-0 border-slate-300 cursor-pointer + return ( + <input + type="checkbox" + checked={row.getIsSelected()} + disabled={!row.getCanSelect()} + onChange={row.getToggleSelectedHandler()} + className={`w-4 h-4 rounded-0 border-slate-300 cursor-pointer focus:outline-none focus:border-blue-500 focus:ring-blue-500 - ${canSelected ? ' ' : 'hidden'} + ${canSelected ? " " : "hidden"} `} - /> - ) - }, - size: 20, - meta: { - align: 'center' - } - }), - - columnHelper.accessor("choice_text", { - header: () => 'Choice Text', - cell: (info) => { - const text = info.getValue(); - const selected = info.row.getIsSelected() - const canSelected = info.row.getCanSelect() - - return ( - <p className={` + /> + ); + }, + size: 20, + meta: { + align: "center", + }, + }), + + columnHelper.accessor("choice_text", { + header: () => "Choice Text", + cell: (info) => { + const text = info.getValue(); + const selected = info.row.getIsSelected(); + const canSelected = info.row.getCanSelect(); + + return ( + <p + className={` w-max truncate text-sm text-left pl-1 - ${selected ? 'font-bold' : 'font-medium'} - ${canSelected ? 'text-blue-950' : 'text-blue-600'} - `}> - {text} - </p> - ) - }, - size: 380, - meta: { - align: 'left' - } - }), - ] - - const table = useReactTable({ - 'data': activeQuestion!.choices ?? [], - columns, - getRowId: (row) => row.choice_id, - state: { - rowSelection, - globalFilter, - }, - // enableRowSelection: true, - enableRowSelection: (row) => { - // Allow deselection always, but limit new selections - const currentSelectionCount = Object.keys(rowSelection).length; - const isRowSelected = rowSelection[row.id]; - - const max_selection = activeQuestion?.selector === "SA" ? 1 : activeQuestion?.choices?.length ?? 10 - - // Allow if: row is already selected OR we haven't hit the limit - return isRowSelected || currentSelectionCount < max_selection; - }, - onRowSelectionChange: handleRowSelectionChange, - onGlobalFilterChange: setGlobalFilter, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - }); - - const selectedCount = table.getSelectedRowModel().rows.length; - - //const { rowRefs, setKeyboardEnabled, keyboardSelectionIdxRef } = useKeyboardMode(table.getRowModel()); - const { rowRefs } = useKeyboardMode(table.getRowModel()); - - return ( - <> - - {/* Controls */} - <div className="py-1"> - <div className="flex flex-wrap gap-2"> - - {/* Search */} - <div className="flex-1"> - <input - type="text" - placeholder="Search choices..." - value={globalFilter ?? ''} - onChange={e => setGlobalFilter(e.target.value)} - className="w-full px-4 py-1 + ${selected ? "font-bold" : "font-medium"} + ${canSelected ? "text-blue-950" : "text-blue-600"} + `} + > + {text} + </p> + ); + }, + size: 380, + meta: { + align: "left", + }, + }), + ]; + + const table = useReactTable({ + data: activeQuestion!.choices ?? [], + columns, + getRowId: (row) => row.choice_id, + state: { + rowSelection, + globalFilter, + }, + // enableRowSelection: true, + enableRowSelection: (row) => { + // Allow deselection always, but limit new selections + const currentSelectionCount = Object.keys(rowSelection).length; + const isRowSelected = rowSelection[row.id]; + + const max_selection = + activeQuestion?.selector === "SA" + ? 1 + : (activeQuestion?.choices?.length ?? 10); + + // Allow if: row is already selected OR we haven't hit the limit + return isRowSelected || currentSelectionCount < max_selection; + }, + onRowSelectionChange: handleRowSelectionChange, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + const selectedCount = table.getSelectedRowModel().rows.length; + + //const { rowRefs, setKeyboardEnabled, keyboardSelectionIdxRef } = useKeyboardMode(table.getRowModel()); + const { rowRefs } = useKeyboardMode(table.getRowModel()); + + return ( + <> + {/* Controls */} + <div className="py-1"> + <div className="flex flex-wrap gap-2"> + {/* Search */} + <div className="flex-1"> + <input + type="text" + placeholder="Search choices..." + value={globalFilter ?? ""} + onChange={(e) => setGlobalFilter(e.target.value)} + className="w-full px-4 py-1 border border-blue-900 bg-blue-400 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> - </div> + /> + </div> - {/* Selected Count */} - {selectedCount > 0 && ( - <div className="flex items-center gap-2 px-2 py-1 + {/* Selected Count */} + {selectedCount > 0 && ( + <div + className="flex items-center gap-2 px-2 py-1 border border-blue-900 text-blue-700 rounded font-medium text-[10px] hover:cursor-pointer hover:bg-blue-800 hover:text-white " - onClick={() => setRowSelection({})}> - <span>{selectedCount} selected</span> - <button className="m-0 p-0 font-bold">✕</button> - </div> - )} - - </div> + onClick={() => setRowSelection({})} + > + <span>{selectedCount} selected</span> + <button className="m-0 p-0 font-bold">✕</button> </div> + )} + </div> + </div> - {/* Table */} - <div className="border border-blue-200 rounded max-h-[400px] overflow-y-auto + {/* Table */} + <div + className="border border-blue-200 rounded max-h-[400px] overflow-y-auto [&::-webkit-scrollbar]:h-[4px] [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-blue-100 [&::-webkit-scrollbar-thumb]:bg-blue-300 - "> - <table className="text-xs table-fixed border-collapse w-full"> - <tbody className="w-full"> - {table.getRowModel().rows.map((row, idx) => ( - <tr key={row.id} - ref={(el) => { - rowRefs.current[idx] = el; - }} - onClick={() => row.toggleSelected()} - className={`cursor-pointer transition-colors w-full - ${row.getIsSelected() ? 'bg-blue-600 ' : ' ' + " + > + <table className="text-xs table-fixed border-collapse w-full"> + <tbody className="w-full"> + {table.getRowModel().rows.map((row, idx) => ( + <tr + key={row.id} + ref={(el) => { + rowRefs.current[idx] = el; + }} + onClick={() => row.toggleSelected()} + className={`cursor-pointer transition-colors w-full + ${ + row.getIsSelected() ? "bg-blue-600 " : " " }`} - > - - {row.getVisibleCells().map((cell) => { - const align = cell.column.columnDef.meta?.align || 'left'; - const alignClass = align === 'center' ? 'text-center' : - align === 'right' ? 'text-right' : 'text-left'; - - return ( - <td key={cell.id} - className={`py-2 ${alignClass}`} - style={{ - width: `${cell.column.getSize()}px`, - }} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - </td> - ) - })} - - </tr> - ))} - </tbody> - </table> - </div> - - {/* Empty State */} - {table.getRowModel().rows.length === 0 && ( - <div className="p-12 text-center text-blue-50 font-bold"> - No items found matching your search - </div> - )} + > + {row.getVisibleCells().map((cell) => { + const align = cell.column.columnDef.meta?.align || "left"; + const alignClass = + align === "center" + ? "text-center" + : align === "right" + ? "text-right" + : "text-left"; + + return ( + <td + key={cell.id} + className={`py-2 ${alignClass}`} + style={{ + width: `${cell.column.getSize()}px`, + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + </td> + ); + })} + </tr> + ))} + </tbody> + </table> + </div> + + {/* Empty State */} + {table.getRowModel().rows.length === 0 && ( + <div className="p-12 text-center text-blue-50 font-bold"> + No items found matching your search + </div> + )} - {/* Footer */} - <div className="px-2 py-2 flex justify-between items-center + {/* Footer */} + <div + className="px-2 py-2 flex justify-between items-center text-xs font-mono font-semibold text-blue-950 - "> - <span> - Showing {table.getRowModel().rows.length} of {activeQuestion?.choices?.length} choices - </span> - {selectedCount > 0 && ( - <span> - {selectedCount} choice{selectedCount !== 1 ? 's' : ''} selected - </span> - )} - </div> - - </> - ) - -} + " + > + <span> + Showing {table.getRowModel().rows.length} of{" "} + {activeQuestion?.choices?.length} choices + </span> + {selectedCount > 0 && ( + <span> + {selectedCount} choice{selectedCount !== 1 ? "s" : ""} selected + </span> + )} + </div> + </> + ); +}; const ProfilingQuestionTitle = () => { - const activeQuestion = useSelector(selectActiveQuestion); - const question_active_tasks = activeQuestion?.importance?.task_count ?? 0 + const activeQuestion = useSelector(selectActiveQuestion); + const question_active_tasks = activeQuestion?.importance?.task_count ?? 0; - if (!activeQuestion) return null; - - let text = activeQuestion?.question_text + if (!activeQuestion) return null; - if (activeQuestion.explanation_template && activeQuestion._validation?.isComplete && activeQuestion._metadata.answers.length == 1) { - const template = activeQuestion.explanation_template; - const pre_code = activeQuestion._metadata.answers[0]; + let text = activeQuestion?.question_text; - const choice = activeQuestion.choices?.find(c => c.choice_id === pre_code) - const choice_text = choice ? choice.choice_text : "your answer" + if ( + activeQuestion.explanation_template && + activeQuestion._validation?.isComplete && + activeQuestion._metadata.answers.length == 1 + ) { + const template = activeQuestion.explanation_template; + const pre_code = activeQuestion._metadata.answers[0]; - text = template.replace("{answer}", choice_text) - } + const choice = activeQuestion.choices?.find( + (c) => c.choice_id === pre_code, + ); + const choice_text = choice ? choice.choice_text : "your answer"; + text = template.replace("{answer}", choice_text); + } - return ( - <div className="flex justify-between items-center px-2"> - - <h3 className="text-left text-sm font-bold m-0 p-0 + return ( + <div className="flex justify-between items-center px-2"> + <h3 + className="text-left text-sm font-bold m-0 p-0 inline-block - "> - {text} - </h3> - - <div className="max-w-[70px] text-center m-0 p-0"> - <h4 className="text-xs font-bold"> - {question_active_tasks.toLocaleString()} - </h4> - <h5 className="text-[10px]"> - Surveys use - this question - </h5> - </div> - </div> - ) -} + " + > + {text} + </h3> + + <div className="max-w-[70px] text-center m-0 p-0"> + <h4 className="text-xs font-bold"> + {question_active_tasks.toLocaleString()} + </h4> + <h5 className="text-[10px]">Surveys use this question</h5> + </div> + </div> + ); +}; const ProfilingQuestionSubmitButton = () => { - const dispatch = useAppDispatch() - - const activeQuestion = useSelector(selectActiveQuestion); - const activeQuestionValidation = useSelector(selectActiveQuestionValidation) - const bpuid = useAppSelector(state => state.app.bpuid) - - const handleOnClick = () => { - if (!activeQuestion) return - if (!activeQuestionValidation?.isComplete) return - if (!bpuid) return; - - dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid })) - } - - return ( - <button - type="submit" - className="cursor-pointer text-sm font-semibold + const dispatch = useAppDispatch(); + + const activeQuestion = useSelector(selectActiveQuestion); + const activeQuestionValidation = useSelector(selectActiveQuestionValidation); + const bpuid = useAppSelector((state) => state.app.bpuid); + + const handleOnClick = () => { + if (!activeQuestion) return; + if (!activeQuestionValidation?.isComplete) return; + if (!bpuid) return; + + dispatch( + saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }), + ); + }; + + return ( + <button + type="submit" + className="cursor-pointer text-sm font-semibold bg-blue-700 text-white py-1 px-4 rounded hover:bg-blue-900 min-w-[148px] disabled:bg-blue-700 disabled:text-rose-300 disabled:cursor-not-allowed " - disabled={!activeQuestionValidation?.isComplete} - onClick={handleOnClick} - > - Answer Question - </button> - ) -} - + disabled={!activeQuestionValidation?.isComplete} + onClick={handleOnClick} + > + Answer Question + </button> + ); +}; export const ProfileQuestionFull = () => { - const activeQuestion = useSelector(selectActiveQuestion); - - const renderContent = () => { - if (!activeQuestion) return null; - switch (activeQuestion.question_type) { - case 'TE': - return <TextEntry /> - case 'MC': - return <MultipleChoice /> - default: - return <MultipleChoice /> - } - }; + const activeQuestion = useSelector(selectActiveQuestion); + + const renderContent = () => { + if (!activeQuestion) return null; + switch (activeQuestion.question_type) { + case "TE": + return <TextEntry />; + case "MC": + return <MultipleChoice />; + default: + return <MultipleChoice />; + } + }; - if (!activeQuestion) return null + if (!activeQuestion) return null; - return ( - <> - {/* {answer && answer.processing && ( + return ( + <> + {/* {answer && answer.processing && ( <motion.div className="bg-gray-300" initial={{ width: "0%" }} @@ -540,104 +566,111 @@ export const ProfileQuestionFull = () => { /> )} */} - {renderContent()} - </> - ) -} + {renderContent()} + </> + ); +}; const PaginationIcon: React.FC<{ - question: ProfileQuestion, idx: number, + question: ProfileQuestion; + idx: number; }> = ({ question, idx }) => { - const dispatch = useAppDispatch() + const dispatch = useAppDispatch(); - // const answers = useAppSelector(state => state.answers) - const isActive = question._metadata?.isActive ?? false - const completed = question._validation?.isComplete ?? false + // const answers = useAppSelector(state => state.answers) + const isActive = question._metadata?.isActive ?? false; + const completed = question._validation?.isComplete ?? false; - const handleSetQuestion = (evt: MouseEvent<HTMLAnchorElement>) => { - if (completed) { - evt.preventDefault() - } else { - dispatch(setActiveQuestion(question.question_id!)) - } + const handleSetQuestion = (evt: MouseEvent<HTMLAnchorElement>) => { + if (completed) { + evt.preventDefault(); + } else { + dispatch(setActiveQuestion(question.question_id!)); } - - return ( - <PaginationItem> - <PaginationLink - href="#" - title={question.question_text} - isActive={isActive} - aria-disabled={!!completed} - - onClick={handleSetQuestion} - className={clsx("cursor-pointer rounded border border-blue-300 text-blue-300 text-[10px] w-7 h-4", - { - "pointer-events-none cursor-not-allowed": completed, - "bg-blue-800 border-blue-800": isActive, - })} - > - {idx + 1} - </PaginationLink> - </PaginationItem> - ) -} + }; + + return ( + <PaginationItem> + <PaginationLink + href="#" + title={question.question_text} + isActive={isActive} + aria-disabled={!!completed} + onClick={handleSetQuestion} + className={clsx( + "cursor-pointer rounded border border-blue-300 text-blue-300 text-[10px] w-7 h-4", + { + "pointer-events-none cursor-not-allowed": completed, + "bg-blue-800 border-blue-800": isActive, + }, + )} + > + {idx + 1} + </PaginationLink> + </PaginationItem> + ); +}; const ProfilingQuestionPagination = () => { - const dispatch = useAppDispatch() - - const questions = useSelector(selectAllQuestions) - const activeQuestion = useSelector(selectActiveQuestion) - - // All the variables needed for a sliding window - const q_idx = questions.findIndex(q => q.question_id === activeQuestion!.question_id) - - const handleNextClick = () => { - dispatch(goToNextQuestion()); - }; - - const questionsWindow = ( - items: ProfileQuestion[], currentIndex: number, windowSize: number = 5 - ): ProfileQuestion[] => { - const half: number = Math.floor(windowSize / 2) - const total: number = items.length - let start: number = currentIndex - half - let end: number = currentIndex + half + 1 - - if (start < 0) { - end += Math.abs(start) - start = 0 - } - - // Adjust if window goes past the end - if (end > total) { - const overflow: number = end - total - start = Math.max(0, start - overflow) - end = total - } + const dispatch = useAppDispatch(); + + const questions = useSelector(selectAllQuestions); + const activeQuestion = useSelector(selectActiveQuestion); + + // All the variables needed for a sliding window + const q_idx = questions.findIndex( + (q) => q.question_id === activeQuestion!.question_id, + ); + + const handleNextClick = () => { + dispatch(goToNextQuestion()); + }; + + const questionsWindow = ( + items: ProfileQuestion[], + currentIndex: number, + windowSize: number = 5, + ): ProfileQuestion[] => { + const half: number = Math.floor(windowSize / 2); + const total: number = items.length; + let start: number = currentIndex - half; + let end: number = currentIndex + half + 1; + + if (start < 0) { + end += Math.abs(start); + start = 0; + } - return items.slice(start, end) + // Adjust if window goes past the end + if (end > total) { + const overflow: number = end - total; + start = Math.max(0, start - overflow); + end = total; } - return ( - <Pagination> - <PaginationContent> - { - questionsWindow(questions, q_idx).map(q => { - return <PaginationIcon - key={q.question_id} - question={q} - idx={questions.findIndex(qq => qq.question_id === q.question_id)} - /> - }) - } - - <PaginationItem> - <PaginationLink - aria-label="Go to next page" - size="default" - - className="rounded text-[10px] p-0 m-0 + return items.slice(start, end); + }; + + return ( + <Pagination> + <PaginationContent> + {questionsWindow(questions, q_idx).map((q) => { + return ( + <PaginationIcon + key={q.question_id} + question={q} + idx={questions.findIndex( + (qq) => qq.question_id === q.question_id, + )} + /> + ); + })} + + <PaginationItem> + <PaginationLink + aria-label="Go to next page" + size="default" + className="rounded text-[10px] p-0 m-0 text-blue-300 border border-blue-100 text-blue-100 hover:bg-blue-800 hover:border-blue-800 hover:text-white @@ -645,115 +678,111 @@ const ProfilingQuestionPagination = () => { h-4 w-13 sm:w-14 " - onClick={handleNextClick} - href="#" - > - <span className="block">Next</span> - <ChevronRightIcon /> - </PaginationLink> - - </PaginationItem> - </PaginationContent> - </Pagination> - ) -} + onClick={handleNextClick} + href="#" + > + <span className="block">Next</span> + <ChevronRightIcon /> + </PaginationLink> + </PaginationItem> + </PaginationContent> + </Pagination> + ); +}; const ProfilingHeader = () => { - const questions = useSelector(questionsSelectors.selectAll); + const questions = useSelector(questionsSelectors.selectAll); - return ( - <h2 className="text-xs font-mono font-bold p-0 m-0"> - {questions.length} Profiling Questions - </h2> - ) -} + return ( + <h2 className="text-xs font-mono font-bold p-0 m-0"> + {questions.length} Profiling Questions + </h2> + ); +}; const ProfilingModalBody = () => { - const activeQuestion = useSelector(selectActiveQuestion); - - if (!activeQuestion) { - return ( - <div className="p-5"> - <h3 className="text-lg font-bold">No Profiling Questions Available</h3> - <p className="my-0 text-sm text-gray-600">Please check back later.</p> - </div> - ) - } + const activeQuestion = useSelector(selectActiveQuestion); + if (!activeQuestion) { return ( - <div className="relative flex flex-col"> - {/* <ProfilingQuestionPagination /> */} - - <div className="flex-shrink-0"> - <ProfilingQuestionTitle /> - </div> - - {/* Scrollable Content */} - <div className="flex-1 p-1"> - <ProfileQuestionFull key={activeQuestion.question_id} /> - </div> - - {/* Sticky Footer */} - <div className="flex-shrink-0 bg-blue-600 p-1 flex justify-between - "> - <div className="justify-start"> - <ProfilingQuestionPagination /> - </div> - <div className="justify-end"> - <ProfilingQuestionSubmitButton /> - </div> - </div> - + <div className="p-5"> + <h3 className="text-lg font-bold">No Profiling Questions Available</h3> + <p className="my-0 text-sm text-gray-600">Please check back later.</p> + </div> + ); + } + + return ( + <div className="relative flex flex-col"> + {/* <ProfilingQuestionPagination /> */} + + <div className="flex-shrink-0"> + <ProfilingQuestionTitle /> + </div> + + {/* Scrollable Content */} + <div className="flex-1 p-1"> + <ProfileQuestionFull key={activeQuestion.question_id} /> + </div> + + {/* Sticky Footer */} + <div + className="flex-shrink-0 bg-blue-600 p-1 flex justify-between + " + > + <div className="justify-start"> + <ProfilingQuestionPagination /> </div> - ) -} + <div className="justify-end"> + <ProfilingQuestionSubmitButton /> + </div> + </div> + </div> + ); +}; const Profiling = () => { - /* This is the main UI for any User Profiling (before entering a task). It's + /* This is the main UI for any User Profiling (before entering a task). It's primary window and position should be dedictated by it's parent so this can be positioned as a window or a sidebar as needed. */ - const [showProfiling, setShowProfiling] = useState(false); + const [showProfiling, setShowProfiling] = useState(false); - const dispatch = useAppDispatch() - const bpuid = useAppSelector(state => state.app.bpuid) + const dispatch = useAppDispatch(); + const bpuid = useAppSelector((state) => state.app.bpuid); - useEffect(() => { - if (!bpuid) return; - dispatch(fetchNewQuestions(bpuid)); - }, [bpid, bpuid]) + useEffect(() => { + if (!bpuid) return; + dispatch(fetchNewQuestions(bpuid)); + }, [bpid, bpuid]); - if (!bpuid) return null; + if (!bpuid) return null; - return ( - <div className={clsx( - "fixed rounded bg-blue-500 text-white shadow-lg z-60 \ + return ( + <div + className={clsx( + "fixed rounded bg-blue-500 text-white shadow-lg z-60 \ bottom-1 sm:bottom-6 \ left-1 sm:left-2", - showProfiling ? "w-[98vw] sm:w-[400px]" : "w-[220px]" - )} - > - - <div className="bg-blue-600 rounded border-white-300 + showProfiling ? "w-[98vw] sm:w-[400px]" : "w-[220px]", + )} + > + <div + className="bg-blue-600 rounded border-white-300 cursor-pointer py-1 px-4 hover:bg-blue-700 transition-colors duration-200" - onClick={() => setShowProfiling(!showProfiling)} - > - <ProfilingHeader /> - - <h4 className="text-[10px] text-center italic text-white-300 py-0"> - (Click to {showProfiling ? 'hide' : 'show'}) - </h4> - </div> - - {showProfiling && ( - <ProfilingModalBody /> - )} - - </div> - ) -} - -export { - Profiling + onClick={() => setShowProfiling(!showProfiling)} + > + <ProfilingHeader /> + + <h4 className="text-[10px] text-center italic text-white-300 py-0"> + (Click to {showProfiling ? "hide" : "show"}) + </h4> + </div> + + {showProfiling && <ProfilingModalBody />} + </div> + ); }; + +export { Profiling }; diff --git a/jb-ui/src/components/Wallet.tsx b/jb-ui/src/components/Wallet.tsx index e4fc694..933dc9a 100644 --- a/jb-ui/src/components/Wallet.tsx +++ b/jb-ui/src/components/Wallet.tsx @@ -1,464 +1,491 @@ import { - UserLedgerTransactionsResponse, - UserLedgerTransactionsResponseTransactionsInner, - WalletApi + UserLedgerTransactionsResponse, + UserLedgerTransactionsResponseTransactionsInner, + WalletApi, } from "@/api_fsb"; import { useAppDispatch, useAppSelector } from "@/hooks"; -import { bpid, formatCentsToUSD, } from "@/lib/utils"; +import { bpid, formatCentsToUSD } from "@/lib/utils"; import { - setTxPagination, setTxTotalItems, setTxTotalPages, - setUserLedgerSummary, - setUserLedgerTxs + setTxPagination, + setTxTotalItems, + setTxTotalPages, + setUserLedgerSummary, + setUserLedgerTxs, } from "@/models/appSlice"; import { - RowData, createColumnHelper, flexRender, getCoreRowModel, - useReactTable + RowData, + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, } from "@tanstack/react-table"; import moment from "moment"; import { useEffect, useState } from "react"; -declare module '@tanstack/react-table' { - interface ColumnMeta<TData extends RowData, TValue> { - align?: 'left' | 'center' | 'right'; - } +declare module "@tanstack/react-table" { + interface ColumnMeta<TData extends RowData, TValue> { + align?: "left" | "center" | "right"; + } } // Function to calculate background color based on balance const getBackgroundColor = (balance: number | undefined): string => { - if (balance === undefined || balance === null) return 'bg-blue-600'; - - if (balance > 0) { - return 'bg-blue-600'; - } else if (balance <= -100) { - return 'bg-red-600'; - } else { - // Interpolate between blue and red for values between 0 and -100 - // progress goes from 0 (at balance = 0) to 1 (at balance = -100) - const progress = Math.abs(balance) / 100; - - // Blue RGB: (37, 99, 235) - Tailwind blue-600 - // Red RGB: (220, 38, 38) - Tailwind red-600 - const r = Math.round(37 + (220 - 37) * progress); - const g = Math.round(99 + (38 - 99) * progress); - const b = Math.round(235 + (38 - 235) * progress); - - return `rgb(${r}, ${g}, ${b})`; - } + if (balance === undefined || balance === null) return "bg-blue-600"; + + if (balance > 0) { + return "bg-blue-600"; + } else if (balance <= -100) { + return "bg-red-600"; + } else { + // Interpolate between blue and red for values between 0 and -100 + // progress goes from 0 (at balance = 0) to 1 (at balance = -100) + const progress = Math.abs(balance) / 100; + + // Blue RGB: (37, 99, 235) - Tailwind blue-600 + // Red RGB: (220, 38, 38) - Tailwind red-600 + const r = Math.round(37 + (220 - 37) * progress); + const g = Math.round(99 + (38 - 99) * progress); + const b = Math.round(235 + (38 - 235) * progress); + + return `rgb(${r}, ${g}, ${b})`; + } }; - const WalletHeader = () => { - const amount = useAppSelector(state => state.app.userWalletBalance)?.amount ?? 0 - - const bgStyle = typeof getBackgroundColor(amount) === 'string' && - getBackgroundColor(amount).startsWith('bg-') - ? {} - : { backgroundColor: getBackgroundColor(amount) }; - - const bgClass = typeof getBackgroundColor(amount) === 'string' && - getBackgroundColor(amount).startsWith('bg-') - ? getBackgroundColor(amount) - : ''; - - return ( - <div className={`w-full py-1 px-1 text-white rounded ${bgClass}`} - style={bgStyle} - > - <h2 className="text-xs font-semibold flex justify-between items-center"> - <span className="text-left">User Wallet:</span> - - {amount >= 0 ? ( - <span className='text-emerald-50'> - {`${formatCentsToUSD(Math.abs(amount))}`} - </span> - ) : ( - <span className='text-rose-50'> - {`(${formatCentsToUSD(Math.abs(amount))})`} - </span> - )} - - </h2> - </div> - ) + const amount = + useAppSelector((state) => state.app.userWalletBalance)?.amount ?? 0; + + const bgStyle = + typeof getBackgroundColor(amount) === "string" && + getBackgroundColor(amount).startsWith("bg-") + ? {} + : { backgroundColor: getBackgroundColor(amount) }; + + const bgClass = + typeof getBackgroundColor(amount) === "string" && + getBackgroundColor(amount).startsWith("bg-") + ? getBackgroundColor(amount) + : ""; + + return ( + <div + className={`w-full py-1 px-1 text-white rounded ${bgClass}`} + style={bgStyle} + > + <h2 className="text-xs font-semibold flex justify-between items-center"> + <span className="text-left">User Wallet:</span> + + {amount >= 0 ? ( + <span className="text-emerald-50"> + {`${formatCentsToUSD(Math.abs(amount))}`} + </span> + ) : ( + <span className="text-rose-50"> + {`(${formatCentsToUSD(Math.abs(amount))})`} + </span> + )} + </h2> + </div> + ); }; const WalletSummary = () => { - const summary = useAppSelector(state => state.app.userLedgerSummary) - - const survey_completes = summary?.bp_payment?.entry_count ?? 0 - const survey_completes_f = (survey_completes ?? 0).toLocaleString('en-US') - - const survey_adjustments = summary?.bp_adjustment?.entry_count ?? 0 - const survey_adjustments_f = (survey_adjustments ?? 0).toLocaleString('en-US') - - const min_payout: number = summary?.bp_payment?.min_amount ?? 0 - const max_payout: number = summary?.bp_payment?.max_amount ?? 0 - const adj_total: number = summary?.bp_adjustment?.total_amount ?? 0 - const user_payments_total: number = (summary?.user_payout_request?.total_amount ?? 0) * -1 - - return ( - <div className="grid grid-cols-[auto_1fr] text-xs"> - <div className="label text-left">Completes:</div> - <div className="value text-right">{survey_completes_f} surveys</div> - - <div className="label text-left">Avg Payouts:</div> - <div className="value text-right">{formatCentsToUSD(min_payout)} – {formatCentsToUSD(max_payout)}</div> - - <div className="label text-left">Survey Adjustments:</div> - <div className="value text-right">{survey_adjustments_f}</div> - - <div className="label text-left">Adjustment Amount:</div> - <div className="value text-right"> - {adj_total >= 0 ? ( - <span className='text-emerald-300'> - {`${formatCentsToUSD(adj_total)}`} - </span> - ) : ( - <span className='text-rose-300'> - {`(${formatCentsToUSD(Math.abs(adj_total))})`} - </span> - )} - </div> - - <div className="label text-left">User Payments:</div> - <div className="value text-right"> - {user_payments_total >= 0 ? ( - <span className='text-emerald-300'> - {`${formatCentsToUSD(user_payments_total)}`} - </span> - ) : ( - <span className='text-rose-300'> - {`(${formatCentsToUSD(Math.abs(user_payments_total))})`} - </span> - )} - - </div> - </div > - ) + const summary = useAppSelector((state) => state.app.userLedgerSummary); + + const survey_completes = summary?.bp_payment?.entry_count ?? 0; + const survey_completes_f = (survey_completes ?? 0).toLocaleString("en-US"); + + const survey_adjustments = summary?.bp_adjustment?.entry_count ?? 0; + const survey_adjustments_f = (survey_adjustments ?? 0).toLocaleString( + "en-US", + ); + + const min_payout: number = summary?.bp_payment?.min_amount ?? 0; + const max_payout: number = summary?.bp_payment?.max_amount ?? 0; + const adj_total: number = summary?.bp_adjustment?.total_amount ?? 0; + const user_payments_total: number = + (summary?.user_payout_request?.total_amount ?? 0) * -1; + + return ( + <div className="grid grid-cols-[auto_1fr] text-xs"> + <div className="label text-left">Completes:</div> + <div className="value text-right">{survey_completes_f} surveys</div> + + <div className="label text-left">Avg Payouts:</div> + <div className="value text-right"> + {formatCentsToUSD(min_payout)} – {formatCentsToUSD(max_payout)} + </div> + + <div className="label text-left">Survey Adjustments:</div> + <div className="value text-right">{survey_adjustments_f}</div> + + <div className="label text-left">Adjustment Amount:</div> + <div className="value text-right"> + {adj_total >= 0 ? ( + <span className="text-emerald-300"> + {`${formatCentsToUSD(adj_total)}`} + </span> + ) : ( + <span className="text-rose-300"> + {`(${formatCentsToUSD(Math.abs(adj_total))})`} + </span> + )} + </div> + + <div className="label text-left">User Payments:</div> + <div className="value text-right"> + {user_payments_total >= 0 ? ( + <span className="text-emerald-300"> + {`${formatCentsToUSD(user_payments_total)}`} + </span> + ) : ( + <span className="text-rose-300"> + {`(${formatCentsToUSD(Math.abs(user_payments_total))})`} + </span> + )} + </div> + </div> + ); }; const WalletTransactionsHeader = () => { - const tx_cnt = useAppSelector(state => state.app.txTotalItems) - const txt_cnt_f = (tx_cnt ?? 0).toLocaleString('en-US') - - const [showTx, setShowTx] = useState(false); - - return ( - <div className="w-full bg-blue-600 text-white rounded text-left"> - <div className="hover:cursor-pointer hover:bg-blue-700 transition-colors duration-200" - onClick={() => setShowTx(!showTx)}> - - <h2 className="text-xs font-semibold rounded flex justify-between + const tx_cnt = useAppSelector((state) => state.app.txTotalItems); + const txt_cnt_f = (tx_cnt ?? 0).toLocaleString("en-US"); + + const [showTx, setShowTx] = useState(false); + + return ( + <div className="w-full bg-blue-600 text-white rounded text-left"> + <div + className="hover:cursor-pointer hover:bg-blue-700 transition-colors duration-200" + onClick={() => setShowTx(!showTx)} + > + <h2 + className="text-xs font-semibold rounded flex justify-between py-1 px-1" - onClick={() => setShowTx(!showTx)} - > - <span>Total Transactions:</span> <span>{txt_cnt_f}</span> - </h2> - - <h4 className="text-[10px] text-center italic text-blue-300 py-1" - onClick={() => setShowTx(!showTx)} - > - (Click to {showTx ? 'hide' : 'show'}) - </h4> - - </div> - - {showTx && ( - <> - <WalletTransactions /> - </> - )} + onClick={() => setShowTx(!showTx)} + > + <span>Total Transactions:</span> <span>{txt_cnt_f}</span> + </h2> - </div> - ) + <h4 + className="text-[10px] text-center italic text-blue-300 py-1" + onClick={() => setShowTx(!showTx)} + > + (Click to {showTx ? "hide" : "show"}) + </h4> + </div> + + {showTx && ( + <> + <WalletTransactions /> + </> + )} + </div> + ); }; - const WalletTransactions = () => { - const dispatch = useAppDispatch() - const bpuid = useAppSelector(state => state.app.bpuid) - const userLedgerTxs = useAppSelector(state => state.app.userLedgerTxs); - - const txPagination = useAppSelector(state => state.app.txPagination); - const txTotalPages = useAppSelector(state => state.app.txTotalPages); - - useEffect(() => { - if (!bpuid) return; - - new WalletApi().getUserTransactionHistoryProductIdTransactionHistoryGet( - bpid, // productId - bpuid, // bpuid - undefined, // createdAfter - undefined, // createdBefore - "-created", // orderBy - txPagination.pageIndex + 1, // page - txPagination.pageSize // pageSize - ).then(res => { - const response = res.data as UserLedgerTransactionsResponse; - console.log("Wallet: fetched user ledger", response); - dispatch(setUserLedgerSummary(response.summary)); - - dispatch(setUserLedgerTxs(response.transactions ?? [])) - dispatch(setTxTotalItems(response.total!)) - dispatch(setTxTotalPages(response.pages!)) - }); - - }, [bpuid, txPagination.pageIndex, txPagination.pageSize]); - - const columnHelper = createColumnHelper<UserLedgerTransactionsResponseTransactionsInner>() - const columns = [ - columnHelper.accessor('created', { - header: () => 'Date', - cell: (info) => moment(info.getValue()).format('M/D/YY H:mm'), - size: 110, - meta: { - align: 'left' - } - }), - columnHelper.accessor('description', { - header: () => 'Type', - cell: props => { - const desc = props.getValue(); - switch (desc) { - case 'Task Complete': - return 'Survey 🎉'; - case 'Task Adjustment': - return 'Reject ⚠️'; - case 'Compensation Bonus': - return 'Bonus 🎁'; - case 'HIT Bonus': - return 'Bonus'; - case 'HIT Reward': - return 'Assignment'; - default: - return desc; - } - }, - size: 80, - meta: { - align: 'left' - } - }), - columnHelper.accessor('amount', { - header: () => 'Amount', - cell: (props) => { - const val = props.renderValue() as number; - const isPositive = val >= 0; - const emoji = isPositive ? "⬆\uFE0E" : "⬇\uFE0E"; - const colorClass = isPositive ? 'font-variant-emoji-text text-emerald-300' : 'font-variant-emoji-text text-rose-300'; + const dispatch = useAppDispatch(); + const bpuid = useAppSelector((state) => state.app.bpuid); + const userLedgerTxs = useAppSelector((state) => state.app.userLedgerTxs); + + const txPagination = useAppSelector((state) => state.app.txPagination); + const txTotalPages = useAppSelector((state) => state.app.txTotalPages); + + useEffect(() => { + if (!bpuid) return; + + new WalletApi() + .getUserTransactionHistoryProductIdTransactionHistoryGet( + bpid, // productId + bpuid, // bpuid + undefined, // createdAfter + undefined, // createdBefore + "-created", // orderBy + txPagination.pageIndex + 1, // page + txPagination.pageSize, // pageSize + ) + .then((res) => { + const response = res.data as UserLedgerTransactionsResponse; + console.log("Wallet: fetched user ledger", response); + dispatch(setUserLedgerSummary(response.summary)); + + dispatch(setUserLedgerTxs(response.transactions ?? [])); + dispatch(setTxTotalItems(response.total!)); + dispatch(setTxTotalPages(response.pages!)); + }); + }, [bpuid, txPagination.pageIndex, txPagination.pageSize]); + + const columnHelper = + createColumnHelper<UserLedgerTransactionsResponseTransactionsInner>(); + const columns = [ + columnHelper.accessor("created", { + header: () => "Date", + cell: (info) => moment(info.getValue()).format("M/D/YY H:mm"), + size: 110, + meta: { + align: "left", + }, + }), + columnHelper.accessor("description", { + header: () => "Type", + cell: (props) => { + const desc = props.getValue(); + switch (desc) { + case "Task Complete": + return "Survey 🎉"; + case "Task Adjustment": + return "Reject ⚠️"; + case "Compensation Bonus": + return "Bonus 🎁"; + case "HIT Bonus": + return "Bonus"; + case "HIT Reward": + return "Assignment"; + default: + return desc; + } + }, + size: 80, + meta: { + align: "left", + }, + }), + columnHelper.accessor("amount", { + header: () => "Amount", + cell: (props) => { + const val = props.renderValue() as number; + const isPositive = val >= 0; + const emoji = isPositive ? "⬆\uFE0E" : "⬇\uFE0E"; + const colorClass = isPositive + ? "font-variant-emoji-text text-emerald-300" + : "font-variant-emoji-text text-rose-300"; + + return ( + <span className={colorClass}> + {`${emoji} ${formatCentsToUSD(Math.abs(val))}`} + </span> + ); + }, + size: 70, + meta: { + align: "center", + }, + }), + columnHelper.accessor("balance_after", { + header: () => "Balance", + cell: (props) => { + const val = props.renderValue() as number; + const isPositive = val >= 0; + const colorClass = isPositive ? "text-emerald-300" : "text-rose-300"; + + return ( + <> + {isPositive ? ( + <span className={colorClass}> + {`${formatCentsToUSD(Math.abs(val))}`} + </span> + ) : ( + <span className={colorClass}> + {`(${formatCentsToUSD(Math.abs(val))})`} + </span> + )} + </> + ); + }, + size: 70, + meta: { + align: "center", + }, + }), + ]; + + const table = useReactTable({ + data: userLedgerTxs, + columns: columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: txTotalPages, + onPaginationChange: (updater) => { + dispatch( + setTxPagination( + typeof updater === "function" ? updater(txPagination) : updater, + ), + ); + }, + state: { + pagination: txPagination, + }, + }); + + return ( + <div className="w-full border-t-1 border-blue-800 p-2"> + <table className="text-xs table-fixed border-collapse"> + <thead className="font-bold"> + {table.getHeaderGroups().map((headerGroup) => ( + <tr key={headerGroup.id}> + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align || "left"; + const alignClass = + align === "center" + ? "text-center" + : align === "right" + ? "text-right" + : "text-left"; return ( - <span className={colorClass}> - {`${emoji} ${formatCentsToUSD(Math.abs(val))}`} - </span> - ) - }, - size: 70, - meta: { - align: 'center' - } - }), - columnHelper.accessor('balance_after', { - header: () => 'Balance', - cell: (props) => { - const val = props.renderValue() as number; - const isPositive = val >= 0; - const colorClass = isPositive ? 'text-emerald-300' : 'text-rose-300'; + <th + key={header.id} + className={`p-0 m-0 ${alignClass}`} + style={{ + width: `${header.column.getSize()}px`, + }} + > + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </th> + ); + })} + </tr> + ))} + </thead> + <tbody> + {table.getRowModel().rows.map((row) => ( + <tr key={row.id}> + {row.getVisibleCells().map((cell) => { + const align = cell.column.columnDef.meta?.align || "left"; + const alignClass = + align === "center" + ? "text-center" + : align === "right" + ? "text-right" + : "text-left"; return ( - <> - {isPositive ? ( - <span className={colorClass}> - {`${formatCentsToUSD(Math.abs(val))}`} - </span> - ) : ( - <span className={colorClass}> - {`(${formatCentsToUSD(Math.abs(val))})`} - </span> - )} - </> - ) - }, - size: 70, - meta: { - align: 'center' - } - }), - ] - - const table = useReactTable({ - 'data': userLedgerTxs, - 'columns': columns, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - pageCount: txTotalPages, - onPaginationChange: (updater) => { - dispatch(setTxPagination( - typeof updater === 'function' ? updater(txPagination) : updater - )); - }, - state: { - pagination: txPagination, - }, - }); - - return ( - <div className="w-full border-t-1 border-blue-800 p-2"> - <table className="text-xs table-fixed border-collapse"> - <thead className="font-bold"> - {table.getHeaderGroups().map((headerGroup) => ( - - <tr key={headerGroup.id}> - {headerGroup.headers.map((header) => { - const align = header.column.columnDef.meta?.align || 'left'; - const alignClass = align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left'; - - return ( - <th - key={header.id} - className={`p-0 m-0 ${alignClass}`} - style={{ - width: `${header.column.getSize()}px`, - }} - > - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - </th> - ) - })} - </tr> - ))} - </thead> - <tbody> - {table.getRowModel().rows.map((row) => ( - <tr key={row.id}> - {row.getVisibleCells().map((cell) => { - const align = cell.column.columnDef.meta?.align || 'left'; - const alignClass = align === 'center' ? 'text-center' : - align === 'right' ? 'text-right' : 'text-left'; - - return ( - <td key={cell.id} - className={`p-0 ${alignClass}`} - style={{ - width: `${cell.column.getSize()}px`, - }} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - </td> - ) - })} - </tr> - ))} - </tbody> - - <tfoot> - <tr> - <td colSpan={columns.length} className="p-2"> - <div className="flex items-center justify-between text-xs"> - - {/* Page info */} - <div className="text-blue-50"> - Page {table.getState().pagination.pageIndex + 1} of{' '} - {table.getPageCount()} - </div> - - {/* Navigation buttons */} - <div className="flex gap-8 text-xs"> - <button - onClick={() => table.previousPage()} - disabled={!table.getCanPreviousPage()} - className="px-3 py-1 border border-blue-200 rounded + <td + key={cell.id} + className={`p-0 ${alignClass}`} + style={{ + width: `${cell.column.getSize()}px`, + }} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </td> + ); + })} + </tr> + ))} + </tbody> + + <tfoot> + <tr> + <td colSpan={columns.length} className="p-2"> + <div className="flex items-center justify-between text-xs"> + {/* Page info */} + <div className="text-blue-50"> + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + </div> + + {/* Navigation buttons */} + <div className="flex gap-8 text-xs"> + <button + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + className="px-3 py-1 border border-blue-200 rounded font-variant-emoji-text hover:cursor-pointer hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" - > - {'⬅\uFE0E'} - </button> - <button - onClick={() => table.nextPage()} - disabled={!table.getCanNextPage()} - className="px-3 py-1 border border-blue-200 rounded + > + {"⬅\uFE0E"} + </button> + <button + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + className="px-3 py-1 border border-blue-200 rounded font-variant-emoji-text hover:cursor-pointer hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" - > - {'⮕\uFE0E'} - </button> - </div> - - {/* Page size selector */} - <select - value={table.getState().pagination.pageSize} - onChange={e => table.setPageSize(Number(e.target.value))} - className="px-2 py-1 border border-blue-200 rounded + > + {"⮕\uFE0E"} + </button> + </div> + + {/* Page size selector */} + <select + value={table.getState().pagination.pageSize} + onChange={(e) => table.setPageSize(Number(e.target.value))} + className="px-2 py-1 border border-blue-200 rounded hover:cursor-pointer hover:bg-blue-700" - > - {[10, 20, 30, 40].map(pageSize => ( - <option key={pageSize} value={pageSize}> - Show {pageSize} - </option> - ))} - </select> - </div> - </td> - </tr> - </tfoot> - - </table> - </div> - ) + > + {[10, 20, 30, 40].map((pageSize) => ( + <option key={pageSize} value={pageSize}> + Show {pageSize} + </option> + ))} + </select> + </div> + </td> + </tr> + </tfoot> + </table> + </div> + ); }; - const Wallet = () => { - const dispatch = useAppDispatch() - const bpuid = useAppSelector(state => state.app.bpuid) - const pagination = useAppSelector(state => state.app.txPagination); - - useEffect(() => { - if (!bpuid) return; - - new WalletApi().getUserTransactionHistoryProductIdTransactionHistoryGet( - bpid, // productId - bpuid, // bpuid - undefined, // createdAfter - undefined, // createdBefore - "-created", // orderBy - pagination.pageIndex + 1, // page - pagination.pageSize // pageSize - ).then(res => { - const response = res.data as UserLedgerTransactionsResponse; - dispatch(setUserLedgerSummary(response.summary)); - dispatch(setUserLedgerTxs(response.transactions ?? [])) - - dispatch(setTxPagination({ - pageIndex: (response.page ?? 1) - 1, - pageSize: response.size ?? 10, - })) - dispatch(setTxTotalItems(response.total ?? 0)) - dispatch(setTxTotalPages(response.pages ?? 9)) - }); - - }, [bpuid]) - - if (!bpuid) return null; - - return ( - <div className="fixed top-2 left-2 p-2 rounded + const dispatch = useAppDispatch(); + const bpuid = useAppSelector((state) => state.app.bpuid); + const pagination = useAppSelector((state) => state.app.txPagination); + + useEffect(() => { + if (!bpuid) return; + + new WalletApi() + .getUserTransactionHistoryProductIdTransactionHistoryGet( + bpid, // productId + bpuid, // bpuid + undefined, // createdAfter + undefined, // createdBefore + "-created", // orderBy + pagination.pageIndex + 1, // page + pagination.pageSize, // pageSize + ) + .then((res) => { + const response = res.data as UserLedgerTransactionsResponse; + dispatch(setUserLedgerSummary(response.summary)); + dispatch(setUserLedgerTxs(response.transactions ?? [])); + + dispatch( + setTxPagination({ + pageIndex: (response.page ?? 1) - 1, + pageSize: response.size ?? 10, + }), + ); + dispatch(setTxTotalItems(response.total ?? 0)); + dispatch(setTxTotalPages(response.pages ?? 9)); + }); + }, [bpuid]); + + if (!bpuid) return null; + + return ( + <div + className="fixed top-2 left-2 p-2 rounded bg-blue-500 text-white shadow-lg z-50 font-mono max-h-[calc(100vh-2.5rem)] overflow-y-auto max-w-[96vw] - "> - <WalletHeader /> - <WalletSummary /> - <WalletTransactionsHeader /> - </div> - ) + " + > + <WalletHeader /> + <WalletSummary /> + <WalletTransactionsHeader /> + </div> + ); }; - -export default Wallet;
\ No newline at end of file +export default Wallet; diff --git a/jb-ui/src/components/ui/button.tsx b/jb-ui/src/components/ui/button.tsx index b5ea4ab..d2176b4 100644 --- a/jb-ui/src/components/ui/button.tsx +++ b/jb-ui/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot } from "radix-ui"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -35,8 +35,8 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } -) + }, +); function Button({ className, @@ -46,9 +46,9 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot.Root : "button" + const Comp = asChild ? Slot.Root : "button"; return ( <Comp @@ -58,7 +58,7 @@ function Button({ className={cn(buttonVariants({ variant, size, className }))} {...props} /> - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/jb-ui/src/components/ui/checkbox.tsx b/jb-ui/src/components/ui/checkbox.tsx index 0e2a6cd..f24ad45 100644 --- a/jb-ui/src/components/ui/checkbox.tsx +++ b/jb-ui/src/components/ui/checkbox.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Checkbox({ className, @@ -13,7 +13,7 @@ function Checkbox({ data-slot="checkbox" className={cn( "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} {...props} > @@ -24,7 +24,7 @@ function Checkbox({ <CheckIcon className="size-3.5" /> </CheckboxPrimitive.Indicator> </CheckboxPrimitive.Root> - ) + ); } -export { Checkbox } +export { Checkbox }; diff --git a/jb-ui/src/components/ui/confetti.tsx b/jb-ui/src/components/ui/confetti.tsx index e0d353a..57d3273 100644 --- a/jb-ui/src/components/ui/confetti.tsx +++ b/jb-ui/src/components/ui/confetti.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react" +import type { ReactNode } from "react"; import React, { createContext, forwardRef, @@ -7,30 +7,30 @@ import React, { useImperativeHandle, useMemo, useRef, -} from "react" +} from "react"; import type { GlobalOptions as ConfettiGlobalOptions, CreateTypes as ConfettiInstance, Options as ConfettiOptions, -} from "canvas-confetti" -import confetti from "canvas-confetti" +} from "canvas-confetti"; +import confetti from "canvas-confetti"; -import { Button } from "@/components/ui/button" +import { Button } from "@/components/ui/button"; type Api = { - fire: (options?: ConfettiOptions) => void -} + fire: (options?: ConfettiOptions) => void; +}; type Props = React.ComponentPropsWithRef<"canvas"> & { - options?: ConfettiOptions - globalOptions?: ConfettiGlobalOptions - manualstart?: boolean - children?: ReactNode -} + options?: ConfettiOptions; + globalOptions?: ConfettiGlobalOptions; + manualstart?: boolean; + children?: ReactNode; +}; -export type ConfettiRef = Api | null +export type ConfettiRef = Api | null; -const ConfettiContext = createContext<Api>({} as Api) +const ConfettiContext = createContext<Api>({} as Api); // Define component first const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => { @@ -40,76 +40,76 @@ const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => { manualstart = false, children, ...rest - } = props - const instanceRef = useRef<ConfettiInstance | null>(null) + } = props; + const instanceRef = useRef<ConfettiInstance | null>(null); const canvasRef = useCallback( (node: HTMLCanvasElement) => { if (node !== null) { - if (instanceRef.current) return + if (instanceRef.current) return; instanceRef.current = confetti.create(node, { ...globalOptions, resize: true, - }) + }); } else { if (instanceRef.current) { - instanceRef.current.reset() - instanceRef.current = null + instanceRef.current.reset(); + instanceRef.current = null; } } }, - [globalOptions] - ) + [globalOptions], + ); const fire = useCallback( async (opts = {}) => { try { - await instanceRef.current?.({ ...options, ...opts }) + await instanceRef.current?.({ ...options, ...opts }); } catch (error) { - console.error("Confetti error:", error) + console.error("Confetti error:", error); } }, - [options] - ) + [options], + ); const api = useMemo( () => ({ fire, }), - [fire] - ) + [fire], + ); - useImperativeHandle(ref, () => api, [api]) + useImperativeHandle(ref, () => api, [api]); useEffect(() => { if (!manualstart) { - ;(async () => { + (async () => { try { - await fire() + await fire(); } catch (error) { - console.error("Confetti effect error:", error) + console.error("Confetti effect error:", error); } - })() + })(); } - }, [manualstart, fire]) + }, [manualstart, fire]); return ( <ConfettiContext.Provider value={api}> <canvas ref={canvasRef} {...rest} /> {children} </ConfettiContext.Provider> - ) -}) + ); +}); // Set display name immediately -ConfettiComponent.displayName = "Confetti" +ConfettiComponent.displayName = "Confetti"; // Export as Confetti -export const Confetti = ConfettiComponent +export const Confetti = ConfettiComponent; interface ConfettiButtonProps extends React.ComponentProps<"button"> { options?: ConfettiOptions & - ConfettiGlobalOptions & { canvas?: HTMLCanvasElement } + ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }; } const ConfettiButtonComponent = ({ @@ -119,28 +119,28 @@ const ConfettiButtonComponent = ({ }: ConfettiButtonProps) => { const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => { try { - const rect = event.currentTarget.getBoundingClientRect() - const x = rect.left + rect.width / 2 - const y = rect.top + rect.height / 2 + const rect = event.currentTarget.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; await confetti({ ...options, origin: { x: x / window.innerWidth, y: y / window.innerHeight, }, - }) + }); } catch (error) { - console.error("Confetti button error:", error) + console.error("Confetti button error:", error); } - } + }; return ( <Button onClick={handleClick} {...props}> {children} </Button> - ) -} + ); +}; -ConfettiButtonComponent.displayName = "ConfettiButton" +ConfettiButtonComponent.displayName = "ConfettiButton"; -export const ConfettiButton = ConfettiButtonComponent +export const ConfettiButton = ConfettiButtonComponent; diff --git a/jb-ui/src/components/ui/dot-pattern.tsx b/jb-ui/src/components/ui/dot-pattern.tsx index 9522b7f..a217a9b 100644 --- a/jb-ui/src/components/ui/dot-pattern.tsx +++ b/jb-ui/src/components/ui/dot-pattern.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useId, useRef, useState } from "react" -import { motion } from "motion/react" +import React, { useEffect, useId, useRef, useState } from "react"; +import { motion } from "motion/react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; /** * DotPattern Component Props @@ -17,16 +17,16 @@ import { cn } from "@/lib/utils" * @param {boolean} [glow=false] - Whether dots should have a glowing animation effect */ interface DotPatternProps extends React.SVGProps<SVGSVGElement> { - width?: number - height?: number - x?: number - y?: number - cx?: number - cy?: number - cr?: number - className?: string - glow?: boolean - [key: string]: unknown + width?: number; + height?: number; + x?: number; + y?: number; + cx?: number; + cy?: number; + cr?: number; + className?: string; + glow?: boolean; + [key: string]: unknown; } /** @@ -71,22 +71,22 @@ export function DotPattern({ glow = false, ...props }: DotPatternProps) { - const id = useId() - const containerRef = useRef<SVGSVGElement>(null) - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) + const id = useId(); + const containerRef = useRef<SVGSVGElement>(null); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); useEffect(() => { const updateDimensions = () => { if (containerRef.current) { - const { width, height } = containerRef.current.getBoundingClientRect() - setDimensions({ width, height }) + const { width, height } = containerRef.current.getBoundingClientRect(); + setDimensions({ width, height }); } - } + }; - updateDimensions() - window.addEventListener("resize", updateDimensions) - return () => window.removeEventListener("resize", updateDimensions) - }, []) + updateDimensions(); + window.addEventListener("resize", updateDimensions); + return () => window.removeEventListener("resize", updateDimensions); + }, []); const dots = Array.from( { @@ -95,16 +95,16 @@ export function DotPattern({ Math.ceil(dimensions.height / height), }, (_, i) => { - const col = i % Math.ceil(dimensions.width / width) - const row = Math.floor(i / Math.ceil(dimensions.width / width)) + const col = i % Math.ceil(dimensions.width / width); + const row = Math.floor(i / Math.ceil(dimensions.width / width)); return { x: col * width + cx, y: row * height + cy, delay: Math.random() * 5, duration: Math.random() * 3 + 2, - } - } - ) + }; + }, + ); return ( <svg @@ -112,7 +112,7 @@ export function DotPattern({ aria-hidden="true" className={cn( "pointer-events-none absolute inset-0 h-full w-full text-neutral-400/80", - className + className, )} {...props} > @@ -152,5 +152,5 @@ export function DotPattern({ /> ))} </svg> - ) + ); } diff --git a/jb-ui/src/components/ui/field.tsx b/jb-ui/src/components/ui/field.tsx index db0dc12..77546e5 100644 --- a/jb-ui/src/components/ui/field.tsx +++ b/jb-ui/src/components/ui/field.tsx @@ -1,9 +1,9 @@ -import { useMemo } from "react" -import { cva, type VariantProps } from "class-variance-authority" +import { useMemo } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { return ( @@ -12,11 +12,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { className={cn( "flex flex-col gap-6", "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", - className + className, )} {...props} /> - ) + ); } function FieldLegend({ @@ -32,11 +32,11 @@ function FieldLegend({ "mb-3 font-medium", "data-[variant=legend]:text-base", "data-[variant=label]:text-sm", - className + className, )} {...props} /> - ) + ); } function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { @@ -45,11 +45,11 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="field-group" className={cn( "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", - className + className, )} {...props} /> - ) + ); } const fieldVariants = cva( @@ -73,8 +73,8 @@ const fieldVariants = cva( defaultVariants: { orientation: "vertical", }, - } -) + }, +); function Field({ className, @@ -89,7 +89,7 @@ function Field({ className={cn(fieldVariants({ orientation }), className)} {...props} /> - ) + ); } function FieldContent({ className, ...props }: React.ComponentProps<"div">) { @@ -98,11 +98,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) { data-slot="field-content" className={cn( "group/field-content flex flex-1 flex-col gap-1.5 leading-snug", - className + className, )} {...props} /> - ) + ); } function FieldLabel({ @@ -116,11 +116,11 @@ function FieldLabel({ "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4", "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", - className + className, )} {...props} /> - ) + ); } function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -129,11 +129,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { data-slot="field-label" className={cn( "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", - className + className, )} {...props} /> - ) + ); } function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { @@ -144,11 +144,11 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { "text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance", "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", - className + className, )} {...props} /> - ) + ); } function FieldSeparator({ @@ -156,7 +156,7 @@ function FieldSeparator({ className, ...props }: React.ComponentProps<"div"> & { - children?: React.ReactNode + children?: React.ReactNode; }) { return ( <div @@ -164,7 +164,7 @@ function FieldSeparator({ data-content={!!children} className={cn( "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", - className + className, )} {...props} > @@ -178,7 +178,7 @@ function FieldSeparator({ </span> )} </div> - ) + ); } function FieldError({ @@ -187,37 +187,37 @@ function FieldError({ errors, ...props }: React.ComponentProps<"div"> & { - errors?: Array<{ message?: string } | undefined> + errors?: Array<{ message?: string } | undefined>; }) { const content = useMemo(() => { if (children) { - return children + return children; } if (!errors?.length) { - return null + return null; } const uniqueErrors = [ ...new Map(errors.map((error) => [error?.message, error])).values(), - ] + ]; if (uniqueErrors?.length == 1) { - return uniqueErrors[0]?.message + return uniqueErrors[0]?.message; } return ( <ul className="ml-4 flex list-disc flex-col gap-1"> {uniqueErrors.map( (error, index) => - error?.message && <li key={index}>{error.message}</li> + error?.message && <li key={index}>{error.message}</li>, )} </ul> - ) - }, [children, errors]) + ); + }, [children, errors]); if (!content) { - return null + return null; } return ( @@ -229,7 +229,7 @@ function FieldError({ > {content} </div> - ) + ); } export { @@ -243,4 +243,4 @@ export { FieldSet, FieldContent, FieldTitle, -} +}; diff --git a/jb-ui/src/components/ui/label.tsx b/jb-ui/src/components/ui/label.tsx index ef7133a..4ef28a9 100644 --- a/jb-ui/src/components/ui/label.tsx +++ b/jb-ui/src/components/ui/label.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Label({ className, @@ -12,11 +12,11 @@ function Label({ data-slot="label" className={cn( "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", - className + className, )} {...props} /> - ) + ); } -export { Label } +export { Label }; diff --git a/jb-ui/src/components/ui/magic-card.tsx b/jb-ui/src/components/ui/magic-card.tsx index e6503d8..2b92e38 100644 --- a/jb-ui/src/components/ui/magic-card.tsx +++ b/jb-ui/src/components/ui/magic-card.tsx @@ -1,16 +1,16 @@ -import React, { useCallback, useEffect } from "react" -import { motion, useMotionTemplate, useMotionValue } from "motion/react" +import React, { useCallback, useEffect } from "react"; +import { motion, useMotionTemplate, useMotionValue } from "motion/react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; interface MagicCardProps { - children?: React.ReactNode - className?: string - gradientSize?: number - gradientColor?: string - gradientOpacity?: number - gradientFrom?: string - gradientTo?: string + children?: React.ReactNode; + className?: string; + gradientSize?: number; + gradientColor?: string; + gradientOpacity?: number; + gradientFrom?: string; + gradientTo?: string; } export function MagicCard({ @@ -22,49 +22,49 @@ export function MagicCard({ gradientFrom = "#9E7AFF", gradientTo = "#FE8BBB", }: MagicCardProps) { - const mouseX = useMotionValue(-gradientSize) - const mouseY = useMotionValue(-gradientSize) + const mouseX = useMotionValue(-gradientSize); + const mouseY = useMotionValue(-gradientSize); const reset = useCallback(() => { - mouseX.set(-gradientSize) - mouseY.set(-gradientSize) - }, [gradientSize, mouseX, mouseY]) + mouseX.set(-gradientSize); + mouseY.set(-gradientSize); + }, [gradientSize, mouseX, mouseY]); const handlePointerMove = useCallback( (e: React.PointerEvent<HTMLDivElement>) => { - const rect = e.currentTarget.getBoundingClientRect() - mouseX.set(e.clientX - rect.left) - mouseY.set(e.clientY - rect.top) + const rect = e.currentTarget.getBoundingClientRect(); + mouseX.set(e.clientX - rect.left); + mouseY.set(e.clientY - rect.top); }, - [mouseX, mouseY] - ) + [mouseX, mouseY], + ); useEffect(() => { - reset() - }, [reset]) + reset(); + }, [reset]); useEffect(() => { const handleGlobalPointerOut = (e: PointerEvent) => { if (!e.relatedTarget) { - reset() + reset(); } - } + }; const handleVisibility = () => { if (document.visibilityState !== "visible") { - reset() + reset(); } - } + }; - window.addEventListener("pointerout", handleGlobalPointerOut) - window.addEventListener("blur", reset) - document.addEventListener("visibilitychange", handleVisibility) + window.addEventListener("pointerout", handleGlobalPointerOut); + window.addEventListener("blur", reset); + document.addEventListener("visibilitychange", handleVisibility); return () => { - window.removeEventListener("pointerout", handleGlobalPointerOut) - window.removeEventListener("blur", reset) - document.removeEventListener("visibilitychange", handleVisibility) - } - }, [reset]) + window.removeEventListener("pointerout", handleGlobalPointerOut); + window.removeEventListener("blur", reset); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, [reset]); return ( <div @@ -97,5 +97,5 @@ export function MagicCard({ /> <div className="relative">{children}</div> </div> - ) + ); } diff --git a/jb-ui/src/components/ui/pagination.tsx b/jb-ui/src/components/ui/pagination.tsx index 1dcfb0c..990f907 100644 --- a/jb-ui/src/components/ui/pagination.tsx +++ b/jb-ui/src/components/ui/pagination.tsx @@ -1,12 +1,12 @@ -import * as React from "react" +import * as React from "react"; import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, -} from "lucide-react" +} from "lucide-react"; -import { cn } from "@/lib/utils" -import { buttonVariants, type Button } from "@/components/ui/button" +import { cn } from "@/lib/utils"; +import { buttonVariants, type Button } from "@/components/ui/button"; function Pagination({ className, ...props }: React.ComponentProps<"nav">) { return ( @@ -17,7 +17,7 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) { className={cn("mx-auto flex w-full justify-center", className)} {...props} /> - ) + ); } function PaginationContent({ @@ -30,17 +30,17 @@ function PaginationContent({ className={cn("flex flex-row items-center gap-1", className)} {...props} /> - ) + ); } function PaginationItem({ ...props }: React.ComponentProps<"li">) { - return <li data-slot="pagination-item" {...props} /> + return <li data-slot="pagination-item" {...props} />; } type PaginationLinkProps = { - isActive?: boolean + isActive?: boolean; } & Pick<React.ComponentProps<typeof Button>, "size"> & - React.ComponentProps<"a"> + React.ComponentProps<"a">; function PaginationLink({ className, @@ -58,11 +58,11 @@ function PaginationLink({ variant: isActive ? "outline" : "ghost", size, }), - className + className, )} {...props} /> - ) + ); } function PaginationPrevious({ @@ -79,7 +79,7 @@ function PaginationPrevious({ <ChevronLeftIcon /> <span className="hidden sm:block">Previous</span> </PaginationLink> - ) + ); } function PaginationNext({ @@ -96,7 +96,7 @@ function PaginationNext({ <span className="hidden sm:block">Next</span> <ChevronRightIcon /> </PaginationLink> - ) + ); } function PaginationEllipsis({ @@ -113,7 +113,7 @@ function PaginationEllipsis({ <MoreHorizontalIcon className="size-4" /> <span className="sr-only">More pages</span> </span> - ) + ); } export { @@ -124,4 +124,4 @@ export { PaginationPrevious, PaginationNext, PaginationEllipsis, -} +}; diff --git a/jb-ui/src/components/ui/separator.tsx b/jb-ui/src/components/ui/separator.tsx index 275381c..72c18e3 100644 --- a/jb-ui/src/components/ui/separator.tsx +++ b/jb-ui/src/components/ui/separator.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Separator({ className, @@ -18,11 +18,11 @@ function Separator({ orientation={orientation} className={cn( "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", - className + className, )} {...props} /> - ) + ); } -export { Separator } +export { Separator }; diff --git a/jb-ui/src/components/ui/skeleton.tsx b/jb-ui/src/components/ui/skeleton.tsx index 32ea0ef..0168998 100644 --- a/jb-ui/src/components/ui/skeleton.tsx +++ b/jb-ui/src/components/ui/skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { className={cn("bg-accent animate-pulse rounded-md", className)} {...props} /> - ) + ); } -export { Skeleton } +export { Skeleton }; diff --git a/jb-ui/src/hooks.ts b/jb-ui/src/hooks.ts index 02ff1b6..7f8db4b 100644 --- a/jb-ui/src/hooks.ts +++ b/jb-ui/src/hooks.ts @@ -1,11 +1,11 @@ // This file serves as a central hub for re-exporting pre-typed Redux hooks. -import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux' -import type { AppDispatch, RootState } from '@/store' - +import { useDispatch, useSelector, TypedUseSelectorHook } from "react-redux"; +import type { AppDispatch, RootState } from "@/store"; // Use throughout your app instead of plain `useDispatch` and `useSelector` // export const useAppDispatch: () => AppDispatch = useDispatch.withTypes<AppDispatch>() -export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector.withTypes<RootState>() +export const useAppSelector: TypedUseSelectorHook<RootState> = + useSelector.withTypes<RootState>(); -export const useAppDispatch: () => AppDispatch = useDispatch +export const useAppDispatch: () => AppDispatch = useDispatch; // export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector diff --git a/jb-ui/src/index.css b/jb-ui/src/index.css index 1677b7b..579102b 100644 --- a/jb-ui/src/index.css +++ b/jb-ui/src/index.css @@ -138,4 +138,3 @@ @apply bg-background text-foreground; } } - diff --git a/jb-ui/src/lib/utils.ts b/jb-ui/src/lib/utils.ts index 0c84192..1aa59c6 100644 --- a/jb-ui/src/lib/utils.ts +++ b/jb-ui/src/lib/utils.ts @@ -1,113 +1,119 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" -import { Source } from "../api_fsb" +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { Source } from "../api_fsb"; export const routeBasename = import.meta.env.VITE_BASENAME; -export const tagManager = import.meta.env.VITE_TAG_MANAGER +export const tagManager = import.meta.env.VITE_TAG_MANAGER; export const bpid = import.meta.env.VITE_BPID; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } export function assert(condition: any, msg?: string): asserts condition { - if (!condition) { - throw new Error(msg); - } + if (!condition) { + throw new Error(msg); + } } export function toCapitalized(word: string): string { - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); } -export function truncate(originalText: string, maxLength: number = 10, replaceStr = "…"): string { - if (originalText && originalText.length > maxLength) { - return originalText.substring(0, maxLength) + replaceStr; - } - return originalText; +export function truncate( + originalText: string, + maxLength: number = 10, + replaceStr = "…", +): string { + if (originalText && originalText.length > maxLength) { + return originalText.substring(0, maxLength) + replaceStr; + } + return originalText; } export function usdCentFmt(value: number): string { - return (value / 100).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }) + return (value / 100).toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); } export function formatStatus(status: string): string { - const table = { - 'c': 'Completed', - 't': 'Terminated', - 'f': 'Failed', - 'e': 'Entered', - } - - return table[status as keyof typeof table] ?? "Unknown"; + const table = { + c: "Completed", + t: "Terminated", + f: "Failed", + e: "Entered", + }; + + return table[status as keyof typeof table] ?? "Unknown"; } export function formatSource(source: Source): string { - const table = { - 'g': 'GRS', - 'c': 'Cint', - 'a': 'Dalia', - 'd': 'Dynata', - 'et': 'Etx', - 'f': 'Full Circle', - 'i': 'InnovateMR', - 'l': 'Lucid', - 'm': 'Morning Consult', - 'n': 'Open Labs', - 'o': 'Pollfish', - 'e': 'Precision Sample', - 'r': 'Prodege User', - 'pr': 'Prodege', - 'p': 'Pulley', - 'rd': 'Rep Data', - 'h': 'Sago', - 's': 'Pure Spectrum', - 't': 'Testing', - 'u': 'Testing 2', - 'w': 'WXET' - } - - return table[source] + const table = { + g: "GRS", + c: "Cint", + a: "Dalia", + d: "Dynata", + et: "Etx", + f: "Full Circle", + i: "InnovateMR", + l: "Lucid", + m: "Morning Consult", + n: "Open Labs", + o: "Pollfish", + e: "Precision Sample", + r: "Prodege User", + pr: "Prodege", + p: "Pulley", + rd: "Rep Data", + h: "Sago", + s: "Pure Spectrum", + t: "Testing", + u: "Testing 2", + w: "WXET", + }; + + return table[source]; } export function formatSecondsVerbose(seconds: number): string { - const mins = Math.floor(seconds / 60) - const secs = seconds % 60 - - const parts = [] - if (mins > 0) parts.push(`${mins} min`) - if (secs > 0 || mins === 0) { - parts.push(`${secs.toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 1 - })} sec`) - } - - return parts.join(" ") + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + + const parts = []; + if (mins > 0) parts.push(`${mins} min`); + if (secs > 0 || mins === 0) { + parts.push( + `${secs.toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + })} sec`, + ); + } + + return parts.join(" "); } export function formatSeconds(seconds: number): string { - const mins = Math.floor(seconds / 60) - const secs = seconds % 60 - const paddedSecs = secs.toString().padStart(2, '0') - return `${mins}:${paddedSecs}` + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + const paddedSecs = secs.toString().padStart(2, "0"); + return `${mins}:${paddedSecs}`; } export function formatCentsToUSD(cents: number): string { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(cents / 100) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cents / 100); } export function titleCase(str: string): string { - return str - .split(" ") - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -}
\ No newline at end of file + return str + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} diff --git a/jb-ui/src/main.tsx b/jb-ui/src/main.tsx index 538bea2..6e02727 100644 --- a/jb-ui/src/main.tsx +++ b/jb-ui/src/main.tsx @@ -1,19 +1,19 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { Provider } from 'react-redux' -import { store } from '@/store' +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Provider } from "react-redux"; +import { store } from "@/store"; import JBApp from "@/JBApp"; -const rootElement = document.getElementById('root'); +const rootElement = document.getElementById("root"); if (!rootElement) { - throw new Error('Failed to find the root element'); + throw new Error("Failed to find the root element"); } const root = ReactDOM.createRoot(rootElement); root.render( - <React.StrictMode> - <Provider store={store}> - <JBApp /> - </Provider> - </React.StrictMode > -);
\ No newline at end of file + <React.StrictMode> + <Provider store={store}> + <JBApp /> + </Provider> + </React.StrictMode>, +); diff --git a/jb-ui/src/models/app.ts b/jb-ui/src/models/app.ts index 95b4c11..4c3e69e 100644 --- a/jb-ui/src/models/app.ts +++ b/jb-ui/src/models/app.ts @@ -1,37 +1,39 @@ import { - UserWalletBalance, TopNPlusBucket, TaskStatusResponse, - UserLedgerTransactionTypesSummary, - UserLedgerTransactionsResponseTransactionsInner, - OfferwallReason + UserWalletBalance, + TopNPlusBucket, + TaskStatusResponse, + UserLedgerTransactionTypesSummary, + UserLedgerTransactionsResponseTransactionsInner, + OfferwallReason, } from "@/api_fsb"; -import { PaginationState } from '@tanstack/react-table'; +import { PaginationState } from "@tanstack/react-table"; export interface App { - // Global IDs needed by GRL + MTurk - bpuid?: string; - assignment_id?: string; - turkSubmitTo?: string; + // Global IDs needed by GRL + MTurk + bpuid?: string; + assignment_id?: string; + turkSubmitTo?: string; - // Global UI settings - loi: number; - availability_count?: number; - attempted_live_eligible_count?: number; - offerwall_reasons: OfferwallReason[]; + // Global UI settings + loi: number; + availability_count?: number; + attempted_live_eligible_count?: number; + offerwall_reasons: OfferwallReason[]; - // The timestamp of when the currently requested bucket was requested. We want - // to save this to prevent it from sitting for longer than 120 seconds. - currentBucketRequested?: number; // Date.now() is a timestamp in ms - currentBuckets?: TopNPlusBucket[]; - currentBucketEntered?: number; // Date.now() is a timestamp in ms - taskStatus?: TaskStatusResponse; + // The timestamp of when the currently requested bucket was requested. We want + // to save this to prevent it from sitting for longer than 120 seconds. + currentBucketRequested?: number; // Date.now() is a timestamp in ms + currentBuckets?: TopNPlusBucket[]; + currentBucketEntered?: number; // Date.now() is a timestamp in ms + taskStatus?: TaskStatusResponse; - // Wallet stuff - userWalletBalance?: UserWalletBalance; - userLedgerSummary?: UserLedgerTransactionTypesSummary; - userLedgerTxCount?: number; - userLedgerTxs: UserLedgerTransactionsResponseTransactionsInner[]; + // Wallet stuff + userWalletBalance?: UserWalletBalance; + userLedgerSummary?: UserLedgerTransactionTypesSummary; + userLedgerTxCount?: number; + userLedgerTxs: UserLedgerTransactionsResponseTransactionsInner[]; - txPagination: PaginationState; - txTotalItems?: number; - txTotalPages?: number; + txPagination: PaginationState; + txTotalItems?: number; + txTotalPages?: number; } diff --git a/jb-ui/src/models/appSlice.ts b/jb-ui/src/models/appSlice.ts index 87952d9..46c6a2f 100644 --- a/jb-ui/src/models/appSlice.ts +++ b/jb-ui/src/models/appSlice.ts @@ -1,188 +1,191 @@ import { - TaskStatusResponse, - TopNPlusBucket, - UserLedgerTransactionsResponseTransactionsInner, - UserLedgerTransactionTypesSummary, - UserWalletBalance, - OfferwallReason + TaskStatusResponse, + TopNPlusBucket, + UserLedgerTransactionsResponseTransactionsInner, + UserLedgerTransactionTypesSummary, + UserWalletBalance, + OfferwallReason, } from "@/api_fsb"; import { App } from "@/models/app"; import { RootState } from "@/store"; -import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { PaginationState } from '@tanstack/react-table'; +import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { PaginationState } from "@tanstack/react-table"; import moment from "moment"; const initialState: App = { - bpuid: undefined, - assignment_id: undefined, - loi: 1800, - currentBuckets: undefined, - currentBucketEntered: undefined, - taskStatus: undefined, - offerwall_reasons: [], - - userWalletBalance: undefined, - userLedgerSummary: undefined, - userLedgerTxCount: undefined, - userLedgerTxs: [], - - txPagination: { pageIndex: 0, pageSize: 10 }, - txTotalItems: undefined, - txTotalPages: undefined -} as App + bpuid: undefined, + assignment_id: undefined, + loi: 1800, + currentBuckets: undefined, + currentBucketEntered: undefined, + taskStatus: undefined, + offerwall_reasons: [], + + userWalletBalance: undefined, + userLedgerSummary: undefined, + userLedgerTxCount: undefined, + userLedgerTxs: [], + + txPagination: { pageIndex: 0, pageSize: 10 }, + txTotalItems: undefined, + txTotalPages: undefined, +} as App; const appSlice = createSlice({ - name: 'app', - initialState, - reducers: { - - setProductUserID(state, action: PayloadAction<string>) { - state.bpuid = action.payload; - }, - - setAssignmentID(state, action: PayloadAction<string>) { - // This is really so silly. Amazon should simply not send - // anything if it's unavailable. - if (action.payload === "ASSIGNMENT_ID_NOT_AVAILABLE") { - state.assignment_id = undefined; - } - state.assignment_id = action.payload; - }, - - setTurkSubmitTo(state, action: PayloadAction<string>) { - state.turkSubmitTo = action.payload; - }, - - setLOI(state, action: PayloadAction<number>) { - state.loi = action.payload; - }, - - setAvailabilityCount(state, action: PayloadAction<number>) { - state.availability_count = action.payload - }, - - setAttemptedLiveEligibleCount(state, action: PayloadAction<number>) { - state.attempted_live_eligible_count = action.payload - }, - - setCurrentBuckets(state, action: PayloadAction<TopNPlusBucket[] | undefined>) { - state.currentBucketRequested = moment.utc().unix(); - state.currentBuckets = action.payload - }, - - setOfferwallReasons(state, action: PayloadAction<OfferwallReason[]>) { - state.offerwall_reasons = action.payload; - }, - - setEnteredTimestamp(state) { - // Go back by 2 seconds to account for any time drift - state.currentBucketEntered = moment.utc().unix() - 2; - }, - - setTaskStatus(state, action: PayloadAction<TaskStatusResponse>) { - state.taskStatus = action.payload; - - state.bpuid = action.payload.product_user_id; - }, - - setUserWalletBalance(state, action: PayloadAction<UserWalletBalance>) { - state.userWalletBalance = action.payload; - }, - - setUserLedgerSummary(state, action: PayloadAction<UserLedgerTransactionTypesSummary>) { - state.userLedgerSummary = action.payload; - }, - - setUserLedgerTxs(state, action: PayloadAction<UserLedgerTransactionsResponseTransactionsInner[]>) { - // We're not appending the transaction details, it's only going - // to reassign the current page of Transactions. - state.userLedgerTxs = action.payload; - }, - - setTxPagination(state, action: PayloadAction<PaginationState>) { - state.txPagination = action.payload; - }, - - setTxTotalItems(state, action: PayloadAction<number>) { - state.txTotalItems = action.payload; - }, - - setTxTotalPages(state, action: PayloadAction<number>) { - state.txTotalPages = action.payload; - } - - } -}) + name: "app", + initialState, + reducers: { + setProductUserID(state, action: PayloadAction<string>) { + state.bpuid = action.payload; + }, + + setAssignmentID(state, action: PayloadAction<string>) { + // This is really so silly. Amazon should simply not send + // anything if it's unavailable. + if (action.payload === "ASSIGNMENT_ID_NOT_AVAILABLE") { + state.assignment_id = undefined; + } + state.assignment_id = action.payload; + }, + + setTurkSubmitTo(state, action: PayloadAction<string>) { + state.turkSubmitTo = action.payload; + }, + + setLOI(state, action: PayloadAction<number>) { + state.loi = action.payload; + }, + + setAvailabilityCount(state, action: PayloadAction<number>) { + state.availability_count = action.payload; + }, + + setAttemptedLiveEligibleCount(state, action: PayloadAction<number>) { + state.attempted_live_eligible_count = action.payload; + }, + + setCurrentBuckets( + state, + action: PayloadAction<TopNPlusBucket[] | undefined>, + ) { + state.currentBucketRequested = moment.utc().unix(); + state.currentBuckets = action.payload; + }, + + setOfferwallReasons(state, action: PayloadAction<OfferwallReason[]>) { + state.offerwall_reasons = action.payload; + }, + + setEnteredTimestamp(state) { + // Go back by 2 seconds to account for any time drift + state.currentBucketEntered = moment.utc().unix() - 2; + }, + + setTaskStatus(state, action: PayloadAction<TaskStatusResponse>) { + state.taskStatus = action.payload; + + state.bpuid = action.payload.product_user_id; + }, + + setUserWalletBalance(state, action: PayloadAction<UserWalletBalance>) { + state.userWalletBalance = action.payload; + }, + + setUserLedgerSummary( + state, + action: PayloadAction<UserLedgerTransactionTypesSummary>, + ) { + state.userLedgerSummary = action.payload; + }, + + setUserLedgerTxs( + state, + action: PayloadAction<UserLedgerTransactionsResponseTransactionsInner[]>, + ) { + // We're not appending the transaction details, it's only going + // to reassign the current page of Transactions. + state.userLedgerTxs = action.payload; + }, + + setTxPagination(state, action: PayloadAction<PaginationState>) { + state.txPagination = action.payload; + }, + + setTxTotalItems(state, action: PayloadAction<number>) { + state.txTotalItems = action.payload; + }, + + setTxTotalPages(state, action: PayloadAction<number>) { + state.txTotalPages = action.payload; + }, + }, +}); export const { - setProductUserID, - setAssignmentID, - setTurkSubmitTo, - setLOI, - setAvailabilityCount, - setAttemptedLiveEligibleCount, - setOfferwallReasons, - setCurrentBuckets, - setEnteredTimestamp, - setTaskStatus, - - setUserWalletBalance, - - setUserLedgerSummary, - setUserLedgerTxs, - - setTxPagination, - setTxTotalItems, - setTxTotalPages + setProductUserID, + setAssignmentID, + setTurkSubmitTo, + setLOI, + setAvailabilityCount, + setAttemptedLiveEligibleCount, + setOfferwallReasons, + setCurrentBuckets, + setEnteredTimestamp, + setTaskStatus, + + setUserWalletBalance, + + setUserLedgerSummary, + setUserLedgerTxs, + + setTxPagination, + setTxTotalItems, + setTxTotalPages, } = appSlice.actions; -export default appSlice.reducer +export default appSlice.reducer; export const getLOIText = createSelector( - [(state: RootState) => state.app], - (app_config): string => { - - const lookup: Record<number, string> = { - 600: "ten", - 1200: "twenty", - 1800: "thirty", - }; - - return lookup[app_config.loi] || " – "; - } + [(state: RootState) => state.app], + (app_config): string => { + const lookup: Record<number, string> = { + 600: "ten", + 1200: "twenty", + 1800: "thirty", + }; + + return lookup[app_config.loi] || " – "; + }, ); export const getAvailabilityCount = createSelector( - [(state: RootState) => state.app], - (app_config): number | undefined => { - return app_config.availability_count; - } + [(state: RootState) => state.app], + (app_config): number | undefined => { + return app_config.availability_count; + }, ); export const isLowBalance = createSelector( - [(state: RootState) => state.app], - (app_config): boolean => { - const bal_amt = app_config.userWalletBalance?.amount ?? 0 - return bal_amt <= -90; - } + [(state: RootState) => state.app], + (app_config): boolean => { + const bal_amt = app_config.userWalletBalance?.amount ?? 0; + return bal_amt <= -90; + }, ); - export const selectBucket = createSelector( - [ - (state: RootState) => state.app.currentBuckets, - ], - (buckets): TopNPlusBucket | null => { - if (buckets && buckets.length >= 1) { - return buckets[0] - } - return null; + [(state: RootState) => state.app.currentBuckets], + (buckets): TopNPlusBucket | null => { + if (buckets && buckets.length >= 1) { + return buckets[0]; } + return null; + }, ); export const getSurveyURL = createSelector( - [selectBucket], - (bucket): string | null => { - return bucket?.uri || null; - } -);
\ No newline at end of file + [selectBucket], + (bucket): string | null => { + return bucket?.uri || null; + }, +); diff --git a/jb-ui/src/models/grlEventsSlice.ts b/jb-ui/src/models/grlEventsSlice.ts index 912c4ca..681c22e 100644 --- a/jb-ui/src/models/grlEventsSlice.ts +++ b/jb-ui/src/models/grlEventsSlice.ts @@ -1,58 +1,56 @@ import { EventEnvelope } from "@/api_fsb"; import { RootState } from "@/store"; -import { createEntityAdapter, createSelector, createSlice } from '@reduxjs/toolkit'; +import { + createEntityAdapter, + createSelector, + createSlice, +} from "@reduxjs/toolkit"; const eventAdapter = createEntityAdapter({ - selectId: (model: EventEnvelope) => model.event_uuid!, - sortComparer: (a, b) => { - // ASC by timestamp - return a.timestamp!.localeCompare(b.timestamp!); - }, + selectId: (model: EventEnvelope) => model.event_uuid!, + sortComparer: (a, b) => { + // ASC by timestamp + return a.timestamp!.localeCompare(b.timestamp!); + }, }); - const grlEventsSlice = createSlice({ - name: 'grlEvents', - initialState: eventAdapter.getInitialState(), - - reducers: { - addEvent: eventAdapter.addOne, - addEvents: eventAdapter.addMany, - upsertEvent: eventAdapter.upsertOne, // Add or Update - - } -}) - - -export const { - addEvent, addEvents, upsertEvent -} = grlEventsSlice.actions; + name: "grlEvents", + initialState: eventAdapter.getInitialState(), + + reducers: { + addEvent: eventAdapter.addOne, + addEvents: eventAdapter.addMany, + upsertEvent: eventAdapter.upsertOne, // Add or Update + }, +}); +export const { addEvent, addEvents, upsertEvent } = grlEventsSlice.actions; export const eventSelectors = eventAdapter.getSelectors( - (state: RootState) => state.events + (state: RootState) => state.events, ); -export const selectAllEvents = (state: RootState): EventEnvelope[] => eventSelectors.selectAll(state); - -const BASE_SPEED = 40; // px/sec at idle -const MAX_SPEED = 380; // px/sec at surge +export const selectAllEvents = (state: RootState): EventEnvelope[] => + eventSelectors.selectAll(state); -const RATE_WINDOW_MS = 10_000; // ms window to measure message frequency -const RATE_TO_SPEED = 55; // px/sec added per msg/sec +const BASE_SPEED = 40; // px/sec at idle +const MAX_SPEED = 380; // px/sec at surge +const RATE_WINDOW_MS = 10_000; // ms window to measure message frequency +const RATE_TO_SPEED = 55; // px/sec added per msg/sec -// --- Speed: derived from arrival rate of recent items --- +// --- Speed: derived from arrival rate of recent items --- export const selectCurrentSpeed = createSelector(selectAllEvents, (items) => { - const now = Date.now(); + const now = Date.now(); - const recentCount = items.filter((m) => { - const ts = new Date(m.timestamp!).getTime(); - return now - ts < RATE_WINDOW_MS; - }).length; + const recentCount = items.filter((m) => { + const ts = new Date(m.timestamp!).getTime(); + return now - ts < RATE_WINDOW_MS; + }).length; - const rate = recentCount / (RATE_WINDOW_MS / 1_000); // msgs/sec - return Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED); + const rate = recentCount / (RATE_WINDOW_MS / 1_000); // msgs/sec + return Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED); }); -export default grlEventsSlice.reducer +export default grlEventsSlice.reducer; diff --git a/jb-ui/src/models/grlStatsSlice.ts b/jb-ui/src/models/grlStatsSlice.ts index 9385256..004d321 100644 --- a/jb-ui/src/models/grlStatsSlice.ts +++ b/jb-ui/src/models/grlStatsSlice.ts @@ -1,54 +1,47 @@ import { StatsSnapshot } from "@/api_fsb"; import { RootState } from "@/store"; -import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; - -const initialState: StatsSnapshot[] = [] +const initialState: StatsSnapshot[] = []; const grlStatsSlice = createSlice({ - name: 'grlStats', - initialState, - reducers: { - - addStatsData(state, action: PayloadAction<StatsSnapshot>) { - state.push(action.payload); - } - } -}) - -export const { - addStatsData -} = grlStatsSlice.actions; -export default grlStatsSlice.reducer - + name: "grlStats", + initialState, + reducers: { + addStatsData(state, action: PayloadAction<StatsSnapshot>) { + state.push(action.payload); + }, + }, +}); + +export const { addStatsData } = grlStatsSlice.actions; +export default grlStatsSlice.reducer; export const selectRecentStats = createSelector( - [ - (state: RootState) => state.stats, - ], - (stats): StatsSnapshot | null => { - const lastStat = stats[stats.length - 1]; - return lastStat ?? null; - } + [(state: RootState) => state.stats], + (stats): StatsSnapshot | null => { + const lastStat = stats[stats.length - 1]; + return lastStat ?? null; + }, ); export const activeUsers = createSelector( - [selectRecentStats], - (recentStats): number | null => { - return recentStats?.active_users_last_24h ?? null; - } + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.active_users_last_24h ?? null; + }, ); export const activeSurveys = createSelector( - [selectRecentStats], - (recentStats): number | null => { - return recentStats?.live_task_count?.total ?? null; - } + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.live_task_count?.total ?? null; + }, ); export const maxPayout = createSelector( - [selectRecentStats], - (recentStats): number | null => { - return recentStats?.live_tasks_max_payout?.value ?? null; - } + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.live_tasks_max_payout?.value ?? null; + }, ); diff --git a/jb-ui/src/models/profilingQuestionsSlice.ts b/jb-ui/src/models/profilingQuestionsSlice.ts index cc36f17..577566f 100644 --- a/jb-ui/src/models/profilingQuestionsSlice.ts +++ b/jb-ui/src/models/profilingQuestionsSlice.ts @@ -1,313 +1,327 @@ import { - BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, - ProfilingQuestionsApi, - StatusResponse, - UpkQuestion, UserQuestionAnswerIn + BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, + ProfilingQuestionsApi, + StatusResponse, + UpkQuestion, + UserQuestionAnswerIn, } from "@/api_fsb"; import { assert, bpid } from "@/lib/utils"; import { questionUtils } from "@/models/profilingUtils"; -import type { RootState } from '@/store'; +import type { RootState } from "@/store"; import { - createAsyncThunk, - createEntityAdapter, createSlice, - PayloadAction, - WritableDraft -} from '@reduxjs/toolkit'; + createAsyncThunk, + createEntityAdapter, + createSlice, + PayloadAction, + WritableDraft, +} from "@reduxjs/toolkit"; interface UpkQuestionMetadata { - // The data we need to conduct profiling questions and manager user - // submission along with the User Interfaces + // The data we need to conduct profiling questions and manager user + // submission along with the User Interfaces - // Only one Question can be active at a time, which is the one - // being shown to the Respondent - isActive: boolean; + // Only one Question can be active at a time, which is the one + // being shown to the Respondent + isActive: boolean; - answers: string[]; + answers: string[]; - // If it's actively being submitted to the server - isProcessing: boolean; + // If it's actively being submitted to the server + isProcessing: boolean; - // If we recieved a successful response from the server after submitting - isSaved: boolean; + // If we recieved a successful response from the server after submitting + isSaved: boolean; } interface ValidationResult { - // If isValid or isComplete are undefined, that means the question - // hasn't been validated yet. This is different from false, which means - // it has been validated and is not valid. - isValid?: boolean; - isComplete?: boolean; - errors: ValidationError[]; -}; + // If isValid or isComplete are undefined, that means the question + // hasn't been validated yet. This is different from false, which means + // it has been validated and is not valid. + isValid?: boolean; + isComplete?: boolean; + errors: ValidationError[]; +} export interface ValidationError { - path: string; // e.g., "user.email" - message: string; - severity: 'error' | 'warning'; -}; + path: string; // e.g., "user.email" + message: string; + severity: "error" | "warning"; +} export interface ProfileQuestion extends UpkQuestion { - _metadata: UpkQuestionMetadata; - _validation: ValidationResult; + _metadata: UpkQuestionMetadata; + _validation: ValidationResult; } // Entity adapter for normalized storage const questionsAdapter = createEntityAdapter({ - selectId: (model: ProfileQuestion) => model.question_id!, - sortComparer: (a, b) => { - return (b.importance?.task_score ?? 0) - (a.importance?.task_score ?? 0); - }, + selectId: (model: ProfileQuestion) => model.question_id!, + sortComparer: (a, b) => { + return (b.importance?.task_score ?? 0) - (a.importance?.task_score ?? 0); + }, }); - function getDefaultMetadata(): UpkQuestionMetadata { - return { - isActive: false, - answers: [], - isProcessing: false, - isSaved: false, - }; + return { + isActive: false, + answers: [], + isProcessing: false, + isSaved: false, + }; } function getDefaultValidation(): ValidationResult { - - return { - isValid: undefined, - isComplete: undefined, - errors: [], - }; + return { + isValid: undefined, + isComplete: undefined, + errors: [], + }; } // Create the async thunk (outside your slice) export const saveAnswer = createAsyncThunk( - 'profilingQuestions/saveAnswer', - async ({ questionId, bpuid }: { questionId: string; bpuid: string }, { getState, dispatch }) => { - const state = getState() as RootState; - const question = state.profilingQuestions.entities[questionId]; - - // Validations - const answers = { - 'question_id': question.question_id!, - 'answer': question._metadata.answers - } as UserQuestionAnswerIn; - - const answers_body = { - 'answers': [answers] - } as BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost; - - // Make API call - const res = await new ProfilingQuestionsApi() - .submitProfilingQuestionsProductIdProfilingQuestionsPost( - bpid, - bpuid, - answers_body, - undefined, - true - ); - - const response = res.data as StatusResponse; - - // Check if we need to fetch new questions BEFORE returning - if (response.status === "success" && isFirstNonGR(state.profilingQuestions)) { - // NOW dispatch it - dispatch(fetchNewQuestions(bpuid)); - } - - return { questionId, response }; - + "profilingQuestions/saveAnswer", + async ( + { questionId, bpuid }: { questionId: string; bpuid: string }, + { getState, dispatch }, + ) => { + const state = getState() as RootState; + const question = state.profilingQuestions.entities[questionId]; + + // Validations + const answers = { + question_id: question.question_id!, + answer: question._metadata.answers, + } as UserQuestionAnswerIn; + + const answers_body = { + answers: [answers], + } as BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost; + + // Make API call + const res = + await new ProfilingQuestionsApi().submitProfilingQuestionsProductIdProfilingQuestionsPost( + bpid, + bpuid, + answers_body, + undefined, + true, + ); + + const response = res.data as StatusResponse; + + // Check if we need to fetch new questions BEFORE returning + if ( + response.status === "success" && + isFirstNonGR(state.profilingQuestions) + ) { + // NOW dispatch it + dispatch(fetchNewQuestions(bpuid)); } -) -export const fetchNewQuestions = createAsyncThunk( - 'profilingQuestions/fetchNewQuestions', - async (bpuid: string) => { - - console.log("profilingQuestions/fetchNewQuestions") - const res = await new ProfilingQuestionsApi() - .getProfilingQuestionsProductIdProfilingQuestionsGet( - bpid, - bpuid, - undefined, // "104.9.125.144", // ip - undefined, // countryIso - undefined, // languageIso - 2_500 - ); - return res.data.questions; - } + return { questionId, response }; + }, ); -type ProfilingQuestionsState = ReturnType<typeof profilingQuestionSlice.getInitialState>; +export const fetchNewQuestions = createAsyncThunk( + "profilingQuestions/fetchNewQuestions", + async (bpuid: string) => { + console.log("profilingQuestions/fetchNewQuestions"); + const res = + await new ProfilingQuestionsApi().getProfilingQuestionsProductIdProfilingQuestionsGet( + bpid, + bpuid, + undefined, // "104.9.125.144", // ip + undefined, // countryIso + undefined, // languageIso + 2_500, + ); + return res.data.questions; + }, +); +type ProfilingQuestionsState = ReturnType< + typeof profilingQuestionSlice.getInitialState +>; // Helper function that contains the logic (outside the slice) function goToNextQuestionLogic(state: WritableDraft<ProfilingQuestionsState>) { - const allQuestions = questionsAdapter.getSelectors().selectAll(state); - const currentIndex = allQuestions.findIndex(q => q._metadata.isActive); - - if (currentIndex === -1) return; - - const totalQuestions = allQuestions.length; - let searchIndex = (currentIndex + 1) % totalQuestions; - let iterations = 0; - - while (iterations < totalQuestions) { - if (!allQuestions[searchIndex]._metadata.isSaved) { - questionsAdapter.updateMany(state, [ - { - id: allQuestions[currentIndex].question_id!, - changes: { - _metadata: { - ...allQuestions[currentIndex]._metadata, - isActive: false - } - } - }, - { - id: allQuestions[searchIndex].question_id!, - changes: { - _metadata: { - ...allQuestions[searchIndex]._metadata, - isActive: true - } - } - } - ]); - return; - } - - searchIndex = (searchIndex + 1) % totalQuestions; - iterations++; + const allQuestions = questionsAdapter.getSelectors().selectAll(state); + const currentIndex = allQuestions.findIndex((q) => q._metadata.isActive); + + if (currentIndex === -1) return; + + const totalQuestions = allQuestions.length; + let searchIndex = (currentIndex + 1) % totalQuestions; + let iterations = 0; + + while (iterations < totalQuestions) { + if (!allQuestions[searchIndex]._metadata.isSaved) { + questionsAdapter.updateMany(state, [ + { + id: allQuestions[currentIndex].question_id!, + changes: { + _metadata: { + ...allQuestions[currentIndex]._metadata, + isActive: false, + }, + }, + }, + { + id: allQuestions[searchIndex].question_id!, + changes: { + _metadata: { + ...allQuestions[searchIndex]._metadata, + isActive: true, + }, + }, + }, + ]); + return; } + + searchIndex = (searchIndex + 1) % totalQuestions; + iterations++; + } } function setProfilingQuestionsLogic( - state: WritableDraft<ProfilingQuestionsState>, - questions: ProfileQuestion[]) { - - const hasActiveQuestion = Object.values(state.entities).some( - entity => entity?._metadata.isActive - ); - - const entities = questions.map((serverQuestion, index) => { - const existing = state.entities[serverQuestion.question_id!]; - const shouldActivate = !hasActiveQuestion && index === 0; - - return { - ...serverQuestion, - _metadata: existing?._metadata || { - ...getDefaultMetadata(), - isActive: shouldActivate, - }, - _validation: existing?._validation || getDefaultValidation(), - }; - }); + state: WritableDraft<ProfilingQuestionsState>, + questions: ProfileQuestion[], +) { + const hasActiveQuestion = Object.values(state.entities).some( + (entity) => entity?._metadata.isActive, + ); - questionsAdapter.setAll(state, entities); -} + const entities = questions.map((serverQuestion, index) => { + const existing = state.entities[serverQuestion.question_id!]; + const shouldActivate = !hasActiveQuestion && index === 0; + return { + ...serverQuestion, + _metadata: existing?._metadata || { + ...getDefaultMetadata(), + isActive: shouldActivate, + }, + _validation: existing?._validation || getDefaultValidation(), + }; + }); + + questionsAdapter.setAll(state, entities); +} function isGR(ext_question_id: string): boolean { - return ext_question_id.startsWith("gr:") + return ext_question_id.startsWith("gr:"); } function isFirstNonGR(state: WritableDraft<ProfilingQuestionsState>): boolean { - // We want to identify if the next question is the first non-GR question - // because will want to trigger a full profile question refresh. + // We want to identify if the next question is the first non-GR question + // because will want to trigger a full profile question refresh. - const allQuestions = questionsAdapter.getSelectors().selectAll(state); - const currentIndex = allQuestions.findIndex(q => q._metadata.isActive); + const allQuestions = questionsAdapter.getSelectors().selectAll(state); + const currentIndex = allQuestions.findIndex((q) => q._metadata.isActive); - if (currentIndex === -1) false; + if (currentIndex === -1) false; - const totalQuestions = allQuestions.length; - let searchIndex = (currentIndex + 1) % totalQuestions; - let iterations = 0; + const totalQuestions = allQuestions.length; + let searchIndex = (currentIndex + 1) % totalQuestions; + let iterations = 0; - while (iterations < totalQuestions) { - if (!allQuestions[searchIndex]._metadata.isSaved) { + while (iterations < totalQuestions) { + if (!allQuestions[searchIndex]._metadata.isSaved) { + const current = isGR(allQuestions[currentIndex].ext_question_id!); + const next = isGR(allQuestions[searchIndex].ext_question_id!); - const current = isGR(allQuestions[currentIndex].ext_question_id!) - const next = isGR(allQuestions[searchIndex].ext_question_id!) - - // We want to identify transitions from GR to non-GR questions, - // as we use GR questions to pre-calculate some non-GR questions, - // we should force a refresh. - return current && !next; - } - - searchIndex = (searchIndex + 1) % totalQuestions; - iterations++; + // We want to identify transitions from GR to non-GR questions, + // as we use GR questions to pre-calculate some non-GR questions, + // we should force a refresh. + return current && !next; } - return false; + searchIndex = (searchIndex + 1) % totalQuestions; + iterations++; + } + + return false; } const profilingQuestionSlice = createSlice({ - name: 'profilingQuestions', - initialState: questionsAdapter.getInitialState(), - reducers: { + name: "profilingQuestions", + initialState: questionsAdapter.getInitialState(), + reducers: { + setProfilingQuestions: ( + state, + action: PayloadAction<ProfileQuestion[]>, + ) => { + setProfilingQuestionsLogic(state, action.payload); + }, - setProfilingQuestions: (state, action: PayloadAction<ProfileQuestion[]>) => { - setProfilingQuestionsLogic(state, action.payload); - }, + metadataUpdated: ( + state, + action: PayloadAction<{ + question_id: string; + metadata: Partial<UpkQuestionMetadata>; + }>, + ) => { + const { question_id, metadata } = action.payload; + const entity = state.entities[question_id]; + if (entity) { + entity._metadata = { ...entity._metadata, ...metadata }; + } + }, - metadataUpdated: ( - state, - action: PayloadAction<{ question_id: string; metadata: Partial<UpkQuestionMetadata> }> - ) => { - const { question_id, metadata } = action.payload; - const entity = state.entities[question_id]; - if (entity) { - entity._metadata = { ...entity._metadata, ...metadata }; - } - }, + validationUpdated: ( + state, + action: PayloadAction<{ + question_id: string; + validation: Partial<ValidationResult>; + }>, + ) => { + const { question_id, validation } = action.payload; + const entity = state.entities[question_id]; + if (entity) { + entity._validation = { ...entity._validation, ...validation }; + } + }, - validationUpdated: ( - state, - action: PayloadAction<{ question_id: string; validation: Partial<ValidationResult> }> - ) => { - const { question_id, validation } = action.payload; - const entity = state.entities[question_id]; - if (entity) { - entity._validation = { ...entity._validation, ...validation }; - } - }, + // Set one question as active, deactivate all others + setActiveQuestion: (state, action: PayloadAction<string>) => { + const questionId = action.payload; - // Set one question as active, deactivate all others - setActiveQuestion: (state, action: PayloadAction<string>) => { - const questionId = action.payload; - - // Deactivate all questions - Object.values(state.entities).forEach(entity => { - if (entity) { - entity._metadata.isActive = false; - } - }); - - // Activate the selected one - const entity = state.entities[questionId]; - if (entity) { - entity._metadata.isActive = true; - } - }, + // Deactivate all questions + Object.values(state.entities).forEach((entity) => { + if (entity) { + entity._metadata.isActive = false; + } + }); - // Clear active state from all questions - clearActiveQuestion: (state) => { - Object.values(state.entities).forEach(entity => { - if (entity) { - entity._metadata.isActive = false; - } - }); - }, + // Activate the selected one + const entity = state.entities[questionId]; + if (entity) { + entity._metadata.isActive = true; + } + }, - goToNextQuestion: (state) => { - goToNextQuestionLogic(state); - }, + // Clear active state from all questions + clearActiveQuestion: (state) => { + Object.values(state.entities).forEach((entity) => { + if (entity) { + entity._metadata.isActive = false; + } + }); + }, + + goToNextQuestion: (state) => { + goToNextQuestionLogic(state); + }, - // ----------------------- + // ----------------------- - addAnswers(state, action: PayloadAction<{ questionId: string, answers: string[] }>) { - /* When changing the answers in anyway, we want to: + addAnswers( + state, + action: PayloadAction<{ questionId: string; answers: string[] }>, + ) { + /* When changing the answers in anyway, we want to: 1. Add the new answers to the question metadata This does not perform validation, it simply reassigns @@ -325,112 +339,124 @@ const profilingQuestionSlice = createSlice({ 3. Re-calculate the isComplete and isValid state */ - // let new_question = questionUtils.addAnswer(action.payload.question, action.payload.answers) - let entity = state.entities[action.payload.questionId]; - const newMetadata = { ...entity._metadata, answers: action.payload.answers }; + // let new_question = questionUtils.addAnswer(action.payload.question, action.payload.answers) + let entity = state.entities[action.payload.questionId]; + const newMetadata = { + ...entity._metadata, + answers: action.payload.answers, + }; - if (entity) { - entity._metadata = newMetadata; + if (entity) { + entity._metadata = newMetadata; - entity = questionUtils.validate(entity) - entity = questionUtils.assignValidationState(entity) + entity = questionUtils.validate(entity); + entity = questionUtils.assignValidationState(entity); - state.entities[action.payload.questionId] = entity - } - }, + state.entities[action.payload.questionId] = entity; + } }, - - extraReducers: (builder) => { - builder - .addCase(saveAnswer.pending, (state, action) => { - const questionId = action.meta.arg.questionId; - const bpuid = action.meta.arg.bpuid; - assert(bpuid, "Worker must be defined"); - - const question = state.entities[questionId]; - if (question) { - assert(question._validation.isComplete, "Must submit Completed Questions"); - assert(!question._metadata.isProcessing, "Can't submit processing Answer"); - assert(!question._metadata.isSaved, "Can't submit completed Answer"); - question._metadata.isProcessing = true; - } - }) - .addCase(saveAnswer.fulfilled, (state, action) => { - const { questionId, response } = action.payload; - const question = state.entities[questionId]; - - if (question) { - question._metadata.isProcessing = false; - if (response.status === "success") { - question._metadata.isSaved = true; - - // fetchNewQuestions is already being dispatched from - // the THUNK - goToNextQuestionLogic(state); - - } else { - question._metadata.isSaved = false; - } - } - }) - .addCase(saveAnswer.rejected, (state, action) => { - const questionId = action.meta.arg.questionId; - const question = state.entities[questionId]; - - if (question) { - question._metadata.isProcessing = false; - question._metadata.isSaved = false; - } - }) - .addCase(fetchNewQuestions.fulfilled, (state, action) => { - console.log(".addCase(fetchNewQuestions.fulfilled, (state, action) => {") - setProfilingQuestionsLogic(state, action.payload as ProfileQuestion[]); - }); - ; - } - -}) + }, + + extraReducers: (builder) => { + builder + .addCase(saveAnswer.pending, (state, action) => { + const questionId = action.meta.arg.questionId; + const bpuid = action.meta.arg.bpuid; + assert(bpuid, "Worker must be defined"); + + const question = state.entities[questionId]; + if (question) { + assert( + question._validation.isComplete, + "Must submit Completed Questions", + ); + assert( + !question._metadata.isProcessing, + "Can't submit processing Answer", + ); + assert(!question._metadata.isSaved, "Can't submit completed Answer"); + question._metadata.isProcessing = true; + } + }) + .addCase(saveAnswer.fulfilled, (state, action) => { + const { questionId, response } = action.payload; + const question = state.entities[questionId]; + + if (question) { + question._metadata.isProcessing = false; + if (response.status === "success") { + question._metadata.isSaved = true; + + // fetchNewQuestions is already being dispatched from + // the THUNK + goToNextQuestionLogic(state); + } else { + question._metadata.isSaved = false; + } + } + }) + .addCase(saveAnswer.rejected, (state, action) => { + const questionId = action.meta.arg.questionId; + const question = state.entities[questionId]; + + if (question) { + question._metadata.isProcessing = false; + question._metadata.isSaved = false; + } + }) + .addCase(fetchNewQuestions.fulfilled, (state, action) => { + console.log( + ".addCase(fetchNewQuestions.fulfilled, (state, action) => {", + ); + setProfilingQuestionsLogic(state, action.payload as ProfileQuestion[]); + }); + }, +}); export const { - setProfilingQuestions, - metadataUpdated, - setActiveQuestion, - goToNextQuestion, - addAnswers, + setProfilingQuestions, + metadataUpdated, + setActiveQuestion, + goToNextQuestion, + addAnswers, } = profilingQuestionSlice.actions; // Selectors export const questionsSelectors = questionsAdapter.getSelectors( - (state: RootState) => state.profilingQuestions + (state: RootState) => state.profilingQuestions, ); // Custom selector to get the active question export const selectAllQuestions = (state: RootState): ProfileQuestion[] => { - return questionsSelectors.selectAll(state) ?? []; + return questionsSelectors.selectAll(state) ?? []; }; // Custom selector to get the active question -export const selectActiveQuestion = (state: RootState): ProfileQuestion | null => { - const allQuestions = questionsSelectors.selectAll(state); - return allQuestions.find(q => q._metadata.isActive) || null; +export const selectActiveQuestion = ( + state: RootState, +): ProfileQuestion | null => { + const allQuestions = questionsSelectors.selectAll(state); + return allQuestions.find((q) => q._metadata.isActive) || null; }; // Custom selector to get active question ID export const selectActiveQuestionId = (state: RootState): string | null => { - const activeQuestion = selectActiveQuestion(state); - return activeQuestion?.question_id || null; + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?.question_id || null; }; -export const selectActiveQuestionMetadata = (state: RootState): UpkQuestionMetadata | null => { - const activeQuestion = selectActiveQuestion(state); - return activeQuestion?._metadata || null; +export const selectActiveQuestionMetadata = ( + state: RootState, +): UpkQuestionMetadata | null => { + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?._metadata || null; }; -export const selectActiveQuestionValidation = (state: RootState): ValidationResult | null => { - const activeQuestion = selectActiveQuestion(state); - return activeQuestion?._validation || null; +export const selectActiveQuestionValidation = ( + state: RootState, +): ValidationResult | null => { + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?._validation || null; }; - -export default profilingQuestionSlice.reducer - +export default profilingQuestionSlice.reducer; diff --git a/jb-ui/src/models/profilingUtils.ts b/jb-ui/src/models/profilingUtils.ts index 104a5bb..a3ef7d2 100644 --- a/jb-ui/src/models/profilingUtils.ts +++ b/jb-ui/src/models/profilingUtils.ts @@ -1,29 +1,36 @@ -import { PatternValidation, UpkQuestionConfigurationMC, UpkQuestionConfigurationTE } from "@/api_fsb"; +import { + PatternValidation, + UpkQuestionConfigurationMC, + UpkQuestionConfigurationTE, +} from "@/api_fsb"; import { assert } from "@/lib/utils"; -import { ProfileQuestion, ValidationError } from '@/models/profilingQuestionsSlice'; -import { Filter } from 'bad-words'; +import { + ProfileQuestion, + ValidationError, +} from "@/models/profilingQuestionsSlice"; +import { Filter } from "bad-words"; const filter = new Filter(); export const questionUtils = { - - assignValidationState: (question: ProfileQuestion): ProfileQuestion => { - /* This function performs validation on the question's current answers and + assignValidationState: (question: ProfileQuestion): ProfileQuestion => { + /* This function performs validation on the question's current answers and assigns any error messages to question._metadata.error_msg. If the question is MC, validate: */ - const errors = question._validation.errors ?? [] + const errors = question._validation.errors ?? []; - question._validation.isComplete = errors.length === 0 - question._validation.isValid = errors.filter(e => e.severity === 'error').length === 0 + question._validation.isComplete = errors.length === 0; + question._validation.isValid = + errors.filter((e) => e.severity === "error").length === 0; - return question; - }, + return question; + }, - validate: (question: ProfileQuestion): ProfileQuestion => { - /* If the question is MC, validate: + validate: (question: ProfileQuestion): ProfileQuestion => { + /* If the question is MC, validate: - validate selector SA vs MA (1 selected vs >1 selected) - the answers match actual codes in the choices @@ -35,121 +42,132 @@ export const questionUtils = { - validation.patterns */ - assert(question.question_id, "Question must have question_id") - // assert(question.configuration, "Question must have configuration") - const answers = question._metadata.answers ?? [] - - // Declare it here, and they we'll reassign any specific - // configuration types based on the question type. - - // Start fresh without any error messages before validating - const errors: ValidationError[] = [] - - switch (question.question_type) { - case "TE": - - answers.length === 0 && errors.push({ - path: `question_id:${question.question_id}`, - message: "No answer provided", - severity: "warning", - } as ValidationError) - - answers.length > 1 && errors.push({ - path: `question_id:${question.question_id}`, - message: "Only one answer allowed", - severity: "error", - } as ValidationError) - - let answer_text: string = answers[0] - - const te_config = question.configuration as UpkQuestionConfigurationTE - const min_length = te_config.min_length ?? 0 - const max_length = te_config.max_length ?? 100_000 - - answer_text.length < min_length && errors.push({ - path: `question_id:${question.question_id}`, - message: "Answer shorter than allowed", - severity: "warning", - } as ValidationError) - - answer_text.length > max_length && errors.push({ - path: `question_id:${question.question_id}`, - message: "Answer longer than allowed", - severity: "error", - } as ValidationError) - - filter.isProfane(answer_text) && errors.push({ - path: `question_id:${question.question_id}`, - message: "Answer has inappropriate content", - severity: "error", - } as ValidationError) - - const patterns: PatternValidation[] = (question.validation ?? {})["patterns"] ?? [] - patterns.forEach((pv) => { - let re = new RegExp(pv.pattern) - answer_text.search(re) == -1 && errors.push({ - path: `question_id:${question.question_id}`, - message: pv.message, - severity: "error", - } as ValidationError) - - }) - - break; - - case "MC": - - answers.length === 0 && errors.push({ - path: `question_id:${question.question_id}`, - message: "Multiple Choice question with no selected answers", - severity: "warning", - } as ValidationError) - - const choice_codes: string[] = question.choices?.map((c) => c.choice_id) ?? []; - - const mc_config = (question.configuration ?? {}) as UpkQuestionConfigurationMC - const max_select = mc_config.max_select ?? choice_codes.length - - switch (question.selector) { - case "SA": - answers.length > 1 && errors.push({ - path: `question_id:${question.question_id}`, - message: "Single Answer MC question with >1 selected answers", - severity: "error", - } as ValidationError) - - break; - - case "MA": - answers.length > max_select && errors.push({ - path: `question_id:${question.question_id}`, - message: "More options selected than allowed", - severity: "error", - } as ValidationError) - - answers.length === max_select && errors.push({ - path: `question_id:${question.question_id}`, - message: "Selected all the options", - severity: "error", - } as ValidationError) - - break; - } - - // Now validate Multiple Choice answers regardless of if - // they're Single Answer (SA) or Multiple Answer (MA) - !answers.every(item => choice_codes.includes(item)) && errors.push({ - path: `question_id:${question.question_id}`, - message: "Invalid options selected", - severity: "error", - }) - - // const max_select: number = question.configuration?.max_select ?? choice_codes.length - // if (answer.values.length > max_select) { - // answer.error_msg = "More options selected than allowed" - // } - - /* + assert(question.question_id, "Question must have question_id"); + // assert(question.configuration, "Question must have configuration") + const answers = question._metadata.answers ?? []; + + // Declare it here, and they we'll reassign any specific + // configuration types based on the question type. + + // Start fresh without any error messages before validating + const errors: ValidationError[] = []; + + switch (question.question_type) { + case "TE": + answers.length === 0 && + errors.push({ + path: `question_id:${question.question_id}`, + message: "No answer provided", + severity: "warning", + } as ValidationError); + + answers.length > 1 && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Only one answer allowed", + severity: "error", + } as ValidationError); + + let answer_text: string = answers[0]; + + const te_config = question.configuration as UpkQuestionConfigurationTE; + const min_length = te_config.min_length ?? 0; + const max_length = te_config.max_length ?? 100_000; + + answer_text.length < min_length && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer shorter than allowed", + severity: "warning", + } as ValidationError); + + answer_text.length > max_length && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer longer than allowed", + severity: "error", + } as ValidationError); + + filter.isProfane(answer_text) && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer has inappropriate content", + severity: "error", + } as ValidationError); + + const patterns: PatternValidation[] = + (question.validation ?? {})["patterns"] ?? []; + patterns.forEach((pv) => { + let re = new RegExp(pv.pattern); + answer_text.search(re) == -1 && + errors.push({ + path: `question_id:${question.question_id}`, + message: pv.message, + severity: "error", + } as ValidationError); + }); + + break; + + case "MC": + answers.length === 0 && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Multiple Choice question with no selected answers", + severity: "warning", + } as ValidationError); + + const choice_codes: string[] = + question.choices?.map((c) => c.choice_id) ?? []; + + const mc_config = (question.configuration ?? + {}) as UpkQuestionConfigurationMC; + const max_select = mc_config.max_select ?? choice_codes.length; + + switch (question.selector) { + case "SA": + answers.length > 1 && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Single Answer MC question with >1 selected answers", + severity: "error", + } as ValidationError); + + break; + + case "MA": + answers.length > max_select && + errors.push({ + path: `question_id:${question.question_id}`, + message: "More options selected than allowed", + severity: "error", + } as ValidationError); + + answers.length === max_select && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Selected all the options", + severity: "error", + } as ValidationError); + + break; + } + + // Now validate Multiple Choice answers regardless of if + // they're Single Answer (SA) or Multiple Answer (MA) + !answers.every((item) => choice_codes.includes(item)) && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Invalid options selected", + severity: "error", + }); + + // const max_select: number = question.configuration?.max_select ?? choice_codes.length + // if (answer.values.length > max_select) { + // answer.error_msg = "More options selected than allowed" + // } + + /* exclusive_choice = next((x for x in question["choices"] if x.get("exclusive")), None) if exclusive_choice: exclusive_choice_id = exclusive_choice["choice_id"] @@ -157,21 +175,20 @@ export const questionUtils = { exclusive_choice_id not in answer, "Invalid exclusive selection" */ - break; - - default: - errors.push({ - path: `question_id:${question.question_id}`, - message: "Incorrect Question Type provided", - severity: "error", - } as ValidationError) - } - - // Assign the errors back to the question's _validation field and - // then return the question - // TODO: does this edit in place, maybe I don't need to return it - question._validation.errors = errors - return question; - }, - -};
\ No newline at end of file + break; + + default: + errors.push({ + path: `question_id:${question.question_id}`, + message: "Incorrect Question Type provided", + severity: "error", + } as ValidationError); + } + + // Assign the errors back to the question's _validation field and + // then return the question + // TODO: does this edit in place, maybe I don't need to return it + question._validation.errors = errors; + return question; + }, +}; diff --git a/jb-ui/src/pages/Home.tsx b/jb-ui/src/pages/Home.tsx index 5a5c953..8d509b6 100644 --- a/jb-ui/src/pages/Home.tsx +++ b/jb-ui/src/pages/Home.tsx @@ -1,13 +1,7 @@ -import max_lulu from "./max-and-lulu.jpg" +import max_lulu from "./max-and-lulu.jpg"; const Home = function () { - return ( - <img - src={max_lulu} - alt="Max and Lulu" - className="object-contain" - /> - ) + return <img src={max_lulu} alt="Max and Lulu" className="object-contain" />; }; -export default Home;
\ No newline at end of file +export default Home; diff --git a/jb-ui/src/pages/LinkAmtAccount.tsx b/jb-ui/src/pages/LinkAmtAccount.tsx index 0c9dab3..a72626f 100644 --- a/jb-ui/src/pages/LinkAmtAccount.tsx +++ b/jb-ui/src/pages/LinkAmtAccount.tsx @@ -5,69 +5,80 @@ import { Button } from "@/components/ui/button"; type RequestState = "idle" | "working" | "sent" | "failed" | "missing-worker"; const LinkAmtAccount = function () { - const [email, setEmail] = useState(""); - const [state, setState] = useState<RequestState>("idle"); - const [mockLink, setMockLink] = useState<string | null>(null); - const amtWorkerId = new URLSearchParams(window.location.search).get("workerId"); + const [email, setEmail] = useState(""); + const [state, setState] = useState<RequestState>("idle"); + const [mockLink, setMockLink] = useState<string | null>(null); + const amtWorkerId = new URLSearchParams(window.location.search).get( + "workerId", + ); - const submit = async (event: FormEvent<HTMLFormElement>) => { - event.preventDefault(); - if (!amtWorkerId) { - setState("missing-worker"); - return; - } + const submit = async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + if (!amtWorkerId) { + setState("missing-worker"); + return; + } - setState("working"); - setMockLink(null); - try { - const response = await fetch("/auth/link-amt/request", { - method: "POST", - credentials: "include", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - "email": email, - "amt_worker_id": amtWorkerId, - }), - }); - if (!response.ok) throw new Error("Account-link request failed"); - const body = await response.json() as {magic_link?: string}; - setMockLink(body.magic_link ?? null); - setState("sent"); - } catch { - setState("failed"); - } - }; + setState("working"); + setMockLink(null); + try { + const response = await fetch("/auth/link-amt/request", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email, + amt_worker_id: amtWorkerId, + }), + }); + if (!response.ok) throw new Error("Account-link request failed"); + const body = (await response.json()) as { magic_link?: string }; + setMockLink(body.magic_link ?? null); + setState("sent"); + } catch { + setState("failed"); + } + }; - return ( - <main className="flex min-h-screen items-center justify-center bg-zinc-200 p-4"> - <section className="w-full max-w-md space-y-4 rounded-lg bg-white p-6 shadow-lg"> - <h1 className="text-xl font-semibold">Link your MTurk account</h1> - <p>Enter the email address you want to use for James Billings.</p> - <form className="space-y-4" onSubmit={submit}> - <input - className="w-full rounded-md border px-3 py-2" - type="email" - autoComplete="email" - required - value={email} - onChange={(event) => setEmail(event.target.value)} - placeholder="you@example.com" - /> - <Button type="submit" disabled={state === "working"}> - {state === "working" ? "Sending…" : "Send link"} - </Button> - </form> - {state === "sent" && <p>Check your email to finish linking your account.</p>} - {state === "failed" && <p>Unable to send the link. Please try again.</p>} - {state === "missing-worker" && <p>This page is missing your MTurk worker ID.</p>} - {mockLink && ( - <p> - Development link: <a className="underline" href={mockLink}>continue</a> - </p> - )} - </section> - </main> - ); + return ( + <main className="flex min-h-screen items-center justify-center bg-zinc-200 p-4"> + <section className="w-full max-w-md space-y-4 rounded-lg bg-white p-6 shadow-lg"> + <h1 className="text-xl font-semibold">Link your MTurk account</h1> + <p>Enter the email address you want to use for James Billings.</p> + <form className="space-y-4" onSubmit={submit}> + <input + className="w-full rounded-md border px-3 py-2" + type="email" + autoComplete="email" + required + value={email} + onChange={(event) => setEmail(event.target.value)} + placeholder="you@example.com" + /> + <Button type="submit" disabled={state === "working"}> + {state === "working" ? "Sending…" : "Send link"} + </Button> + </form> + {state === "sent" && ( + <p>Check your email to finish linking your account.</p> + )} + {state === "failed" && ( + <p>Unable to send the link. Please try again.</p> + )} + {state === "missing-worker" && ( + <p>This page is missing your MTurk worker ID.</p> + )} + {mockLink && ( + <p> + Development link:{" "} + <a className="underline" href={mockLink}> + continue + </a> + </p> + )} + </section> + </main> + ); }; export default LinkAmtAccount; diff --git a/jb-ui/src/pages/MagicLink.tsx b/jb-ui/src/pages/MagicLink.tsx index 4042624..eb272a6 100644 --- a/jb-ui/src/pages/MagicLink.tsx +++ b/jb-ui/src/pages/MagicLink.tsx @@ -3,42 +3,44 @@ import { useEffect, useRef, useState } from "react"; type ExchangeState = "working" | "failed" | "missing"; const MagicLink = function () { - const started = useRef(false); - const [state, setState] = useState<ExchangeState>("working"); - - useEffect(() => { - // React Strict Mode runs effects twice in development. Never redeem twice. - if (started.current) return; - started.current = true; - - const params = new URLSearchParams(window.location.search); - const token = params.get("token"); - if (!token) { - setState("missing"); - return; - } - - // Keep the credential out of browser history and subsequent Referer headers. - window.history.replaceState({}, "", window.location.pathname); - - void fetch("/auth/magic-link/exchange", { - method: "POST", - credentials: "include", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({"token": token}), - }).then((response) => { - if (!response.ok) throw new Error("Magic-link exchange failed"); - window.location.replace("/"); - }).catch(() => setState("failed")); - }, []); - - if (state === "missing") { - return <p>This login link is missing its token.</p>; + const started = useRef(false); + const [state, setState] = useState<ExchangeState>("working"); + + useEffect(() => { + // React Strict Mode runs effects twice in development. Never redeem twice. + if (started.current) return; + started.current = true; + + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + if (!token) { + setState("missing"); + return; } - if (state === "failed") { - return <p>This login link is invalid or has expired.</p>; - } - return <p>Signing you in…</p>; + + // Keep the credential out of browser history and subsequent Referer headers. + window.history.replaceState({}, "", window.location.pathname); + + void fetch("/auth/magic-link/exchange", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: token }), + }) + .then((response) => { + if (!response.ok) throw new Error("Magic-link exchange failed"); + window.location.replace("/"); + }) + .catch(() => setState("failed")); + }, []); + + if (state === "missing") { + return <p>This login link is missing its token.</p>; + } + if (state === "failed") { + return <p>This login link is invalid or has expired.</p>; + } + return <p>Signing you in…</p>; }; export default MagicLink; diff --git a/jb-ui/src/pages/Preview.tsx b/jb-ui/src/pages/Preview.tsx index 153b4bb..6e50b0d 100644 --- a/jb-ui/src/pages/Preview.tsx +++ b/jb-ui/src/pages/Preview.tsx @@ -1,287 +1,300 @@ import { - Leaderboard, - LeaderboardApi, - LeaderboardCode, - LeaderboardFrequency, - LeaderboardRow, + Leaderboard, + LeaderboardApi, + LeaderboardCode, + LeaderboardFrequency, + LeaderboardRow, } from "@/api_fsb"; import { useAppSelector } from "@/hooks"; -import { bpid, formatCentsToUSD, truncate, } from "@/lib/utils"; +import { bpid, formatCentsToUSD, truncate } from "@/lib/utils"; import { activeSurveys, activeUsers, maxPayout } from "@/models/grlStatsSlice"; import { clsx } from "clsx"; -import moment from 'moment'; -import { useEffect, useState } from 'react'; +import moment from "moment"; +import { useEffect, useState } from "react"; const showSmartRank = ( - item: LeaderboardRow, - index: number, - items: LeaderboardRow[] | undefined + item: LeaderboardRow, + index: number, + items: LeaderboardRow[] | undefined, ): boolean => { - /** - * Smart rank calculation - this determines if we should show the rank - * value. It's confusing to show people that multiple individuals ranked - * in the same place, so this will only display the next ranked value, eg: - * - * - 1st - * - ___ - * - 3rd - * - ___ - * - ___ - * - 6th - * - * @returns number - */ - if (!items) return false; - - const thisRank = item.rank; - if (index >= 1) { - const prevRank = items[index - 1].rank; - if (thisRank === prevRank) { - return false; - } + /** + * Smart rank calculation - this determines if we should show the rank + * value. It's confusing to show people that multiple individuals ranked + * in the same place, so this will only display the next ranked value, eg: + * + * - 1st + * - ___ + * - 3rd + * - ___ + * - ___ + * - 6th + * + * @returns number + */ + if (!items) return false; + + const thisRank = item.rank; + if (index >= 1) { + const prevRank = items[index - 1].rank; + if (thisRank === prevRank) { + return false; } - return true; + } + return true; }; interface FrequencyButtonsProps { - selected: LeaderboardFrequency; - onChange: (frequency: LeaderboardFrequency) => void; + selected: LeaderboardFrequency; + onChange: (frequency: LeaderboardFrequency) => void; } const frequency_map = [ - { label: 'Day', value: LeaderboardFrequency.Daily }, - { label: 'Week', value: LeaderboardFrequency.Weekly }, - { label: 'Month', value: LeaderboardFrequency.Monthly }, + { label: "Day", value: LeaderboardFrequency.Daily }, + { label: "Week", value: LeaderboardFrequency.Weekly }, + { label: "Month", value: LeaderboardFrequency.Monthly }, ]; const ThreeButtonToggle = ({ selected, onChange }: FrequencyButtonsProps) => { - return ( - <div className="inline-flex rounded shadow-sm" role="group"> - {frequency_map.map((button, idx) => ( - <button - type="button" - key={button.value} - onClick={() => onChange(button.value)} - className={clsx( - "px-2 py-1 text-[10px] font-medium", - "border-t border-b border-gray-200", - "hover:cursor-pointer hover:bg-zinc-200 hover:text-zinc-950", - idx === 0 && "rounded-l", - idx === frequency_map.length - 1 && "rounded-r", - selected === button.value - ? 'text-zinc-50 bg-zinc-400' - : 'text-zinc-950 bg-zinc-50', - )}> - {button.label} - </button> - ))} - </div> - ); + return ( + <div className="inline-flex rounded shadow-sm" role="group"> + {frequency_map.map((button, idx) => ( + <button + type="button" + key={button.value} + onClick={() => onChange(button.value)} + className={clsx( + "px-2 py-1 text-[10px] font-medium", + "border-t border-b border-gray-200", + "hover:cursor-pointer hover:bg-zinc-200 hover:text-zinc-950", + idx === 0 && "rounded-l", + idx === frequency_map.length - 1 && "rounded-r", + selected === button.value + ? "text-zinc-50 bg-zinc-400" + : "text-zinc-950 bg-zinc-50", + )} + > + {button.label} + </button> + ))} + </div> + ); }; - - // Leaderboards Component export const Leaderboards = () => { - /** - * Leaderboards do not do any caching, or better state management. This is okay because - * the API is handled to take it. However, it can easily handled better client - * side rather than making the - * **/ - - const [frequency, setFrequency] = useState<LeaderboardFrequency>( - LeaderboardFrequency.Daily + /** + * Leaderboards do not do any caching, or better state management. This is okay because + * the API is handled to take it. However, it can easily handled better client + * side rather than making the + * **/ + + const [frequency, setFrequency] = useState<LeaderboardFrequency>( + LeaderboardFrequency.Daily, + ); + + const [payouts, setPayouts] = useState<Leaderboard | null>(null); + const [completes, setCompletes] = useState<Leaderboard | null>(null); + + // const bpuid = useAppSelector(state => state.app.bpuid) + const bpuid = undefined; + + useEffect(() => { + new LeaderboardApi() + .timespanLeaderboardProductIdLeaderboardTimespanBoardCodeGet( + bpid, + LeaderboardCode.CompleteCount, + frequency, + "us", + bpuid, // bpuid + undefined, // within_time + 10, + ) + .then((res) => { + setCompletes(res.data.leaderboard as Leaderboard); + }); + + new LeaderboardApi() + .timespanLeaderboardProductIdLeaderboardTimespanBoardCodeGet( + bpid, + LeaderboardCode.SumUserPayout, + frequency, + "us", + bpuid, + undefined, + 10, + ) + .then((res) => { + setPayouts(res.data.leaderboard as Leaderboard); + }); + }, [frequency]); + + let dateRange = " - "; + if (completes) { + const start = moment(completes?.start_timestamp * 1000).format( + "MMM Do, ha", ); - - const [payouts, setPayouts] = useState<Leaderboard | null>(null); - const [completes, setCompletes] = useState<Leaderboard | null>(null); - - // const bpuid = useAppSelector(state => state.app.bpuid) - const bpuid = undefined - - useEffect(() => { - new LeaderboardApi().timespanLeaderboardProductIdLeaderboardTimespanBoardCodeGet( - bpid, - LeaderboardCode.CompleteCount, - frequency, - "us", - bpuid, // bpuid - undefined, // within_time - 10) - .then(res => { - setCompletes(res.data.leaderboard as Leaderboard) - }) - - new LeaderboardApi().timespanLeaderboardProductIdLeaderboardTimespanBoardCodeGet( - bpid, - LeaderboardCode.SumUserPayout, - frequency, - "us", - bpuid, - undefined, - 10) - .then(res => { - setPayouts(res.data.leaderboard as Leaderboard) - }) - }, [frequency]); - - let dateRange = " - " - if (completes) { - const start = moment(completes?.start_timestamp * 1000).format("MMM Do, ha"); - const end = moment(completes?.end_timestamp * 1000).format("MMM Do, h:mm:ssa"); - //dateRange = `${start} – ${end} (${completes?.timezone_name})`; - dateRange = `${start} – ${end}`; - } - - const freq_label = frequency_map.find(f => f.value === frequency)?.label.toLocaleLowerCase() - - return ( - <div className="my-2 mx-4 py-0"> - <div className="w-full flex items-center justify-between my-1"> - {/* Left spacer (hidden on small screens) */} - <div className="hidden md:block md:w-1/3"></div> - - {/* Centered text */} - <h3 className="text-center flex-1 md:1/3"> - Leaderboards - </h3> - - {/* Right-aligned button group */} - <div className="flex md:w-1/3 justify-end"> - <ThreeButtonToggle - selected={frequency} - onChange={setFrequency} - /> - </div> - </div> - - <div className="grid grid-cols-1 mb-2 text-zinc-700"> - <p className="text-center text-[10px] font-thin"> - {dateRange} - </p> - </div> - - <div className="grid grid-cols-1 gap-4 px-12 md:px-0 - md:grid-cols-2"> - <table className="border-collapse text-emerald-950 text-[8px] md:text-sm"> - <caption className="caption-top text-xs font-semibold"> - Most earned in the past {freq_label}. - </caption> - <thead> - <tr className=" + const end = moment(completes?.end_timestamp * 1000).format( + "MMM Do, h:mm:ssa", + ); + //dateRange = `${start} – ${end} (${completes?.timezone_name})`; + dateRange = `${start} – ${end}`; + } + + const freq_label = frequency_map + .find((f) => f.value === frequency) + ?.label.toLocaleLowerCase(); + + return ( + <div className="my-2 mx-4 py-0"> + <div className="w-full flex items-center justify-between my-1"> + {/* Left spacer (hidden on small screens) */} + <div className="hidden md:block md:w-1/3"></div> + + {/* Centered text */} + <h3 className="text-center flex-1 md:1/3">Leaderboards</h3> + + {/* Right-aligned button group */} + <div className="flex md:w-1/3 justify-end"> + <ThreeButtonToggle selected={frequency} onChange={setFrequency} /> + </div> + </div> + + <div className="grid grid-cols-1 mb-2 text-zinc-700"> + <p className="text-center text-[10px] font-thin">{dateRange}</p> + </div> + + <div + className="grid grid-cols-1 gap-4 px-12 md:px-0 + md:grid-cols-2" + > + <table className="border-collapse text-emerald-950 text-[8px] md:text-sm"> + <caption className="caption-top text-xs font-semibold"> + Most earned in the past {freq_label}. + </caption> + <thead> + <tr + className=" font-mono font-semibold text-center - border border-zinc-150"> - <th>Rank</th> - <th>Bonus</th> - <th>User</th> - </tr> - </thead> - <tbody className="font-mono"> - - {payouts?.rows?.map((dataItem: LeaderboardRow) => ( - <tr key={dataItem.bpuid} className=" + border border-zinc-150" + > + <th>Rank</th> + <th>Bonus</th> + <th>User</th> + </tr> + </thead> + <tbody className="font-mono"> + {payouts?.rows?.map((dataItem: LeaderboardRow) => ( + <tr + key={dataItem.bpuid} + className=" text-center - border border-zinc-100"> - <td className="font-medium"> - {dataItem.rank} - </td> - <td className="font-semibold"> - {formatCentsToUSD(dataItem.value)} - </td> - <td className="font-light"> - {truncate(dataItem.bpuid, 6, "*****").toUpperCase()} - </td> - </tr> - ))} - </tbody> - </table> - - <table className="border-collapse border border-gray-400 - text-emerald-950 text-[8px] md:text-sm"> - <caption className="caption-top text-xs font-semibold"> - Top completes in the past {freq_label}. - </caption> - <thead> - <tr className=" + border border-zinc-100" + > + <td className="font-medium">{dataItem.rank}</td> + <td className="font-semibold"> + {formatCentsToUSD(dataItem.value)} + </td> + <td className="font-light"> + {truncate(dataItem.bpuid, 6, "*****").toUpperCase()} + </td> + </tr> + ))} + </tbody> + </table> + + <table + className="border-collapse border border-gray-400 + text-emerald-950 text-[8px] md:text-sm" + > + <caption className="caption-top text-xs font-semibold"> + Top completes in the past {freq_label}. + </caption> + <thead> + <tr + className=" font-mono font-semibold text-center - border border-zinc-150 rounded"> - <th>Rank</th> - <th>Completes</th> - <th>User</th> - </tr> - </thead> - <tbody className="font-mono"> - - {completes?.rows?.map((dataItem: LeaderboardRow, index: number) => ( - <tr key={dataItem.bpuid} className=" + border border-zinc-150 rounded" + > + <th>Rank</th> + <th>Completes</th> + <th>User</th> + </tr> + </thead> + <tbody className="font-mono"> + {completes?.rows?.map((dataItem: LeaderboardRow, index: number) => ( + <tr + key={dataItem.bpuid} + className=" text-center - border border-zinc-100"> - <td className="font-medium"> - {dataItem.rank ?? showSmartRank(dataItem, index, completes?.rows)} - </td> - <td className="font-semibold"> - {dataItem.value} - </td> - <td className="font-light"> - {truncate(dataItem.bpuid, 6, "*****").toUpperCase()} - </td> - </tr> - ))} - </tbody> - </table> - </div> - </div> - ); + border border-zinc-100" + > + <td className="font-medium"> + {dataItem.rank ?? + showSmartRank(dataItem, index, completes?.rows)} + </td> + <td className="font-semibold">{dataItem.value}</td> + <td className="font-light"> + {truncate(dataItem.bpuid, 6, "*****").toUpperCase()} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + ); }; - - // Preview Component const Preview = () => { - /** - * - * @returns {JSX.Element} - */ - - const users = useAppSelector(state => activeUsers(state)) - const users_f: string = users?.toLocaleString() ?? " – " - - const surveys = useAppSelector(state => activeSurveys(state)) - const surveys_f: string = surveys?.toLocaleString() ?? " – " - - const survey_max_cpi = useAppSelector(state => maxPayout(state)) - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }); - const survey_max_cpi_f: string = formatter.format((survey_max_cpi ?? 0) / 100) - - return ( - <div className="max-h-[calc(100vh-10rem)] overflow-y-auto"> - - <div className="bg-zinc-100 rounded py-2 m-0"> - <p className="text-center text-sm italic text-zinc-600"> - {users_f} people are actively attempting {surveys_f} available surveys right now. - </p> - </div> - - <Leaderboards /> - - <div className="bg-zinc-100 rounded py-2 px-6 m-0 text-xs text-center"> - <p className="py-1"> - Get paid to take surveys. Our algorithm attempts to pair you with the - highest paying survey available at the moment (right now, the highest - paying survey is {survey_max_cpi_f}) that you are eligible for, - each attempt is a valid HIT. Try as many as you can, our system will - self regulate you: we want to ensure you have a high chance of being - paired with a survey based on what surveys are available, as their - availability constantly changes.</p> - <p className="py-1"> - Each HIT is paid when you attempt to take a survey and if you complete - the survey, you will be awarded a bonus equal to that in which the - survey pays out.</p> - </div> - </div> - ); + /** + * + * @returns {JSX.Element} + */ + + const users = useAppSelector((state) => activeUsers(state)); + const users_f: string = users?.toLocaleString() ?? " – "; + + const surveys = useAppSelector((state) => activeSurveys(state)); + const surveys_f: string = surveys?.toLocaleString() ?? " – "; + + const survey_max_cpi = useAppSelector((state) => maxPayout(state)); + const formatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }); + const survey_max_cpi_f: string = formatter.format( + (survey_max_cpi ?? 0) / 100, + ); + + return ( + <div className="max-h-[calc(100vh-10rem)] overflow-y-auto"> + <div className="bg-zinc-100 rounded py-2 m-0"> + <p className="text-center text-sm italic text-zinc-600"> + {users_f} people are actively attempting {surveys_f} available surveys + right now. + </p> + </div> + + <Leaderboards /> + + <div className="bg-zinc-100 rounded py-2 px-6 m-0 text-xs text-center"> + <p className="py-1"> + Get paid to take surveys. Our algorithm attempts to pair you with the + highest paying survey available at the moment (right now, the highest + paying survey is {survey_max_cpi_f}) that you are eligible for, each + attempt is a valid HIT. Try as many as you can, our system will self + regulate you: we want to ensure you have a high chance of being paired + with a survey based on what surveys are available, as their + availability constantly changes. + </p> + <p className="py-1"> + Each HIT is paid when you attempt to take a survey and if you complete + the survey, you will be awarded a bonus equal to that in which the + survey pays out. + </p> + </div> + </div> + ); }; -export default Preview;
\ No newline at end of file +export default Preview; diff --git a/jb-ui/src/pages/Result.tsx b/jb-ui/src/pages/Result.tsx index 47af248..477f77e 100644 --- a/jb-ui/src/pages/Result.tsx +++ b/jb-ui/src/pages/Result.tsx @@ -10,114 +10,124 @@ import { useEffect } from "react"; // } export const Complete = () => { - const ts = useAppSelector(state => state.app.taskStatus) - - return ( - <> - <h2 className="text-lg">Survey Completed</h2> - <p className="text-emerald-700 text-lg font-bold">Congratulations! You completed the Survey.</p> - <p className="text-emerald-700 text-2xl font-bold">{ts?.user_payout_string}</p> - <p className="text-slate-300">You may close this tab and return to submit the HIT</p> - </> - ) -} + const ts = useAppSelector((state) => state.app.taskStatus); + + return ( + <> + <h2 className="text-lg">Survey Completed</h2> + <p className="text-emerald-700 text-lg font-bold"> + Congratulations! You completed the Survey. + </p> + <p className="text-emerald-700 text-2xl font-bold"> + {ts?.user_payout_string} + </p> + <p className="text-slate-300"> + You may close this tab and return to submit the HIT + </p> + </> + ); +}; export const Enter = () => { - return ( - <> - <h2 className="text-lg">Entered</h2> - <p className="text-slate-800 text-lg font-bold">You're currently in the Survey</p> - <p className="text-slate-300">You may close this tab and return to submit the HIT</p> - </> - ) -} + return ( + <> + <h2 className="text-lg">Entered</h2> + <p className="text-slate-800 text-lg font-bold"> + You're currently in the Survey + </p> + <p className="text-slate-300"> + You may close this tab and return to submit the HIT + </p> + </> + ); +}; export const InComplete = () => { - const ts = useAppSelector(state => state.app.taskStatus) - if (!ts) { return null; } - - return ( - <> - <h2 className="text-lg">HIT Complete</h2> - <p className="text-rose-800 text-lg font-bold">Survey terminated the attempt.</p> - <p className="text-slate-300">You may close this tab and return to submit the HIT</p> - </> - - ) -} + const ts = useAppSelector((state) => state.app.taskStatus); + if (!ts) { + return null; + } + + return ( + <> + <h2 className="text-lg">HIT Complete</h2> + <p className="text-rose-800 text-lg font-bold"> + Survey terminated the attempt. + </p> + <p className="text-slate-300"> + You may close this tab and return to submit the HIT + </p> + </> + ); +}; export const Unknown = () => { - - return ( - <> - <h2 className="text-lg">Task Issue</h2> - <p className="text-rose-800 text-lg font-bold">You're not currently eligible for any Surveys</p> - <p className="text-slate-300">You may close this tab and return to submit the HIT</p> - </> - ) -} + return ( + <> + <h2 className="text-lg">Task Issue</h2> + <p className="text-rose-800 text-lg font-bold"> + You're not currently eligible for any Surveys + </p> + <p className="text-slate-300"> + You may close this tab and return to submit the HIT + </p> + </> + ); +}; export const Loading = () => { - - return ( - <p className="text-2xl font-semibold">Loading...</p> - ) -} - + return <p className="text-2xl font-semibold">Loading...</p>; +}; const Result = () => { - - const ts = useAppSelector(state => state.app.taskStatus) - - const confettiCannons = () => { - const end = Date.now() + 3 * 1000 - const colors = ["#84cc16", "#1e3a8a", "#ec4899"] - const frame = () => { - if (Date.now() > end) return - confetti({ - particleCount: 3, - angle: 60, - spread: 55, - startVelocity: 80, - gravity: 0.7, - origin: { x: 0, y: 0.8 }, - colors: colors, - }) - confetti({ - particleCount: 3, - angle: 120, - spread: 55, - startVelocity: 80, - gravity: 0.7, - origin: { x: 1, y: 0.8 }, - colors: colors, - }) - requestAnimationFrame(frame) - } - frame() + const ts = useAppSelector((state) => state.app.taskStatus); + + const confettiCannons = () => { + const end = Date.now() + 3 * 1000; + const colors = ["#84cc16", "#1e3a8a", "#ec4899"]; + const frame = () => { + if (Date.now() > end) return; + confetti({ + particleCount: 3, + angle: 60, + spread: 55, + startVelocity: 80, + gravity: 0.7, + origin: { x: 0, y: 0.8 }, + colors: colors, + }); + confetti({ + particleCount: 3, + angle: 120, + spread: 55, + startVelocity: 80, + gravity: 0.7, + origin: { x: 1, y: 0.8 }, + colors: colors, + }); + requestAnimationFrame(frame); + }; + frame(); + }; + + useEffect(() => { + if (ts?.status === 3) { + confettiCannons(); } - - useEffect(() => { - if (ts?.status === 3) { - confettiCannons() - } - }, [ts]) - - - switch (ts?.status) { - case 0: // UNKNOWN - return <Unknown />; - case 1: // ENTER - return <Enter />; - case 2: // INCOMPLETE - return <InComplete />; - case 3: // COMPLETE - return <Complete />; - default: - return <Loading />; - } - + }, [ts]); + + switch (ts?.status) { + case 0: // UNKNOWN + return <Unknown />; + case 1: // ENTER + return <Enter />; + case 2: // INCOMPLETE + return <InComplete />; + case 3: // COMPLETE + return <Complete />; + default: + return <Loading />; + } }; - -export default Result;
\ No newline at end of file +export default Result; diff --git a/jb-ui/src/pages/Transfer.tsx b/jb-ui/src/pages/Transfer.tsx new file mode 100644 index 0000000..6e087d6 --- /dev/null +++ b/jb-ui/src/pages/Transfer.tsx @@ -0,0 +1,50 @@ +import { clsx } from "clsx"; + +import { useSelector } from "react-redux"; + +const Transfer = function () { + let error = []; + + const handleInputChange = ( + event: React.KeyboardEvent<HTMLInputElement>, + ) => {}; + + const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>) => {}; + + const handleSubmit = (e) => { + e.preventDefault(); + console.log("Form submitted successfully!"); + }; + + return ( + <div className="px-6 my-6"> + <h2 + className=" + bg-red-500 rounded py-2 + text-white text-2xl font-semibold text-center" + > + Transfer Required + </h2> + <p + className=" + py-4 + text-zinc-500 text-2xl font-semibold text-center" + > + James Billings is finished 😭 Just Kidding!!! + </p> + + <input + type="text" + id="text-entry-input" + aria-describedby="" + // defaultValue={answer?.values.length ? answer?.values[0] : ""} + // onKeyUp={handleInputChange} + // onKeyDown={handleKeyPress} // Use onKeyDown to listen for the key press + // title={error ? answer?.error_msg : ""} + className={clsx("w-full border-1 rounded px-2 py-1")} + /> + </div> + ); +}; + +export default Transfer; diff --git a/jb-ui/src/pages/Work.tsx b/jb-ui/src/pages/Work.tsx index c54732f..32dcc96 100644 --- a/jb-ui/src/pages/Work.tsx +++ b/jb-ui/src/pages/Work.tsx @@ -1,765 +1,847 @@ import { - BucketTask, - OfferwallApi, - OfferwallReason, - ReportApi, ReportTask, ReportValue, StatusApi, - TasksStatusResponse, TaskStatusResponse, TopNPlusBlockOfferWall + BucketTask, + OfferwallApi, + OfferwallReason, + ReportApi, + ReportTask, + ReportValue, + StatusApi, + TasksStatusResponse, + TaskStatusResponse, + TopNPlusBlockOfferWall, } from "@/api_fsb"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, - FieldLegend, - FieldSet, + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, } from "@/components/ui/field"; import { useAppDispatch, useAppSelector } from "@/hooks"; import { bpid, formatCentsToUSD, formatSource, truncate } from "@/lib/utils"; import { - getAvailabilityCount, getLOIText, - isLowBalance, - selectBucket, - setAttemptedLiveEligibleCount, - setAvailabilityCount, - setCurrentBuckets, - setEnteredTimestamp, setLOI, - setOfferwallReasons, - setTaskStatus + getAvailabilityCount, + getLOIText, + isLowBalance, + selectBucket, + setAttemptedLiveEligibleCount, + setAvailabilityCount, + setCurrentBuckets, + setEnteredTimestamp, + setLOI, + setOfferwallReasons, + setTaskStatus, } from "@/models/appSlice"; import { - createColumnHelper, flexRender, getCoreRowModel, - useReactTable + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, } from "@tanstack/react-table"; import { XIcon } from "lucide-react"; import moment from "moment/moment"; import { useEffect, useRef, useState } from "react"; -import Markdown from 'react-markdown'; +import Markdown from "react-markdown"; const Report = ({ onClose }: { onClose: () => void }) => { - const [showComplete, setShowComplete] = useState(false); - const [selectedValues, setSelectedValues] = useState<ReportValue[]>([]); - - const bpuid = useAppSelector(state => state.app.bpuid) - const ts = useAppSelector(state => state.app.taskStatus) - - const handleCheckboxChange = (value: ReportValue, checked: boolean) => { - setSelectedValues(prev => - checked - ? [...prev, value] - : prev.filter(v => v !== value) - ); + const [showComplete, setShowComplete] = useState(false); + const [selectedValues, setSelectedValues] = useState<ReportValue[]>([]); + + const bpuid = useAppSelector((state) => state.app.bpuid); + const ts = useAppSelector((state) => state.app.taskStatus); + + const handleCheckboxChange = (value: ReportValue, checked: boolean) => { + setSelectedValues((prev) => + checked ? [...prev, value] : prev.filter((v) => v !== value), + ); + }; + + const clicked_submit = () => { + if (!bpuid) return; + // ReportValue.NUMBER_0 + console.log("selectedValues:", selectedValues); + + const rt: ReportTask = { + bpuid: bpuid, + reasons: selectedValues, + notes: "", }; - const clicked_submit = () => { - if (!bpuid) return; - // ReportValue.NUMBER_0 - console.log("selectedValues:", selectedValues); - - const rt: ReportTask = { - 'bpuid': bpuid, - 'reasons': selectedValues, - 'notes': "" - }; - - new ReportApi().reportTaskProductIdReportPost( - bpid, // productId - rt, // reportTask - ).then(res => { - console.log("Response:", res); // Check what's actually here - - if (res.status === 400) { - // Handle error here instead - } else { - setShowComplete(true); - } - }) - } - - const reportReasons = [ - { id: 'TECHNICAL_ERROR', label: 'Technical Error', value: ReportValue.NUMBER_1 }, - { id: 'NO_REDIRECT', label: 'No Redirect', value: ReportValue.NUMBER_2 }, - { id: 'PRIVACY_INVASION', label: 'Privacy Invasion', value: ReportValue.NUMBER_3 }, - { id: 'UNCOMFORTABLE_TOPICS', label: 'Uncomfortable Topics', value: ReportValue.NUMBER_4 }, - { id: 'ASKED_FOR_NOT_ALLOWED_ACTION', label: 'Asked for Not Allowed Action', value: ReportValue.NUMBER_5 }, - { id: 'BAD_ON_MOBILE', label: 'Bad on Mobile', value: ReportValue.NUMBER_6 }, - { id: 'DIDNT_LIKE', label: "Didn't Like", value: ReportValue.NUMBER_7 }, - ]; - - if (!ts?.finished) { - return <p>Please finish a Task to report its status.</p> - } - - if (showComplete) { - return <SubimtAMT /> - } - - return ( - <div className="border-2 border-rose-200 rounded"> - <div className="flex justify-center"> - <FieldSet className="mt-2"> - <FieldLegend variant="label" className="my-0 text-rose-950"> - Please select all reasons why you are reporting this Task: - </FieldLegend> - <FieldDescription className="my-0 text-rose-950"> - You must provide at least one reason to report a Task. - </FieldDescription> - <FieldGroup className="gap-2 my-0 py-0"> - - {reportReasons.map((reason) => ( - <Field key={reason.id} orientation="horizontal" className=" - hover:cursor-pointer"> - <Checkbox - id={reason.id} - name={reason.id} - onCheckedChange={(checked) => - handleCheckboxChange(reason.value, checked as boolean) - } - /> - <FieldLabel - htmlFor={reason.id} - className="font-medium text-rose-950 + new ReportApi() + .reportTaskProductIdReportPost( + bpid, // productId + rt, // reportTask + ) + .then((res) => { + console.log("Response:", res); // Check what's actually here + + if (res.status === 400) { + // Handle error here instead + } else { + setShowComplete(true); + } + }); + }; + + const reportReasons = [ + { + id: "TECHNICAL_ERROR", + label: "Technical Error", + value: ReportValue.NUMBER_1, + }, + { id: "NO_REDIRECT", label: "No Redirect", value: ReportValue.NUMBER_2 }, + { + id: "PRIVACY_INVASION", + label: "Privacy Invasion", + value: ReportValue.NUMBER_3, + }, + { + id: "UNCOMFORTABLE_TOPICS", + label: "Uncomfortable Topics", + value: ReportValue.NUMBER_4, + }, + { + id: "ASKED_FOR_NOT_ALLOWED_ACTION", + label: "Asked for Not Allowed Action", + value: ReportValue.NUMBER_5, + }, + { + id: "BAD_ON_MOBILE", + label: "Bad on Mobile", + value: ReportValue.NUMBER_6, + }, + { id: "DIDNT_LIKE", label: "Didn't Like", value: ReportValue.NUMBER_7 }, + ]; + + if (!ts?.finished) { + return <p>Please finish a Task to report its status.</p>; + } + + if (showComplete) { + return <SubimtAMT />; + } + + return ( + <div className="border-2 border-rose-200 rounded"> + <div className="flex justify-center"> + <FieldSet className="mt-2"> + <FieldLegend variant="label" className="my-0 text-rose-950"> + Please select all reasons why you are reporting this Task: + </FieldLegend> + <FieldDescription className="my-0 text-rose-950"> + You must provide at least one reason to report a Task. + </FieldDescription> + <FieldGroup className="gap-2 my-0 py-0"> + {reportReasons.map((reason) => ( + <Field + key={reason.id} + orientation="horizontal" + className=" + hover:cursor-pointer" + > + <Checkbox + id={reason.id} + name={reason.id} + onCheckedChange={(checked) => + handleCheckboxChange(reason.value, checked as boolean) + } + /> + <FieldLabel + htmlFor={reason.id} + className="font-medium text-rose-950 hover:cursor-pointer hover:font-semibold" - > - {reason.label} - </FieldLabel> - </Field> - ))} - - </FieldGroup> - </FieldSet> - </div> - <div className="flex justify-center my-2 space-x-5"> - <Button variant="outline" size="icon" aria-label="Submit" - className=" + > + {reason.label} + </FieldLabel> + </Field> + ))} + </FieldGroup> + </FieldSet> + </div> + <div className="flex justify-center my-2 space-x-5"> + <Button + variant="outline" + size="icon" + aria-label="Submit" + className=" border-2 rounded border-zinc-800 text-zinc-800 hover:cursor-pointer hover:bg-zinc-800 hover:text-zinc-50" - onClick={onClose}> - <XIcon /> - </Button> - <Button variant="outline" - className=" + onClick={onClose} + > + <XIcon /> + </Button> + <Button + variant="outline" + className=" border-2 rounded border-rose-400 text-rose-950 hover:cursor-pointer hover:bg-rose-400 hover:text-rose-50" - onClick={clicked_submit}>Submit and Complete HIT</Button> - </div> - </div> - ) -} + onClick={clicked_submit} + > + Submit and Complete HIT + </Button> + </div> + </div> + ); +}; const SubimtAMT = () => { - const bpuid = useAppSelector(state => state.app.bpuid) - const assignment_id = useAppSelector(state => state.app.assignment_id) - const ts = useAppSelector(state => state.app.taskStatus) - const turkSubmitTo = useAppSelector(state => state.app.turkSubmitTo) + const bpuid = useAppSelector((state) => state.app.bpuid); + const assignment_id = useAppSelector((state) => state.app.assignment_id); + const ts = useAppSelector((state) => state.app.taskStatus); + const turkSubmitTo = useAppSelector((state) => state.app.turkSubmitTo); - const formRef = useRef<HTMLFormElement>(null); + const formRef = useRef<HTMLFormElement>(null); - useEffect(() => { - if (!ts) return; - if (!bpuid) return; - if (!assignment_id) return; - if (!turkSubmitTo) return; + useEffect(() => { + if (!ts) return; + if (!bpuid) return; + if (!assignment_id) return; + if (!turkSubmitTo) return; - if (formRef.current) { - formRef.current.submit(); - } - }, [ts?.tsid, bpuid, assignment_id, turkSubmitTo]); + if (formRef.current) { + formRef.current.submit(); + } + }, [ts?.tsid, bpuid, assignment_id, turkSubmitTo]); + + return ( + <> + <form + ref={formRef} + action={`${turkSubmitTo}/mturk/externalSubmit`} + method="POST" + style={{ display: "none" }} + > + <input type="hidden" name="assignmentId" value={assignment_id} /> + <input type="hidden" name="tsid" value={ts?.tsid} /> + <input type="hidden" name="amt_assignment_id" value={assignment_id} /> + <input type="hidden" name="amt_worker_id" value={bpuid} /> + <button type="submit">Submit HIT</button> + </form> + </> + ); +}; - return ( - <> - <form - ref={formRef} - action={`${turkSubmitTo}/mturk/externalSubmit`} - method="POST" - style={{ display: 'none' }}> - <input type="hidden" name="assignmentId" value={assignment_id} /> - <input type="hidden" name="tsid" value={ts?.tsid} /> - <input type="hidden" name="amt_assignment_id" value={assignment_id} /> - <input type="hidden" name="amt_worker_id" value={bpuid} /> - <button type="submit">Submit HIT</button> - </form> - </> - ) -} +const SurveyMonitor = () => { + const dispatch = useAppDispatch(); + const [isPolling, setIsPolling] = useState(true); + // const [timeAgo, setTimeAgo] = useState(""); -const SurveyMonitor = () => { - const dispatch = useAppDispatch() - - const [isPolling, setIsPolling] = useState(true); - // const [timeAgo, setTimeAgo] = useState(""); - - const [showComplete, setShowComplete] = useState(false); - const [showReport, setShowReport] = useState(false); - - const bpuid = useAppSelector(state => state.app.bpuid) - const entered = useAppSelector(state => state.app.currentBucketEntered) - const isLow = useAppSelector(state => isLowBalance(state)) - - const ts = useAppSelector(state => state.app.taskStatus) - const isBaddie = ts?.status_code_1 === "SESSION_START_FAIL" || ts?.status_code_1 === "SESSION_START_QUALITY_FAIL" || ts?.status_code_1 === "SESSION_CONTINUE_QUALITY_FAIL"; - - // useEffect(() => { - // if (!ts) return; - - // const updateTimeAgo = () => { - // const time_ago = moment(ts.started).fromNow(); - // setTimeAgo(time_ago); - // }; - - // // Update immediately - // updateTimeAgo(); - - // // Then update every minute - // const intervalId = setInterval(updateTimeAgo, 60 * 1000); - - // return () => clearInterval(intervalId); - // }, [ts]); - - useEffect(() => { - if (!bpuid) return; - if (!entered) return; - if (!isPolling) return; - - const pollApi = async () => { - new StatusApi().listTaskStatusesProductIdStatusGet( - bpid, // productId - bpuid, // Worker ID - entered, // startedAfter - moment.utc().unix() // startedBefore - ).then(res => { - const d = res.data as TasksStatusResponse; - - if (!d.tasks_status || d.tasks_status.length === 0) { - // Likely still in a GRS survey - return; - } - - // if (d_len >= 2) Sentry.captureMessage("Results returned back multiple") - const ts = d.tasks_status![0] as TaskStatusResponse; - dispatch(setTaskStatus(ts)) - - if (ts.status == 2 || ts.status == 3) { - // INCOMPLETE + COMPLETE - setIsPolling(false); - } - }); - - // If there is already a TaskStatus, update the time ago - // if (ts) { - // const time_ago = moment(ts.started).fromNow(); - // setTimeAgo(time_ago); - // } - }; - - // Call immediately on mount, then set up interval - pollApi(); - const intervalId = setInterval(pollApi, 4 * 1000); - - // Cleanup: stop interval when component unmounts or isPolling changes - return () => clearInterval(intervalId); - }, [isPolling, entered]); - - if (!ts) { - return ( - <p>Loading</p> - ) - } + const [showComplete, setShowComplete] = useState(false); + const [showReport, setShowReport] = useState(false); - // This is if they are actively in a survey or we can't determine the - // status yet. We want to show the timer and keep them on this page to - // monitor for completion. This gets shown no matter what regardless of - // balance. - // - // 0 - UNKNOWN, 1 - ENTER - if (ts.status === 0 || ts.status === 1) { - return ( - <> - <div> - <h2 className=" - bg-blue-500 rounded py-2 - text-white text-2xl font-semibold text-center"> - In Progress - </h2> - <p className=" - py-4 - text-zinc-500 text-2xl font-semibold text-center"> - We're actively monitoring your Task status. - </p> - </div> - </> + const bpuid = useAppSelector((state) => state.app.bpuid); + const entered = useAppSelector((state) => state.app.currentBucketEntered); + const isLow = useAppSelector((state) => isLowBalance(state)); + + const ts = useAppSelector((state) => state.app.taskStatus); + const isBaddie = + ts?.status_code_1 === "SESSION_START_FAIL" || + ts?.status_code_1 === "SESSION_START_QUALITY_FAIL" || + ts?.status_code_1 === "SESSION_CONTINUE_QUALITY_FAIL"; + + // useEffect(() => { + // if (!ts) return; + + // const updateTimeAgo = () => { + // const time_ago = moment(ts.started).fromNow(); + // setTimeAgo(time_ago); + // }; + + // // Update immediately + // updateTimeAgo(); + + // // Then update every minute + // const intervalId = setInterval(updateTimeAgo, 60 * 1000); + + // return () => clearInterval(intervalId); + // }, [ts]); + + useEffect(() => { + if (!bpuid) return; + if (!entered) return; + if (!isPolling) return; + + const pollApi = async () => { + new StatusApi() + .listTaskStatusesProductIdStatusGet( + bpid, // productId + bpuid, // Worker ID + entered, // startedAfter + moment.utc().unix(), // startedBefore ) - } + .then((res) => { + const d = res.data as TasksStatusResponse; + + if (!d.tasks_status || d.tasks_status.length === 0) { + // Likely still in a GRS survey + return; + } + + // if (d_len >= 2) Sentry.captureMessage("Results returned back multiple") + const ts = d.tasks_status![0] as TaskStatusResponse; + dispatch(setTaskStatus(ts)); + + if (ts.status == 2 || ts.status == 3) { + // INCOMPLETE + COMPLETE + setIsPolling(false); + } + }); + + // If there is already a TaskStatus, update the time ago + // if (ts) { + // const time_ago = moment(ts.started).fromNow(); + // setTimeAgo(time_ago); + // } + }; - // This is if they have finished a survey complete and it resulted in a - // COMPLETE or a INCOMPLETE (doesn't matter). They can now submit the HIT. - // - // 2 - INCOMPLETE, 3 - COMPLETE + // Call immediately on mount, then set up interval + pollApi(); + const intervalId = setInterval(pollApi, 4 * 1000); + + // Cleanup: stop interval when component unmounts or isPolling changes + return () => clearInterval(intervalId); + }, [isPolling, entered]); + + if (!ts) { + return <p>Loading</p>; + } + + // This is if they are actively in a survey or we can't determine the + // status yet. We want to show the timer and keep them on this page to + // monitor for completion. This gets shown no matter what regardless of + // balance. + // + // 0 - UNKNOWN, 1 - ENTER + if (ts.status === 0 || ts.status === 1) { return ( - <> - <div> - <h2 className=" + <> + <div> + <h2 + className=" bg-blue-500 rounded py-2 - text-white text-2xl font-semibold text-center"> - {ts.status === 3 ? "Survey Complete!" : "Survey Terminated."} - </h2> - - {((ts.status === 3 || !isLow) && !isBaddie) && ( - <p className=" + text-white text-2xl font-semibold text-center" + > + In Progress + </h2> + <p + className=" + py-4 + text-zinc-500 text-2xl font-semibold text-center" + > + We're actively monitoring your Task status. + </p> + </div> + </> + ); + } + + // This is if they have finished a survey complete and it resulted in a + // COMPLETE or a INCOMPLETE (doesn't matter). They can now submit the HIT. + // + // 2 - INCOMPLETE, 3 - COMPLETE + return ( + <> + <div> + <h2 + className=" + bg-blue-500 rounded py-2 + text-white text-2xl font-semibold text-center" + > + {ts.status === 3 ? "Survey Complete!" : "Survey Terminated."} + </h2> + + {(ts.status === 3 || !isLow) && !isBaddie && ( + <p + className=" py-2 - text-zinc-500 text-md font-medium text-center"> - You may now submit the HIT. - </p> - )} - </div> - - {(isBaddie) ? ( - <> - <div className=" + text-zinc-500 text-md font-medium text-center" + > + You may now submit the HIT. + </p> + )} + </div> + + {isBaddie ? ( + <> + <div + className=" border-2 border-rose-500 rounded py-2 - bg-rose-400 text-rose-700"> - <h2 className="text-center text-xl font-bold text-white"> - Quality Check Failed. - </h2> - <p className="px-4 py-2 text-center text-sm font-medium text-white"> - Unfortunately, it looks like you did not pass the quality check for this survey. - </p> - - <p className=" + bg-rose-400 text-rose-700" + > + <h2 className="text-center text-xl font-bold text-white"> + Quality Check Failed. + </h2> + <p className="px-4 py-2 text-center text-sm font-medium text-white"> + Unfortunately, it looks like you did not pass the quality check + for this survey. + </p> + + <p + className=" pt-0 pb-4 - text-red-950 text-sm font-semibold text-center"> - Please return the HIT to avoid having your assignment rejected. - </p> - </div> - </> - - ) : ( - <> - {(ts.status === 3 || !isLow) ? ( - <> - <div className="flex justify-center my-4 space-x-5"> - - <button type="button" className=" + text-red-950 text-sm font-semibold text-center" + > + Please return the HIT to avoid having your assignment rejected. + </p> + </div> + </> + ) : ( + <> + {ts.status === 3 || !isLow ? ( + <> + <div className="flex justify-center my-4 space-x-5"> + <button + type="button" + className=" border-4 rounded border-emerald-400 py-1 px-3 text-xl font-bold text-emerald-950 hover:cursor-pointer hover:bg-emerald-400 hover:text-emerald-50" - onClick={() => setShowComplete(true)} - >Submit HIT</button> - - <button type="button" className=" + onClick={() => setShowComplete(true)} + > + Submit HIT + </button> + + <button + type="button" + className=" border-4 rounded border-rose-200 py-1 px-3 text-xl font-bold text-rose-200 hover:cursor-pointer hover:border-rose-500 hover:bg-rose-400 hover:text-rose-50" - onClick={() => setShowReport(!showReport)} - >Report Survey</button> - </div> - - {showComplete && <SubimtAMT />} - {showReport && <Report onClose={() => setShowReport(false)} />} - </> - ) : ( - <div> - <p className=" - text-red-600 text-lg font-semibold text-center"> - Your balance is low. You cannot submit the HIT at this time. - </p> - - <p className=" - pt-0 pb-4 - text-red-950 text-sm font-semibold text-center"> - Please return the HIT to avoid having your assignment rejected. - </p> - </div> - )} - </> - - )} - + onClick={() => setShowReport(!showReport)} + > + Report Survey + </button> + </div> + + {showComplete && <SubimtAMT />} + {showReport && <Report onClose={() => setShowReport(false)} />} + </> + ) : ( + <div> + <p + className=" + text-red-600 text-lg font-semibold text-center" + > + Your balance is low. You cannot submit the HIT at this time. + </p> + <p + className=" + pt-0 pb-4 + text-red-950 text-sm font-semibold text-center" + > + Please return the HIT to avoid having your assignment rejected. + </p> + </div> + )} </> - ) -} - + )} + </> + ); +}; const LOISelect = () => { - // This is what's responsible for being able to click on the text - // we set the global LOI times. - - const dispatch = useAppDispatch() - const loi: number = useAppSelector(state => state.app.loi) - const bucket = useAppSelector(state => selectBucket(state)) - - const toggle_loi = function () { - dispatch(setLOI(loi >= 1800 ? 600 : loi + 600)) - } - - const time_f = useAppSelector(state => getLOIText(state)) - - const avg_time_f = ((bucket?.duration.q2 ?? 0) / 60).toLocaleString( - 'en-US', { minimumFractionDigits: 0, maximumFractionDigits: 1 }) - - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }); - const avg_pay_f: string = formatter.format((bucket?.payout.q2 ?? 0) / 100) - - return ( - <div className="bg-zinc-100 rounded py-2 m-0"> - <p className="text-zinc-700 text-2xl text-center font-bold m-0"> - I have <span - className="cursor-pointer text-emerald-600 italic hover:text-emerald-700 hover:underline" - onClick={toggle_loi}>{time_f}</span> minutes - </p> - <p - className="text-center text-xs font-thin m-0 text-zinc-500"> - The average time spent is {avg_time_f} minutes and pays {avg_pay_f}</p> - </div> - ) - + // This is what's responsible for being able to click on the text + // we set the global LOI times. + + const dispatch = useAppDispatch(); + const loi: number = useAppSelector((state) => state.app.loi); + const bucket = useAppSelector((state) => selectBucket(state)); + + const toggle_loi = function () { + dispatch(setLOI(loi >= 1800 ? 600 : loi + 600)); + }; + + const time_f = useAppSelector((state) => getLOIText(state)); + + const avg_time_f = ((bucket?.duration.q2 ?? 0) / 60).toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }); + + const formatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }); + const avg_pay_f: string = formatter.format((bucket?.payout.q2 ?? 0) / 100); + + return ( + <div className="bg-zinc-100 rounded py-2 m-0"> + <p className="text-zinc-700 text-2xl text-center font-bold m-0"> + I have{" "} + <span + className="cursor-pointer text-emerald-600 italic hover:text-emerald-700 hover:underline" + onClick={toggle_loi} + > + {time_f} + </span>{" "} + minutes + </p> + <p className="text-center text-xs font-thin m-0 text-zinc-500"> + The average time spent is {avg_time_f} minutes and pays {avg_pay_f} + </p> + </div> + ); }; const SurveyLoading = () => { - return ( - <div className=" + return ( + <div + className=" border-8 border-emerald-400 rounded py-0 my-2 text-center text-3xl - hover:cursor-pointer hover:bg-green-500 hover:text-white"> - <p className="py-2 text-center text-3xl font-semibold"> - Survey Loading… - </p> - </div> - ) -} - + hover:cursor-pointer hover:bg-green-500 hover:text-white" + > + <p className="py-2 text-center text-3xl font-semibold">Survey Loading…</p> + </div> + ); +}; const NoSurveys = () => { - const offerwall_reasons = useAppSelector(state => state.app.offerwall_reasons) - - const REASON_DISPLAY: Record<OfferwallReason, { label: string; description: string }> = { - [OfferwallReason.UserBlocked]: { - label: "Account Restricted", - description: "Your account has been restricted.", - }, - [OfferwallReason.HighReconRate]: { - label: "High Reconciliation Rate", - description: "Your account has an unusually high reconciliation rate.", - }, - [OfferwallReason.UncommonDemographics]: { - label: "Demographic Mismatch", - description: "No surveys require your current demographics.", - }, - [OfferwallReason.UnderMinimumAge]: { - label: "Age Requirement Not Met", - description: "You must meet the minimum age requirement to participate.", - }, - [OfferwallReason.ExhaustedHighValueSupply]: { - label: "No Premium Offers Available", - description: "You've attempted all premium surveys.", - }, - [OfferwallReason.AllEligibleAttempted]: { - label: "All Offers Attempted", - description: "You've attempted all available surveys.", - }, - [OfferwallReason.LowCurrentSupply]: { - label: "Limited Availability", - description: "There are currently limited opportunities available.", - }, - }; - - function formatReasons(reasons: OfferwallReason[]): { label: string; description: string }[] { - return reasons.map((r) => REASON_DISPLAY[r]); - } + const offerwall_reasons = useAppSelector( + (state) => state.app.offerwall_reasons, + ); - return ( - <> - <div className=" + const REASON_DISPLAY: Record< + OfferwallReason, + { label: string; description: string } + > = { + [OfferwallReason.UserBlocked]: { + label: "Account Restricted", + description: "Your account has been restricted.", + }, + [OfferwallReason.HighReconRate]: { + label: "High Reconciliation Rate", + description: "Your account has an unusually high reconciliation rate.", + }, + [OfferwallReason.UncommonDemographics]: { + label: "Demographic Mismatch", + description: "No surveys require your current demographics.", + }, + [OfferwallReason.UnderMinimumAge]: { + label: "Age Requirement Not Met", + description: "You must meet the minimum age requirement to participate.", + }, + [OfferwallReason.ExhaustedHighValueSupply]: { + label: "No Premium Offers Available", + description: "You've attempted all premium surveys.", + }, + [OfferwallReason.AllEligibleAttempted]: { + label: "All Offers Attempted", + description: "You've attempted all available surveys.", + }, + [OfferwallReason.LowCurrentSupply]: { + label: "Limited Availability", + description: "There are currently limited opportunities available.", + }, + }; + + function formatReasons( + reasons: OfferwallReason[], + ): { label: string; description: string }[] { + return reasons.map((r) => REASON_DISPLAY[r]); + } + + return ( + <> + <div + className=" border-8 border-emerald-400 rounded py-0 my-2 text-center text-3xl - hover:cursor-pointer hover:bg-green-500 hover:text-white"> - <p className="py-2 text-center text-3xl font-semibold"> - No Surveys available… - </p> - </div> - - {formatReasons(offerwall_reasons).map(r => { - return ( - <div className=" + hover:cursor-pointer hover:bg-green-500 hover:text-white" + > + <p className="py-2 text-center text-3xl font-semibold"> + No Surveys available… + </p> + </div> + + {formatReasons(offerwall_reasons).map((r) => { + return ( + <div + className=" border-6 border-rose-200 rounded py-0 my-2 text-center - hover:cursor-pointer hover:bg-rose-200 hover:text-rose-950 "> - <p className="py-2 text-center text-md font-semibold text-rose-600"> - {r.description} - </p> - </div> - ) - })} - </> - ) -} + hover:cursor-pointer hover:bg-rose-200 hover:text-rose-950 " + > + <p className="py-2 text-center text-md font-semibold text-rose-600"> + {r.description} + </p> + </div> + ); + })} + </> + ); +}; const BucketDetails = () => { - const bucket = useAppSelector(state => selectBucket(state)) - if (!bucket) return null; - const [showDetails, setShowDetails] = useState(false); - - const columnHelper = createColumnHelper<BucketTask>() - const columns = [ - columnHelper.display({ - id: 'index', - header: '#', - cell: ({ row }) => row.index + 1, - size: 25, - meta: { - align: 'center' - } - }), - columnHelper.accessor('source', { - header: () => 'Marketplace', - cell: (props) => { - const s = props.getValue() - return formatSource(s) - }, - size: 85, - meta: { - align: 'left' - } - }), - columnHelper.accessor('id', { - header: () => 'Survey ID', - cell: props => { - return truncate(props.getValue(), 6, "*****").toUpperCase() - }, - size: 70, - meta: { - align: 'left' - } - }), - columnHelper.accessor('payout', { - header: () => 'Amount', - cell: (props) => { - const val = props.renderValue() as number; - return formatCentsToUSD(val) - }, - size: 50, - meta: { - align: 'center' - } - }), - columnHelper.accessor('loi', { - header: () => 'Duration', - cell: (props) => { - const val = props.renderValue() as number; - return `${Math.round(val / 60)} min` - }, - size: 50, - meta: { - align: 'center' - } - }), - ] - - const table = useReactTable({ - 'data': bucket.contents, - 'columns': columns, - getCoreRowModel: getCoreRowModel(), - }); - - return ( - <> - <div className="w-full text-center text-[8px] text-emerald-600 + const bucket = useAppSelector((state) => selectBucket(state)); + if (!bucket) return null; + const [showDetails, setShowDetails] = useState(false); + + const columnHelper = createColumnHelper<BucketTask>(); + const columns = [ + columnHelper.display({ + id: "index", + header: "#", + cell: ({ row }) => row.index + 1, + size: 25, + meta: { + align: "center", + }, + }), + columnHelper.accessor("source", { + header: () => "Marketplace", + cell: (props) => { + const s = props.getValue(); + return formatSource(s); + }, + size: 85, + meta: { + align: "left", + }, + }), + columnHelper.accessor("id", { + header: () => "Survey ID", + cell: (props) => { + return truncate(props.getValue(), 6, "*****").toUpperCase(); + }, + size: 70, + meta: { + align: "left", + }, + }), + columnHelper.accessor("payout", { + header: () => "Amount", + cell: (props) => { + const val = props.renderValue() as number; + return formatCentsToUSD(val); + }, + size: 50, + meta: { + align: "center", + }, + }), + columnHelper.accessor("loi", { + header: () => "Duration", + cell: (props) => { + const val = props.renderValue() as number; + return `${Math.round(val / 60)} min`; + }, + size: 50, + meta: { + align: "center", + }, + }), + ]; + + const table = useReactTable({ + data: bucket.contents, + columns: columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + <> + <div + className="w-full text-center text-[8px] text-emerald-600 rounded py-2 my-1 hover:cursor-pointer hover:text-emerald-700 hover:underline hover:bg-emerald-200 " - onClick={() => setShowDetails(!showDetails)} - > - ({showDetails ? 'Hide' : 'Show'} details of surveys in this bucket) - </div> - - {showDetails && ( - <div className="w-full border-t-1 border-emerald-200 py-1 my-0 flex justify-center"> - <table className="text-[10px] text-emerald-900 table-fixed border-collapse"> - <thead className="font-bold"> - {table.getHeaderGroups().map((headerGroup) => ( - - <tr key={headerGroup.id}> - {headerGroup.headers.map((header) => { - const align = header.column.columnDef.meta?.align || 'left'; - const alignClass = align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left'; - - return ( - <th - key={header.id} - className={`p-0 m-0 ${alignClass}`} - style={{ - width: `${header.column.getSize()}px`, - }} - > - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - </th> - ) - })} - </tr> - ))} - </thead> - <tbody> - {table.getRowModel().rows.map((row) => ( - <tr key={row.id}> - {row.getVisibleCells().map((cell) => { - const align = cell.column.columnDef.meta?.align || 'left'; - const alignClass = align === 'center' ? 'text-center' : - align === 'right' ? 'text-right' : 'text-left'; - - return ( - <td key={cell.id} - className={`p-0 ${alignClass}`} - style={{ - width: `${cell.column.getSize()}px`, - }} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - </td> - ) - })} - </tr> - ))} - </tbody> - - </table> - </div> - )} - </> - ) + onClick={() => setShowDetails(!showDetails)} + > + ({showDetails ? "Hide" : "Show"} details of surveys in this bucket) + </div> + + {showDetails && ( + <div className="w-full border-t-1 border-emerald-200 py-1 my-0 flex justify-center"> + <table className="text-[10px] text-emerald-900 table-fixed border-collapse"> + <thead className="font-bold"> + {table.getHeaderGroups().map((headerGroup) => ( + <tr key={headerGroup.id}> + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align || "left"; + const alignClass = + align === "center" + ? "text-center" + : align === "right" + ? "text-right" + : "text-left"; + + return ( + <th + key={header.id} + className={`p-0 m-0 ${alignClass}`} + style={{ + width: `${header.column.getSize()}px`, + }} + > + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </th> + ); + })} + </tr> + ))} + </thead> + <tbody> + {table.getRowModel().rows.map((row) => ( + <tr key={row.id}> + {row.getVisibleCells().map((cell) => { + const align = cell.column.columnDef.meta?.align || "left"; + const alignClass = + align === "center" + ? "text-center" + : align === "right" + ? "text-right" + : "text-left"; + + return ( + <td + key={cell.id} + className={`p-0 ${alignClass}`} + style={{ + width: `${cell.column.getSize()}px`, + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + </td> + ); + })} + </tr> + ))} + </tbody> + </table> + </div> + )} + </> + ); }; const SurveyEnter = () => { - const dispatch = useAppDispatch() - const loi = useAppSelector(state => state.app.loi) - const bpuid = useAppSelector(state => state.app.bpuid) - - const available_cnt = useAppSelector(state => getAvailabilityCount(state)) - const buckets = useAppSelector(state => state.app.currentBuckets) - const bucket = useAppSelector(state => selectBucket(state)) - const isLow = useAppSelector(state => isLowBalance(state)) - - useEffect(() => { - if (!bpuid) return; - - new OfferwallApi().topNPlusBlockOfferwallProductIdOfferwallD48cce47Get( - bpid, // productId - bpuid, // bpuid - undefined, // ip - undefined, // countryIso - undefined, // languages - undefined, // behavior - undefined, // minPayout - loi, //duration - 1 // nBins - ).then(res => { - const top_n_res = res.data.offerwall as TopNPlusBlockOfferWall; - dispatch(setAvailabilityCount(top_n_res.availability_count)) - if (top_n_res.attempted_live_eligible_count) { - dispatch(setAttemptedLiveEligibleCount(top_n_res.attempted_live_eligible_count)) - } - dispatch(setCurrentBuckets(top_n_res.buckets)) - dispatch(setOfferwallReasons(top_n_res.offerwall_reasons ?? [])) - }); - }, [loi, bpuid]); - - - const start_survey = () => { - if (!bucket?.uri) return; - - dispatch(setEnteredTimestamp()); - window.open(bucket.uri, '_blank'); - } - - if (buckets === undefined) { - return <SurveyLoading /> - } - - if (available_cnt === 0 || buckets.length === 0 || !bucket) { - return <NoSurveys /> - } - - return ( - <> - {isLow && ( - <div className=" + const dispatch = useAppDispatch(); + const loi = useAppSelector((state) => state.app.loi); + const bpuid = useAppSelector((state) => state.app.bpuid); + + const available_cnt = useAppSelector((state) => getAvailabilityCount(state)); + const buckets = useAppSelector((state) => state.app.currentBuckets); + const bucket = useAppSelector((state) => selectBucket(state)); + const isLow = useAppSelector((state) => isLowBalance(state)); + + useEffect(() => { + if (!bpuid) return; + + new OfferwallApi() + .topNPlusBlockOfferwallProductIdOfferwallD48cce47Get( + bpid, // productId + bpuid, // bpuid + undefined, // ip + undefined, // countryIso + undefined, // languages + undefined, // behavior + undefined, // minPayout + loi, //duration + 1, // nBins + ) + .then((res) => { + const top_n_res = res.data.offerwall as TopNPlusBlockOfferWall; + dispatch(setAvailabilityCount(top_n_res.availability_count)); + if (top_n_res.attempted_live_eligible_count) { + dispatch( + setAttemptedLiveEligibleCount( + top_n_res.attempted_live_eligible_count, + ), + ); + } + dispatch(setCurrentBuckets(top_n_res.buckets)); + dispatch(setOfferwallReasons(top_n_res.offerwall_reasons ?? [])); + }); + }, [loi, bpuid]); + + const start_survey = () => { + if (!bucket?.uri) return; + + dispatch(setEnteredTimestamp()); + window.open(bucket.uri, "_blank"); + }; + + if (buckets === undefined) { + return <SurveyLoading />; + } + + if (available_cnt === 0 || buckets.length === 0 || !bucket) { + return <NoSurveys />; + } + + return ( + <> + {isLow && ( + <div + className=" border-8 border-rose-500 rounded py-2 - bg-rose-400 text-rose-700"> - <h2 className="text-center text-xl font-bold text-white"> - Your balance is low. - </h2> - <p className="px-4 py-2 text-center text-sm font-medium text-white"> - You may continue to attempt surveys, but you will be - advised to return the HIT if your survey attempt does - not result in a complete. - </p> - <p className="px-4 py-2 text-center text-sm font-bold text-white"> - HIT submissions with a balance exceeding -$1.00 will - return in rejected assignments. - </p> - </div> - )} - - <LOISelect /> - <div - onClick={start_survey} - className=" + bg-rose-400 text-rose-700" + > + <h2 className="text-center text-xl font-bold text-white"> + Your balance is low. + </h2> + <p className="px-4 py-2 text-center text-sm font-medium text-white"> + You may continue to attempt surveys, but you will be advised to + return the HIT if your survey attempt does not result in a complete. + </p> + <p className="px-4 py-2 text-center text-sm font-bold text-white"> + HIT submissions with a balance exceeding -$1.00 will return in + rejected assignments. + </p> + </div> + )} + + <LOISelect /> + <div + onClick={start_survey} + className=" border-8 border-emerald-400 rounded py-0 my-2 - hover:cursor-pointer hover:bg-emerald-400 hover:text-emerald-50"> - <p className="py-2 text-center text-3xl font-semibold"> - <a - target="_blank" - rel="noopener noreferrer"> - Start Task - </a> - </p> - </div> - <BucketDetails /> - - <div className="border-2 border-emerald-200 rounded py-2 m-0"> - <p className="text-emerald-700 text-sm text-center font-medium px-2 m-0"> - <Markdown> - {bucket.eligibility_explanation} - </Markdown> - </p> - </div> - </> - ) + hover:cursor-pointer hover:bg-emerald-400 hover:text-emerald-50" + > + <p className="py-2 text-center text-3xl font-semibold"> + <a target="_blank" rel="noopener noreferrer"> + Start Task + </a> + </p> + </div> + <BucketDetails /> + + <div className="border-2 border-emerald-200 rounded py-2 m-0"> + <p className="text-emerald-700 text-sm text-center font-medium px-2 m-0"> + <Markdown>{bucket.eligibility_explanation}</Markdown> + </p> + </div> + </> + ); }; - const Work = () => { - const entered = useAppSelector(state => state.app.currentBucketEntered) - const assignment_id = useAppSelector(state => state.app.assignment_id) - - if (!assignment_id) { - return ( - <p className="text-center text-2xl py-3 text-red-800 font-semibold"> - No Assignment ID found. Please access this page through - the Amazon Mechanical Turk platform. - </p> - ) - } + const entered = useAppSelector((state) => state.app.currentBucketEntered); + const assignment_id = useAppSelector((state) => state.app.assignment_id); + if (!assignment_id) { return ( - <> - {entered ? ( - <SurveyMonitor /> - ) : ( - <SurveyEnter /> - )} - </> - ) + <p className="text-center text-2xl py-3 text-red-800 font-semibold"> + No Assignment ID found. Please access this page through the Amazon + Mechanical Turk platform. + </p> + ); + } + + return <>{entered ? <SurveyMonitor /> : <SurveyEnter />}</>; }; - -export default Work;
\ No newline at end of file +export default Work; diff --git a/jb-ui/src/store.ts b/jb-ui/src/store.ts index 7b4d3e3..07cac9e 100644 --- a/jb-ui/src/store.ts +++ b/jb-ui/src/store.ts @@ -1,32 +1,31 @@ -import appReducers from "@/models/appSlice" -import grlStatsReducers from "@/models/grlStatsSlice" -import grlEventsReducers from "@/models/grlEventsSlice" -import profilingQuestionsReducers from "@/models/profilingQuestionsSlice" -import type { Action, ThunkAction } from '@reduxjs/toolkit' -import { configureStore } from '@reduxjs/toolkit' - +import appReducers from "@/models/appSlice"; +import grlStatsReducers from "@/models/grlStatsSlice"; +import grlEventsReducers from "@/models/grlEventsSlice"; +import profilingQuestionsReducers from "@/models/profilingQuestionsSlice"; +import type { Action, ThunkAction } from "@reduxjs/toolkit"; +import { configureStore } from "@reduxjs/toolkit"; export const store = configureStore({ - reducer: { - app: appReducers, - stats: grlStatsReducers, - events: grlEventsReducers, - profilingQuestions: profilingQuestionsReducers, - } -}) + reducer: { + app: appReducers, + stats: grlStatsReducers, + events: grlEventsReducers, + profilingQuestions: profilingQuestionsReducers, + }, +}); // Infer the type of `store` -export type AppStore = typeof store +export type AppStore = typeof store; // Infer the `AppDispatch` type from the store itself -export type AppDispatch = typeof store.dispatch +export type AppDispatch = typeof store.dispatch; // Same for the `RootState` type -export type RootState = ReturnType<typeof store.getState> +export type RootState = ReturnType<typeof store.getState>; // Define a reusable type describing thunk functions export type AppThunk<ThunkReturnType = void> = ThunkAction< - ThunkReturnType, - RootState, - unknown, - Action -> + ThunkReturnType, + RootState, + unknown, + Action +>; diff --git a/jb-ui/src/vite-env.d.ts b/jb-ui/src/vite-env.d.ts index 11d7a7a..93dc3cc 100644 --- a/jb-ui/src/vite-env.d.ts +++ b/jb-ui/src/vite-env.d.ts @@ -1,17 +1,17 @@ /// <reference types="vite/client" /> interface ViteTypeOptions { - // By adding this line, you can make the type of ImportMetaEnv strict - // to disallow unknown keys. - // strictImportMetaEnv: unknown + // By adding this line, you can make the type of ImportMetaEnv strict + // to disallow unknown keys. + // strictImportMetaEnv: unknown } interface ImportMetaEnv { - readonly VITE_BASENAME: string - readonly VITE_BPID: string - readonly VITE_TAG_MANAGER: string + readonly VITE_BASENAME: string; + readonly VITE_BPID: string; + readonly VITE_TAG_MANAGER: string; } interface ImportMeta { - readonly env: ImportMetaEnv -}
\ No newline at end of file + readonly env: ImportMetaEnv; +} |
