aboutsummaryrefslogtreecommitdiff
path: root/jb-ui/src
diff options
context:
space:
mode:
Diffstat (limited to 'jb-ui/src')
-rw-r--r--jb-ui/src/JBApp.tsx343
-rw-r--r--jb-ui/src/components/EventMarquee.tsx428
-rw-r--r--jb-ui/src/components/Footer.tsx76
-rw-r--r--jb-ui/src/components/Profiling.tsx1297
-rw-r--r--jb-ui/src/components/Wallet.tsx859
-rw-r--r--jb-ui/src/components/ui/button.tsx20
-rw-r--r--jb-ui/src/components/ui/checkbox.tsx14
-rw-r--r--jb-ui/src/components/ui/confetti.tsx98
-rw-r--r--jb-ui/src/components/ui/dot-pattern.tsx60
-rw-r--r--jb-ui/src/components/ui/field.tsx72
-rw-r--r--jb-ui/src/components/ui/label.tsx12
-rw-r--r--jb-ui/src/components/ui/magic-card.tsx70
-rw-r--r--jb-ui/src/components/ui/pagination.tsx30
-rw-r--r--jb-ui/src/components/ui/separator.tsx14
-rw-r--r--jb-ui/src/components/ui/skeleton.tsx6
-rw-r--r--jb-ui/src/hooks.ts10
-rw-r--r--jb-ui/src/index.css1
-rw-r--r--jb-ui/src/lib/utils.ts164
-rw-r--r--jb-ui/src/main.tsx24
-rw-r--r--jb-ui/src/models/app.ts58
-rw-r--r--jb-ui/src/models/appSlice.ts323
-rw-r--r--jb-ui/src/models/grlEventsSlice.ts74
-rw-r--r--jb-ui/src/models/grlStatsSlice.ts67
-rw-r--r--jb-ui/src/models/profilingQuestionsSlice.ts672
-rw-r--r--jb-ui/src/models/profilingUtils.ts309
-rw-r--r--jb-ui/src/pages/Home.tsx12
-rw-r--r--jb-ui/src/pages/LinkAmtAccount.tsx131
-rw-r--r--jb-ui/src/pages/MagicLink.tsx72
-rw-r--r--jb-ui/src/pages/Preview.tsx523
-rw-r--r--jb-ui/src/pages/Result.tsx206
-rw-r--r--jb-ui/src/pages/Transfer.tsx50
-rw-r--r--jb-ui/src/pages/Work.tsx1452
-rw-r--r--jb-ui/src/store.ts43
-rw-r--r--jb-ui/src/vite-env.d.ts16
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;
+}