diff options
Diffstat (limited to 'jb-ui/src/pages')
| -rw-r--r-- | jb-ui/src/pages/Home.tsx | 13 | ||||
| -rw-r--r-- | jb-ui/src/pages/Preview.tsx | 288 | ||||
| -rw-r--r-- | jb-ui/src/pages/Result.tsx | 123 | ||||
| -rw-r--r-- | jb-ui/src/pages/Work.tsx | 765 | ||||
| -rw-r--r-- | jb-ui/src/pages/max-and-lulu.jpg | bin | 0 -> 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 Binary files differnew file mode 100644 index 0000000..e7e0b7d --- /dev/null +++ b/jb-ui/src/pages/max-and-lulu.jpg |
