summaryrefslogtreecommitdiff
path: root/jb-ui/src/pages
diff options
context:
space:
mode:
authorMax Nanis2026-02-18 20:42:03 -0500
committerMax Nanis2026-02-18 20:42:03 -0500
commit3eaa56f0306ead818f64c3d99fc6d230d9b970a4 (patch)
tree9fecc2f1456e6321572e0e65f57106916df173e2 /jb-ui/src/pages
downloadamt-jb-3eaa56f0306ead818f64c3d99fc6d230d9b970a4.tar.gz
amt-jb-3eaa56f0306ead818f64c3d99fc6d230d9b970a4.zip
HERE WE GO, HERE WE GO, HERE WE GO
Diffstat (limited to 'jb-ui/src/pages')
-rw-r--r--jb-ui/src/pages/Home.tsx13
-rw-r--r--jb-ui/src/pages/Preview.tsx288
-rw-r--r--jb-ui/src/pages/Result.tsx123
-rw-r--r--jb-ui/src/pages/Work.tsx765
-rw-r--r--jb-ui/src/pages/max-and-lulu.jpgbin0 -> 774803 bytes
5 files changed, 1189 insertions, 0 deletions
diff --git a/jb-ui/src/pages/Home.tsx b/jb-ui/src/pages/Home.tsx
new file mode 100644
index 0000000..5a5c953
--- /dev/null
+++ b/jb-ui/src/pages/Home.tsx
@@ -0,0 +1,13 @@
+import max_lulu from "./max-and-lulu.jpg"
+
+const Home = function () {
+ return (
+ <img
+ src={max_lulu}
+ alt="Max and Lulu"
+ className="object-contain"
+ />
+ )
+};
+
+export default Home; \ No newline at end of file
diff --git a/jb-ui/src/pages/Preview.tsx b/jb-ui/src/pages/Preview.tsx
new file mode 100644
index 0000000..9506664
--- /dev/null
+++ b/jb-ui/src/pages/Preview.tsx
@@ -0,0 +1,288 @@
+import {
+ Leaderboard,
+ LeaderboardApi,
+ LeaderboardCode,
+ LeaderboardFrequency,
+ LeaderboardRow,
+} from "@/api_fsb";
+import { useAppSelector } from "@/hooks";
+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';
+
+const showSmartRank = (
+ 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;
+ }
+ }
+ return true;
+};
+
+interface FrequencyButtonsProps {
+ selected: LeaderboardFrequency;
+ onChange: (frequency: LeaderboardFrequency) => void;
+}
+
+
+const ThreeButtonToggle = ({ selected, onChange }: FrequencyButtonsProps) => {
+ const buttons = [
+ { label: 'Day', value: LeaderboardFrequency.Daily },
+ { label: 'Week', value: LeaderboardFrequency.Weekly },
+ { label: 'Month', value: LeaderboardFrequency.Monthly },
+ ];
+
+ // rounded-l
+
+ return (
+ <div className="inline-flex rounded shadow-sm" role="group">
+ {buttons.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 === buttons.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
+ );
+
+ 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}`;
+ }
+
+ 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 week.
+ </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="
+ 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 week.
+ </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="
+ 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>
+ );
+};
+
+
+
+// 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>
+ );
+};
+export default Preview; \ No newline at end of file
diff --git a/jb-ui/src/pages/Result.tsx b/jb-ui/src/pages/Result.tsx
new file mode 100644
index 0000000..47af248
--- /dev/null
+++ b/jb-ui/src/pages/Result.tsx
@@ -0,0 +1,123 @@
+import { useAppSelector } from "@/hooks";
+import confetti from "canvas-confetti";
+import { useEffect } from "react";
+
+// getOutcomeStr: function () {
+// return {
+// 0: "UNKNOWN", 1: "ENTER",
+// 2: "INCOMPLETE", 3: "COMPLETE"
+// }[this.get('status')] || '';
+// }
+
+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>
+ </>
+ )
+}
+
+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>
+ </>
+ )
+}
+
+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>
+ </>
+
+ )
+}
+
+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>
+ </>
+ )
+}
+
+export const Loading = () => {
+
+ 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()
+ }
+
+ 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 />;
+ }
+
+};
+
+
+export default Result; \ No newline at end of file
diff --git a/jb-ui/src/pages/Work.tsx b/jb-ui/src/pages/Work.tsx
new file mode 100644
index 0000000..c54732f
--- /dev/null
+++ b/jb-ui/src/pages/Work.tsx
@@ -0,0 +1,765 @@
+import {
+ 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,
+} 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
+} from "@/models/appSlice";
+import {
+ 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';
+
+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 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
+ 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="
+ 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="
+ 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>
+ )
+}
+
+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 formRef = useRef<HTMLFormElement>(null);
+
+ 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]);
+
+ 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 [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>
+ )
+ }
+
+ // 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>
+ </>
+ )
+ }
+
+ // 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="
+ 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="
+ 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="
+ 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="
+ 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>
+ )}
+ </>
+
+ )}
+
+
+ </>
+ )
+}
+
+
+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>
+ )
+
+};
+
+const SurveyLoading = () => {
+ 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>
+ )
+}
+
+
+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]);
+ }
+
+ 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="
+ 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>
+ )
+ })}
+ </>
+ )
+}
+
+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
+ 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>
+ )}
+ </>
+ )
+};
+
+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="
+ 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="
+ 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>
+ </>
+ )
+};
+
+
+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>
+ )
+ }
+
+ return (
+ <>
+ {entered ? (
+ <SurveyMonitor />
+ ) : (
+ <SurveyEnter />
+ )}
+ </>
+ )
+};
+
+
+export default Work; \ No newline at end of file
diff --git a/jb-ui/src/pages/max-and-lulu.jpg b/jb-ui/src/pages/max-and-lulu.jpg
new file mode 100644
index 0000000..e7e0b7d
--- /dev/null
+++ b/jb-ui/src/pages/max-and-lulu.jpg
Binary files differ