diff options
Diffstat (limited to 'jb-ui/src/pages')
| -rw-r--r-- | jb-ui/src/pages/Home.tsx | 12 | ||||
| -rw-r--r-- | jb-ui/src/pages/MagicLink.tsx | 46 | ||||
| -rw-r--r-- | jb-ui/src/pages/Preview.tsx | 609 | ||||
| -rw-r--r-- | jb-ui/src/pages/Result.tsx | 198 | ||||
| -rw-r--r-- | jb-ui/src/pages/Transfer.tsx | 162 | ||||
| -rw-r--r-- | jb-ui/src/pages/Work.tsx | 1435 |
6 files changed, 1413 insertions, 1049 deletions
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/MagicLink.tsx b/jb-ui/src/pages/MagicLink.tsx new file mode 100644 index 0000000..eb272a6 --- /dev/null +++ b/jb-ui/src/pages/MagicLink.tsx @@ -0,0 +1,46 @@ +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>; + } + 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..c58a85e 100644 --- a/jb-ui/src/pages/Preview.tsx +++ b/jb-ui/src/pages/Preview.tsx @@ -1,287 +1,394 @@ 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 { FormEvent, 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 = undefined; + + useEffect(() => { + new LeaderboardApi() + .timespanLeaderboardProductIdLeaderboardTimespanBoardCodeGet( + bpid, + LeaderboardCode.CompleteCount, + frequency, + "us", + 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> + 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> + ); +}; + +type RequestState = "idle" | "working" | "failed"; + +interface LoginFormProps { + onSubmitSuccess: () => void; +} + +const LoginForm = function ({ onSubmitSuccess }: LoginFormProps) { + const [email, setEmail] = useState(""); + const [state, setState] = useState<RequestState>("idle"); + + // const dispatch = useAppDispatch(); + const submit = async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + setState("working"); + + try { + const response = await fetch("/auth/magic-link/request", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email, + }), + }); + + if (!response.ok) throw new Error("Account-link request failed"); + const body = (await response.json()) as { magic_link?: string }; + console.log(body.magic_link ?? null); + onSubmitSuccess(); + } catch { + setState("failed"); + } + }; + + return ( + <> + <form className="w-full" onSubmit={submit}> + <div className="flex items-center border-b-4 border-blue-700 py-2"> + <input + className="appearance-none bg-transparent border-none w-full text-gray-700 mr-3 py-1 px-2 leading-tight focus:outline-none font-semibold" + type="email" + autoComplete="email" + required + value={email} + onChange={(event) => setEmail(event.target.value)} + placeholder="hello@jamesbillings67.com" + aria-label="Email address" + /> + + <button + className="flex-shrink-0 bg-blue-700 hover:bg-blue-800 text-white py-1 px-2 rounded cursor-pointer font-semibold" + type="submit" + disabled={state === "working"} + > + {state === "working" ? "Sending…" : "Login / Signup"} + </button> </div> - ); + </form> + + {state === "failed" && <p>Unable to send the link. Please try again.</p>} + </> + ); }; +const LoginSent = function () { + return ( + <div className="my-1"> + <h2 + className=" + bg-emerald-400 rounded py-2 + text-white text-l font-semibold text-center" + > + Please check your email. + </h2> + <p className="text-slate-800 text-xs font-semibold text-center my-1 py-2"> + If you don't recieve anything, please check your spam folder, or try + again shortly. + </p> + </div> + ); +}; +const Login = () => { + const [submitted, setSubmitted] = useState(false); + + return ( + <> + {submitted ? ( + <LoginSent /> + ) : ( + <LoginForm onSubmitSuccess={() => setSubmitted(true)} /> + )} + </> + ); +}; // 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> + /** + * + * @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 ( + <> + <Login /> + + <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. 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> + </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..6385bc7 100644 --- a/jb-ui/src/pages/Result.tsx +++ b/jb-ui/src/pages/Result.tsx @@ -10,114 +10,116 @@ 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</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</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">Attempt 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</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</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..8079da2 --- /dev/null +++ b/jb-ui/src/pages/Transfer.tsx @@ -0,0 +1,162 @@ +import { type FormEvent, useState } from "react"; +import { useAppSelector } from "@/hooks"; +import { useGetMeQuery } from "@/models/authAPI"; +import { Button } from "@/components/ui/button"; +import max_lulu from "./max-and-lulu.jpg"; + +// const handleInputChange = ( +// event: React.KeyboardEvent<HTMLInputElement>, +// ) => {}; + +// const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>) => {}; +// const handleSubmit = (e) => { +// e.preventDefault(); +// console.log("Form submitted successfully!"); +// }; + +const TransferComplete = function () { + return ( + <div className="px-6 my-6"> + <h2 + className=" + bg-emerald-400 rounded py-2 + text-white text-2xl font-semibold text-center" + > + Please check your email. + </h2> + <p className="text-slate-600 text-l font-semibold text-center py-5 px-6"> + If you don't recieve anything, please check your spam folder, or try + again shortly. + </p> + <p className="bg-slate-200 text-xs font-semibold text-center p-3 my-3"> + We will be assisting account transfers throughout the rest of the month. + If you have any further questions or concerns, please email us directly + with your desired account email and the AMT ID to associate with your + worker history. + </p> + <img src={max_lulu} alt="Max and Lulu" className="object-contain" /> + </div> + ); +}; + +type RequestState = "idle" | "working" | "sent" | "failed"; + +interface TransferSubmitProps { + onSubmitSuccess: () => void; +} + +const TransferSubmit = function ({ onSubmitSuccess }: TransferSubmitProps) { + const [email, setEmail] = useState(""); + const [state, setState] = useState<RequestState>("idle"); + const { data: user } = useGetMeQuery(); + + const submit = async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + setState("working"); + + 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: user?.product_user_id, + }), + }); + + if (!response.ok) throw new Error("Account-link request failed"); + + const body = (await response.json()) as { magic_link?: string }; + console.log(body.magic_link ?? null); + onSubmitSuccess(); + } catch { + setState("failed"); + } + }; + + return ( + <div className="px-6 my-6"> + <h2 + className=" + bg-red-700 rounded py-2 + text-white text-2xl font-semibold text-center" + > + Transfer Required + </h2> + <h4 + className=" + mt-2 + bg-slate-500 rounded p-2 + text-white text-sm font-semibold text-center" + > + James Billings is no longer available on Amazon Mechanical Turk. Our + dedicated platform will enable you to do the same work as always, paid + directly to you. + <br /> + Enter your email below to transfer your account history. + <br /> + This will allow you to keep your full account history and wallet + balance. + </h4> + + <form onSubmit={submit}> + <input + className="w-full rounded-md border-3 border-slate-700 px-3 py-2 font-semibold my-5" + type="email" + autoComplete="email" + required + value={email} + onChange={(event) => setEmail(event.target.value)} + placeholder="you@example.com" + /> + <Button + className="w-1/2 font-semibold bg-slate-600 cursor-pointer hover:bg-blue-950" + type="submit" + disabled={state === "working"} + > + {state === "working" ? "Sending…" : "Send link"} + </Button> + </form> + + {state === "sent" && ( + <h4 + className=" + mt-2 + bg-slate-500 rounded py-2 + text-white text-l font-semibold text-center" + > + Check your email to finish linking your account. + </h4> + )} + + {state === "failed" && <p>Unable to send the link. Please try again.</p>} + </div> + ); +}; + +const Transfer = function () { + const bpuid = useAppSelector((state) => state.app.bpuid); + const [submitted, setSubmitted] = useState(false); + + if (!bpuid) { + return ( + <p className="text-center text-2xl py-3 text-red-800 font-semibold"> + No Worker ID found. Please access this page through the Amazon + Mechanical Turk platform. + </p> + ); + } + + return ( + <> + {submitted ? ( + <TransferComplete /> + ) : ( + <TransferSubmit onSubmitSuccess={() => setSubmitted(true)} /> + )} + </> + ); +}; + +export default Transfer; diff --git a/jb-ui/src/pages/Work.tsx b/jb-ui/src/pages/Work.tsx index c54732f..6d0b1c1 100644 --- a/jb-ui/src/pages/Work.tsx +++ b/jb-ui/src/pages/Work.tsx @@ -1,765 +1,818 @@ +import { useGetMeQuery } from "@/models/authAPI"; +import { Link } from "react-router-dom"; 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 Preview from "@/pages/Preview"; 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 { useEffect, 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 [, setShowComplete] = useState(false); + const [selectedValues, setSelectedValues] = useState<ReportValue[]>([]); + const { data: user } = useGetMeQuery(); + 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 (!user?.product_user_id) return; + // ReportValue.NUMBER_0 + console.log("selectedValues:", selectedValues); + + const rt: ReportTask = { + bpuid: user.product_user_id, + 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>; + } + + // TODO! What do we do here, redirect back to / with a thank you + wallet refresh? + // 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 Attempt + </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 SurveyMonitor = () => { + const dispatch = useAppDispatch(); - const formRef = useRef<HTMLFormElement>(null); + const [isPolling, setIsPolling] = useState(true); + // const [timeAgo, setTimeAgo] = useState(""); - useEffect(() => { - if (!ts) return; - if (!bpuid) return; - if (!assignment_id) return; - if (!turkSubmitTo) return; + const [, setShowComplete] = useState(false); + const [showReport, setShowReport] = useState(false); - if (formRef.current) { - formRef.current.submit(); - } - }, [ts?.tsid, bpuid, assignment_id, turkSubmitTo]); + const { data: user } = useGetMeQuery(); + const entered = useAppSelector((state) => state.app.currentBucketEntered); + const isLow = useAppSelector((state) => isLowBalance(state)); - 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 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 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> - </> + // 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 (!user?.product_user_id) return; + if (!entered) return; + if (!isPolling) return; + + const pollApi = async () => { + new StatusApi() + .listTaskStatusesProductIdStatusGet( + bpid, // productId + user.product_user_id, // 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); + } + }); - // 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 + // 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=" + <> + <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 Attempt. + // + // 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 attempt. + </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" + > + <Link to="/" reloadDocument> + Click to Refresh and Try Again + </Link> + </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)} + > + Finish Attempt + </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> + {/* TODO! What do we do here, redirect back to / with wallet */} + {/* TODO! refresh? The confetti is on a different page. */} + {/* {showComplete && <SubimtAMT />} */} + {showReport && <Report onClose={() => setShowReport(false)} />} + </> + ) : ( + <div> + <p + className=" + text-red-600 text-lg font-semibold text-center" + > + Your balance is low. + </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.", - }, - }; + const offerwall_reasons = useAppSelector( + (state) => state.app.offerwall_reasons, + ); - function formatReasons(reasons: OfferwallReason[]): { label: string; description: string }[] { - return reasons.map((r) => REASON_DISPLAY[r]); - } - - 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 { data: user } = useGetMeQuery(); + + 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 (!user?.product_user_id) return; + + new OfferwallApi() + .topNPlusBlockOfferwallProductIdOfferwallD48cce47Get( + bpid, // productId + // "7d9240132c695c05cd0ba47e2580158719f8215b", + // "71.225.51.26", + user.product_user_id, + 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, user?.product_user_id]); + + 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 not earn an + attempt credit 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"> + Attempts with a balance exceeding -$1.00 will not be issued an + attempt credit. + </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> - ) - } - - return ( - <> - {entered ? ( - <SurveyMonitor /> - ) : ( - <SurveyEnter /> - )} - </> - ) + 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 { data: user, isLoading, isError } = useGetMeQuery(); + // const location = useLocation(); + + if (isLoading) return <div>Loading...</div>; + + if (isError || !user) { + return <Preview />; + } + + return <>{entered ? <SurveyMonitor /> : <SurveyEnter />}</>; }; - -export default Work;
\ No newline at end of file +export default Work; |
