From ed201936813d9a720b51e6cb40a05267b94e7314 Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Thu, 10 Sep 2026 17:46:41 -0700 Subject: Typescript prettier Committing seperate so it's easier to see actual changes. --- jb-ui/src/models/app.ts | 58 +-- jb-ui/src/models/appSlice.ts | 323 ++++++------- jb-ui/src/models/grlEventsSlice.ts | 74 ++- jb-ui/src/models/grlStatsSlice.ts | 67 ++- jb-ui/src/models/profilingQuestionsSlice.ts | 672 +++++++++++++++------------- jb-ui/src/models/profilingUtils.ts | 309 +++++++------ 6 files changed, 771 insertions(+), 732 deletions(-) (limited to 'jb-ui/src/models') diff --git a/jb-ui/src/models/app.ts b/jb-ui/src/models/app.ts index 95b4c11..4c3e69e 100644 --- a/jb-ui/src/models/app.ts +++ b/jb-ui/src/models/app.ts @@ -1,37 +1,39 @@ import { - UserWalletBalance, TopNPlusBucket, TaskStatusResponse, - UserLedgerTransactionTypesSummary, - UserLedgerTransactionsResponseTransactionsInner, - OfferwallReason + UserWalletBalance, + TopNPlusBucket, + TaskStatusResponse, + UserLedgerTransactionTypesSummary, + UserLedgerTransactionsResponseTransactionsInner, + OfferwallReason, } from "@/api_fsb"; -import { PaginationState } from '@tanstack/react-table'; +import { PaginationState } from "@tanstack/react-table"; export interface App { - // Global IDs needed by GRL + MTurk - bpuid?: string; - assignment_id?: string; - turkSubmitTo?: string; + // Global IDs needed by GRL + MTurk + bpuid?: string; + assignment_id?: string; + turkSubmitTo?: string; - // Global UI settings - loi: number; - availability_count?: number; - attempted_live_eligible_count?: number; - offerwall_reasons: OfferwallReason[]; + // Global UI settings + loi: number; + availability_count?: number; + attempted_live_eligible_count?: number; + offerwall_reasons: OfferwallReason[]; - // The timestamp of when the currently requested bucket was requested. We want - // to save this to prevent it from sitting for longer than 120 seconds. - currentBucketRequested?: number; // Date.now() is a timestamp in ms - currentBuckets?: TopNPlusBucket[]; - currentBucketEntered?: number; // Date.now() is a timestamp in ms - taskStatus?: TaskStatusResponse; + // The timestamp of when the currently requested bucket was requested. We want + // to save this to prevent it from sitting for longer than 120 seconds. + currentBucketRequested?: number; // Date.now() is a timestamp in ms + currentBuckets?: TopNPlusBucket[]; + currentBucketEntered?: number; // Date.now() is a timestamp in ms + taskStatus?: TaskStatusResponse; - // Wallet stuff - userWalletBalance?: UserWalletBalance; - userLedgerSummary?: UserLedgerTransactionTypesSummary; - userLedgerTxCount?: number; - userLedgerTxs: UserLedgerTransactionsResponseTransactionsInner[]; + // Wallet stuff + userWalletBalance?: UserWalletBalance; + userLedgerSummary?: UserLedgerTransactionTypesSummary; + userLedgerTxCount?: number; + userLedgerTxs: UserLedgerTransactionsResponseTransactionsInner[]; - txPagination: PaginationState; - txTotalItems?: number; - txTotalPages?: number; + txPagination: PaginationState; + txTotalItems?: number; + txTotalPages?: number; } diff --git a/jb-ui/src/models/appSlice.ts b/jb-ui/src/models/appSlice.ts index 87952d9..46c6a2f 100644 --- a/jb-ui/src/models/appSlice.ts +++ b/jb-ui/src/models/appSlice.ts @@ -1,188 +1,191 @@ import { - TaskStatusResponse, - TopNPlusBucket, - UserLedgerTransactionsResponseTransactionsInner, - UserLedgerTransactionTypesSummary, - UserWalletBalance, - OfferwallReason + TaskStatusResponse, + TopNPlusBucket, + UserLedgerTransactionsResponseTransactionsInner, + UserLedgerTransactionTypesSummary, + UserWalletBalance, + OfferwallReason, } from "@/api_fsb"; import { App } from "@/models/app"; import { RootState } from "@/store"; -import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { PaginationState } from '@tanstack/react-table'; +import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { PaginationState } from "@tanstack/react-table"; import moment from "moment"; const initialState: App = { - bpuid: undefined, - assignment_id: undefined, - loi: 1800, - currentBuckets: undefined, - currentBucketEntered: undefined, - taskStatus: undefined, - offerwall_reasons: [], - - userWalletBalance: undefined, - userLedgerSummary: undefined, - userLedgerTxCount: undefined, - userLedgerTxs: [], - - txPagination: { pageIndex: 0, pageSize: 10 }, - txTotalItems: undefined, - txTotalPages: undefined -} as App + bpuid: undefined, + assignment_id: undefined, + loi: 1800, + currentBuckets: undefined, + currentBucketEntered: undefined, + taskStatus: undefined, + offerwall_reasons: [], + + userWalletBalance: undefined, + userLedgerSummary: undefined, + userLedgerTxCount: undefined, + userLedgerTxs: [], + + txPagination: { pageIndex: 0, pageSize: 10 }, + txTotalItems: undefined, + txTotalPages: undefined, +} as App; const appSlice = createSlice({ - name: 'app', - initialState, - reducers: { - - setProductUserID(state, action: PayloadAction) { - state.bpuid = action.payload; - }, - - setAssignmentID(state, action: PayloadAction) { - // This is really so silly. Amazon should simply not send - // anything if it's unavailable. - if (action.payload === "ASSIGNMENT_ID_NOT_AVAILABLE") { - state.assignment_id = undefined; - } - state.assignment_id = action.payload; - }, - - setTurkSubmitTo(state, action: PayloadAction) { - state.turkSubmitTo = action.payload; - }, - - setLOI(state, action: PayloadAction) { - state.loi = action.payload; - }, - - setAvailabilityCount(state, action: PayloadAction) { - state.availability_count = action.payload - }, - - setAttemptedLiveEligibleCount(state, action: PayloadAction) { - state.attempted_live_eligible_count = action.payload - }, - - setCurrentBuckets(state, action: PayloadAction) { - state.currentBucketRequested = moment.utc().unix(); - state.currentBuckets = action.payload - }, - - setOfferwallReasons(state, action: PayloadAction) { - state.offerwall_reasons = action.payload; - }, - - setEnteredTimestamp(state) { - // Go back by 2 seconds to account for any time drift - state.currentBucketEntered = moment.utc().unix() - 2; - }, - - setTaskStatus(state, action: PayloadAction) { - state.taskStatus = action.payload; - - state.bpuid = action.payload.product_user_id; - }, - - setUserWalletBalance(state, action: PayloadAction) { - state.userWalletBalance = action.payload; - }, - - setUserLedgerSummary(state, action: PayloadAction) { - state.userLedgerSummary = action.payload; - }, - - setUserLedgerTxs(state, action: PayloadAction) { - // We're not appending the transaction details, it's only going - // to reassign the current page of Transactions. - state.userLedgerTxs = action.payload; - }, - - setTxPagination(state, action: PayloadAction) { - state.txPagination = action.payload; - }, - - setTxTotalItems(state, action: PayloadAction) { - state.txTotalItems = action.payload; - }, - - setTxTotalPages(state, action: PayloadAction) { - state.txTotalPages = action.payload; - } - - } -}) + name: "app", + initialState, + reducers: { + setProductUserID(state, action: PayloadAction) { + state.bpuid = action.payload; + }, + + setAssignmentID(state, action: PayloadAction) { + // This is really so silly. Amazon should simply not send + // anything if it's unavailable. + if (action.payload === "ASSIGNMENT_ID_NOT_AVAILABLE") { + state.assignment_id = undefined; + } + state.assignment_id = action.payload; + }, + + setTurkSubmitTo(state, action: PayloadAction) { + state.turkSubmitTo = action.payload; + }, + + setLOI(state, action: PayloadAction) { + state.loi = action.payload; + }, + + setAvailabilityCount(state, action: PayloadAction) { + state.availability_count = action.payload; + }, + + setAttemptedLiveEligibleCount(state, action: PayloadAction) { + state.attempted_live_eligible_count = action.payload; + }, + + setCurrentBuckets( + state, + action: PayloadAction, + ) { + state.currentBucketRequested = moment.utc().unix(); + state.currentBuckets = action.payload; + }, + + setOfferwallReasons(state, action: PayloadAction) { + state.offerwall_reasons = action.payload; + }, + + setEnteredTimestamp(state) { + // Go back by 2 seconds to account for any time drift + state.currentBucketEntered = moment.utc().unix() - 2; + }, + + setTaskStatus(state, action: PayloadAction) { + state.taskStatus = action.payload; + + state.bpuid = action.payload.product_user_id; + }, + + setUserWalletBalance(state, action: PayloadAction) { + state.userWalletBalance = action.payload; + }, + + setUserLedgerSummary( + state, + action: PayloadAction, + ) { + state.userLedgerSummary = action.payload; + }, + + setUserLedgerTxs( + state, + action: PayloadAction, + ) { + // We're not appending the transaction details, it's only going + // to reassign the current page of Transactions. + state.userLedgerTxs = action.payload; + }, + + setTxPagination(state, action: PayloadAction) { + state.txPagination = action.payload; + }, + + setTxTotalItems(state, action: PayloadAction) { + state.txTotalItems = action.payload; + }, + + setTxTotalPages(state, action: PayloadAction) { + state.txTotalPages = action.payload; + }, + }, +}); export const { - setProductUserID, - setAssignmentID, - setTurkSubmitTo, - setLOI, - setAvailabilityCount, - setAttemptedLiveEligibleCount, - setOfferwallReasons, - setCurrentBuckets, - setEnteredTimestamp, - setTaskStatus, - - setUserWalletBalance, - - setUserLedgerSummary, - setUserLedgerTxs, - - setTxPagination, - setTxTotalItems, - setTxTotalPages + setProductUserID, + setAssignmentID, + setTurkSubmitTo, + setLOI, + setAvailabilityCount, + setAttemptedLiveEligibleCount, + setOfferwallReasons, + setCurrentBuckets, + setEnteredTimestamp, + setTaskStatus, + + setUserWalletBalance, + + setUserLedgerSummary, + setUserLedgerTxs, + + setTxPagination, + setTxTotalItems, + setTxTotalPages, } = appSlice.actions; -export default appSlice.reducer +export default appSlice.reducer; export const getLOIText = createSelector( - [(state: RootState) => state.app], - (app_config): string => { - - const lookup: Record = { - 600: "ten", - 1200: "twenty", - 1800: "thirty", - }; - - return lookup[app_config.loi] || " – "; - } + [(state: RootState) => state.app], + (app_config): string => { + const lookup: Record = { + 600: "ten", + 1200: "twenty", + 1800: "thirty", + }; + + return lookup[app_config.loi] || " – "; + }, ); export const getAvailabilityCount = createSelector( - [(state: RootState) => state.app], - (app_config): number | undefined => { - return app_config.availability_count; - } + [(state: RootState) => state.app], + (app_config): number | undefined => { + return app_config.availability_count; + }, ); export const isLowBalance = createSelector( - [(state: RootState) => state.app], - (app_config): boolean => { - const bal_amt = app_config.userWalletBalance?.amount ?? 0 - return bal_amt <= -90; - } + [(state: RootState) => state.app], + (app_config): boolean => { + const bal_amt = app_config.userWalletBalance?.amount ?? 0; + return bal_amt <= -90; + }, ); - export const selectBucket = createSelector( - [ - (state: RootState) => state.app.currentBuckets, - ], - (buckets): TopNPlusBucket | null => { - if (buckets && buckets.length >= 1) { - return buckets[0] - } - return null; + [(state: RootState) => state.app.currentBuckets], + (buckets): TopNPlusBucket | null => { + if (buckets && buckets.length >= 1) { + return buckets[0]; } + return null; + }, ); export const getSurveyURL = createSelector( - [selectBucket], - (bucket): string | null => { - return bucket?.uri || null; - } -); \ No newline at end of file + [selectBucket], + (bucket): string | null => { + return bucket?.uri || null; + }, +); diff --git a/jb-ui/src/models/grlEventsSlice.ts b/jb-ui/src/models/grlEventsSlice.ts index 912c4ca..681c22e 100644 --- a/jb-ui/src/models/grlEventsSlice.ts +++ b/jb-ui/src/models/grlEventsSlice.ts @@ -1,58 +1,56 @@ import { EventEnvelope } from "@/api_fsb"; import { RootState } from "@/store"; -import { createEntityAdapter, createSelector, createSlice } from '@reduxjs/toolkit'; +import { + createEntityAdapter, + createSelector, + createSlice, +} from "@reduxjs/toolkit"; const eventAdapter = createEntityAdapter({ - selectId: (model: EventEnvelope) => model.event_uuid!, - sortComparer: (a, b) => { - // ASC by timestamp - return a.timestamp!.localeCompare(b.timestamp!); - }, + selectId: (model: EventEnvelope) => model.event_uuid!, + sortComparer: (a, b) => { + // ASC by timestamp + return a.timestamp!.localeCompare(b.timestamp!); + }, }); - const grlEventsSlice = createSlice({ - name: 'grlEvents', - initialState: eventAdapter.getInitialState(), - - reducers: { - addEvent: eventAdapter.addOne, - addEvents: eventAdapter.addMany, - upsertEvent: eventAdapter.upsertOne, // Add or Update - - } -}) - - -export const { - addEvent, addEvents, upsertEvent -} = grlEventsSlice.actions; + name: "grlEvents", + initialState: eventAdapter.getInitialState(), + + reducers: { + addEvent: eventAdapter.addOne, + addEvents: eventAdapter.addMany, + upsertEvent: eventAdapter.upsertOne, // Add or Update + }, +}); +export const { addEvent, addEvents, upsertEvent } = grlEventsSlice.actions; export const eventSelectors = eventAdapter.getSelectors( - (state: RootState) => state.events + (state: RootState) => state.events, ); -export const selectAllEvents = (state: RootState): EventEnvelope[] => eventSelectors.selectAll(state); - -const BASE_SPEED = 40; // px/sec at idle -const MAX_SPEED = 380; // px/sec at surge +export const selectAllEvents = (state: RootState): EventEnvelope[] => + eventSelectors.selectAll(state); -const RATE_WINDOW_MS = 10_000; // ms window to measure message frequency -const RATE_TO_SPEED = 55; // px/sec added per msg/sec +const BASE_SPEED = 40; // px/sec at idle +const MAX_SPEED = 380; // px/sec at surge +const RATE_WINDOW_MS = 10_000; // ms window to measure message frequency +const RATE_TO_SPEED = 55; // px/sec added per msg/sec -// --- Speed: derived from arrival rate of recent items --- +// --- Speed: derived from arrival rate of recent items --- export const selectCurrentSpeed = createSelector(selectAllEvents, (items) => { - const now = Date.now(); + const now = Date.now(); - const recentCount = items.filter((m) => { - const ts = new Date(m.timestamp!).getTime(); - return now - ts < RATE_WINDOW_MS; - }).length; + const recentCount = items.filter((m) => { + const ts = new Date(m.timestamp!).getTime(); + return now - ts < RATE_WINDOW_MS; + }).length; - const rate = recentCount / (RATE_WINDOW_MS / 1_000); // msgs/sec - return Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED); + const rate = recentCount / (RATE_WINDOW_MS / 1_000); // msgs/sec + return Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED); }); -export default grlEventsSlice.reducer +export default grlEventsSlice.reducer; diff --git a/jb-ui/src/models/grlStatsSlice.ts b/jb-ui/src/models/grlStatsSlice.ts index 9385256..004d321 100644 --- a/jb-ui/src/models/grlStatsSlice.ts +++ b/jb-ui/src/models/grlStatsSlice.ts @@ -1,54 +1,47 @@ import { StatsSnapshot } from "@/api_fsb"; import { RootState } from "@/store"; -import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; - -const initialState: StatsSnapshot[] = [] +const initialState: StatsSnapshot[] = []; const grlStatsSlice = createSlice({ - name: 'grlStats', - initialState, - reducers: { - - addStatsData(state, action: PayloadAction) { - state.push(action.payload); - } - } -}) - -export const { - addStatsData -} = grlStatsSlice.actions; -export default grlStatsSlice.reducer - + name: "grlStats", + initialState, + reducers: { + addStatsData(state, action: PayloadAction) { + state.push(action.payload); + }, + }, +}); + +export const { addStatsData } = grlStatsSlice.actions; +export default grlStatsSlice.reducer; export const selectRecentStats = createSelector( - [ - (state: RootState) => state.stats, - ], - (stats): StatsSnapshot | null => { - const lastStat = stats[stats.length - 1]; - return lastStat ?? null; - } + [(state: RootState) => state.stats], + (stats): StatsSnapshot | null => { + const lastStat = stats[stats.length - 1]; + return lastStat ?? null; + }, ); export const activeUsers = createSelector( - [selectRecentStats], - (recentStats): number | null => { - return recentStats?.active_users_last_24h ?? null; - } + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.active_users_last_24h ?? null; + }, ); export const activeSurveys = createSelector( - [selectRecentStats], - (recentStats): number | null => { - return recentStats?.live_task_count?.total ?? null; - } + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.live_task_count?.total ?? null; + }, ); export const maxPayout = createSelector( - [selectRecentStats], - (recentStats): number | null => { - return recentStats?.live_tasks_max_payout?.value ?? null; - } + [selectRecentStats], + (recentStats): number | null => { + return recentStats?.live_tasks_max_payout?.value ?? null; + }, ); diff --git a/jb-ui/src/models/profilingQuestionsSlice.ts b/jb-ui/src/models/profilingQuestionsSlice.ts index cc36f17..577566f 100644 --- a/jb-ui/src/models/profilingQuestionsSlice.ts +++ b/jb-ui/src/models/profilingQuestionsSlice.ts @@ -1,313 +1,327 @@ import { - BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, - ProfilingQuestionsApi, - StatusResponse, - UpkQuestion, UserQuestionAnswerIn + BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, + ProfilingQuestionsApi, + StatusResponse, + UpkQuestion, + UserQuestionAnswerIn, } from "@/api_fsb"; import { assert, bpid } from "@/lib/utils"; import { questionUtils } from "@/models/profilingUtils"; -import type { RootState } from '@/store'; +import type { RootState } from "@/store"; import { - createAsyncThunk, - createEntityAdapter, createSlice, - PayloadAction, - WritableDraft -} from '@reduxjs/toolkit'; + createAsyncThunk, + createEntityAdapter, + createSlice, + PayloadAction, + WritableDraft, +} from "@reduxjs/toolkit"; interface UpkQuestionMetadata { - // The data we need to conduct profiling questions and manager user - // submission along with the User Interfaces + // The data we need to conduct profiling questions and manager user + // submission along with the User Interfaces - // Only one Question can be active at a time, which is the one - // being shown to the Respondent - isActive: boolean; + // Only one Question can be active at a time, which is the one + // being shown to the Respondent + isActive: boolean; - answers: string[]; + answers: string[]; - // If it's actively being submitted to the server - isProcessing: boolean; + // If it's actively being submitted to the server + isProcessing: boolean; - // If we recieved a successful response from the server after submitting - isSaved: boolean; + // If we recieved a successful response from the server after submitting + isSaved: boolean; } interface ValidationResult { - // If isValid or isComplete are undefined, that means the question - // hasn't been validated yet. This is different from false, which means - // it has been validated and is not valid. - isValid?: boolean; - isComplete?: boolean; - errors: ValidationError[]; -}; + // If isValid or isComplete are undefined, that means the question + // hasn't been validated yet. This is different from false, which means + // it has been validated and is not valid. + isValid?: boolean; + isComplete?: boolean; + errors: ValidationError[]; +} export interface ValidationError { - path: string; // e.g., "user.email" - message: string; - severity: 'error' | 'warning'; -}; + path: string; // e.g., "user.email" + message: string; + severity: "error" | "warning"; +} export interface ProfileQuestion extends UpkQuestion { - _metadata: UpkQuestionMetadata; - _validation: ValidationResult; + _metadata: UpkQuestionMetadata; + _validation: ValidationResult; } // Entity adapter for normalized storage const questionsAdapter = createEntityAdapter({ - selectId: (model: ProfileQuestion) => model.question_id!, - sortComparer: (a, b) => { - return (b.importance?.task_score ?? 0) - (a.importance?.task_score ?? 0); - }, + selectId: (model: ProfileQuestion) => model.question_id!, + sortComparer: (a, b) => { + return (b.importance?.task_score ?? 0) - (a.importance?.task_score ?? 0); + }, }); - function getDefaultMetadata(): UpkQuestionMetadata { - return { - isActive: false, - answers: [], - isProcessing: false, - isSaved: false, - }; + return { + isActive: false, + answers: [], + isProcessing: false, + isSaved: false, + }; } function getDefaultValidation(): ValidationResult { - - return { - isValid: undefined, - isComplete: undefined, - errors: [], - }; + return { + isValid: undefined, + isComplete: undefined, + errors: [], + }; } // Create the async thunk (outside your slice) export const saveAnswer = createAsyncThunk( - 'profilingQuestions/saveAnswer', - async ({ questionId, bpuid }: { questionId: string; bpuid: string }, { getState, dispatch }) => { - const state = getState() as RootState; - const question = state.profilingQuestions.entities[questionId]; - - // Validations - const answers = { - 'question_id': question.question_id!, - 'answer': question._metadata.answers - } as UserQuestionAnswerIn; - - const answers_body = { - 'answers': [answers] - } as BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost; - - // Make API call - const res = await new ProfilingQuestionsApi() - .submitProfilingQuestionsProductIdProfilingQuestionsPost( - bpid, - bpuid, - answers_body, - undefined, - true - ); - - const response = res.data as StatusResponse; - - // Check if we need to fetch new questions BEFORE returning - if (response.status === "success" && isFirstNonGR(state.profilingQuestions)) { - // NOW dispatch it - dispatch(fetchNewQuestions(bpuid)); - } - - return { questionId, response }; - + "profilingQuestions/saveAnswer", + async ( + { questionId, bpuid }: { questionId: string; bpuid: string }, + { getState, dispatch }, + ) => { + const state = getState() as RootState; + const question = state.profilingQuestions.entities[questionId]; + + // Validations + const answers = { + question_id: question.question_id!, + answer: question._metadata.answers, + } as UserQuestionAnswerIn; + + const answers_body = { + answers: [answers], + } as BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost; + + // Make API call + const res = + await new ProfilingQuestionsApi().submitProfilingQuestionsProductIdProfilingQuestionsPost( + bpid, + bpuid, + answers_body, + undefined, + true, + ); + + const response = res.data as StatusResponse; + + // Check if we need to fetch new questions BEFORE returning + if ( + response.status === "success" && + isFirstNonGR(state.profilingQuestions) + ) { + // NOW dispatch it + dispatch(fetchNewQuestions(bpuid)); } -) -export const fetchNewQuestions = createAsyncThunk( - 'profilingQuestions/fetchNewQuestions', - async (bpuid: string) => { - - console.log("profilingQuestions/fetchNewQuestions") - const res = await new ProfilingQuestionsApi() - .getProfilingQuestionsProductIdProfilingQuestionsGet( - bpid, - bpuid, - undefined, // "104.9.125.144", // ip - undefined, // countryIso - undefined, // languageIso - 2_500 - ); - return res.data.questions; - } + return { questionId, response }; + }, ); -type ProfilingQuestionsState = ReturnType; +export const fetchNewQuestions = createAsyncThunk( + "profilingQuestions/fetchNewQuestions", + async (bpuid: string) => { + console.log("profilingQuestions/fetchNewQuestions"); + const res = + await new ProfilingQuestionsApi().getProfilingQuestionsProductIdProfilingQuestionsGet( + bpid, + bpuid, + undefined, // "104.9.125.144", // ip + undefined, // countryIso + undefined, // languageIso + 2_500, + ); + return res.data.questions; + }, +); +type ProfilingQuestionsState = ReturnType< + typeof profilingQuestionSlice.getInitialState +>; // Helper function that contains the logic (outside the slice) function goToNextQuestionLogic(state: WritableDraft) { - const allQuestions = questionsAdapter.getSelectors().selectAll(state); - const currentIndex = allQuestions.findIndex(q => q._metadata.isActive); - - if (currentIndex === -1) return; - - const totalQuestions = allQuestions.length; - let searchIndex = (currentIndex + 1) % totalQuestions; - let iterations = 0; - - while (iterations < totalQuestions) { - if (!allQuestions[searchIndex]._metadata.isSaved) { - questionsAdapter.updateMany(state, [ - { - id: allQuestions[currentIndex].question_id!, - changes: { - _metadata: { - ...allQuestions[currentIndex]._metadata, - isActive: false - } - } - }, - { - id: allQuestions[searchIndex].question_id!, - changes: { - _metadata: { - ...allQuestions[searchIndex]._metadata, - isActive: true - } - } - } - ]); - return; - } - - searchIndex = (searchIndex + 1) % totalQuestions; - iterations++; + const allQuestions = questionsAdapter.getSelectors().selectAll(state); + const currentIndex = allQuestions.findIndex((q) => q._metadata.isActive); + + if (currentIndex === -1) return; + + const totalQuestions = allQuestions.length; + let searchIndex = (currentIndex + 1) % totalQuestions; + let iterations = 0; + + while (iterations < totalQuestions) { + if (!allQuestions[searchIndex]._metadata.isSaved) { + questionsAdapter.updateMany(state, [ + { + id: allQuestions[currentIndex].question_id!, + changes: { + _metadata: { + ...allQuestions[currentIndex]._metadata, + isActive: false, + }, + }, + }, + { + id: allQuestions[searchIndex].question_id!, + changes: { + _metadata: { + ...allQuestions[searchIndex]._metadata, + isActive: true, + }, + }, + }, + ]); + return; } + + searchIndex = (searchIndex + 1) % totalQuestions; + iterations++; + } } function setProfilingQuestionsLogic( - state: WritableDraft, - questions: ProfileQuestion[]) { - - const hasActiveQuestion = Object.values(state.entities).some( - entity => entity?._metadata.isActive - ); - - const entities = questions.map((serverQuestion, index) => { - const existing = state.entities[serverQuestion.question_id!]; - const shouldActivate = !hasActiveQuestion && index === 0; - - return { - ...serverQuestion, - _metadata: existing?._metadata || { - ...getDefaultMetadata(), - isActive: shouldActivate, - }, - _validation: existing?._validation || getDefaultValidation(), - }; - }); + state: WritableDraft, + questions: ProfileQuestion[], +) { + const hasActiveQuestion = Object.values(state.entities).some( + (entity) => entity?._metadata.isActive, + ); - questionsAdapter.setAll(state, entities); -} + const entities = questions.map((serverQuestion, index) => { + const existing = state.entities[serverQuestion.question_id!]; + const shouldActivate = !hasActiveQuestion && index === 0; + return { + ...serverQuestion, + _metadata: existing?._metadata || { + ...getDefaultMetadata(), + isActive: shouldActivate, + }, + _validation: existing?._validation || getDefaultValidation(), + }; + }); + + questionsAdapter.setAll(state, entities); +} function isGR(ext_question_id: string): boolean { - return ext_question_id.startsWith("gr:") + return ext_question_id.startsWith("gr:"); } function isFirstNonGR(state: WritableDraft): boolean { - // We want to identify if the next question is the first non-GR question - // because will want to trigger a full profile question refresh. + // We want to identify if the next question is the first non-GR question + // because will want to trigger a full profile question refresh. - const allQuestions = questionsAdapter.getSelectors().selectAll(state); - const currentIndex = allQuestions.findIndex(q => q._metadata.isActive); + const allQuestions = questionsAdapter.getSelectors().selectAll(state); + const currentIndex = allQuestions.findIndex((q) => q._metadata.isActive); - if (currentIndex === -1) false; + if (currentIndex === -1) false; - const totalQuestions = allQuestions.length; - let searchIndex = (currentIndex + 1) % totalQuestions; - let iterations = 0; + const totalQuestions = allQuestions.length; + let searchIndex = (currentIndex + 1) % totalQuestions; + let iterations = 0; - while (iterations < totalQuestions) { - if (!allQuestions[searchIndex]._metadata.isSaved) { + while (iterations < totalQuestions) { + if (!allQuestions[searchIndex]._metadata.isSaved) { + const current = isGR(allQuestions[currentIndex].ext_question_id!); + const next = isGR(allQuestions[searchIndex].ext_question_id!); - const current = isGR(allQuestions[currentIndex].ext_question_id!) - const next = isGR(allQuestions[searchIndex].ext_question_id!) - - // We want to identify transitions from GR to non-GR questions, - // as we use GR questions to pre-calculate some non-GR questions, - // we should force a refresh. - return current && !next; - } - - searchIndex = (searchIndex + 1) % totalQuestions; - iterations++; + // We want to identify transitions from GR to non-GR questions, + // as we use GR questions to pre-calculate some non-GR questions, + // we should force a refresh. + return current && !next; } - return false; + searchIndex = (searchIndex + 1) % totalQuestions; + iterations++; + } + + return false; } const profilingQuestionSlice = createSlice({ - name: 'profilingQuestions', - initialState: questionsAdapter.getInitialState(), - reducers: { + name: "profilingQuestions", + initialState: questionsAdapter.getInitialState(), + reducers: { + setProfilingQuestions: ( + state, + action: PayloadAction, + ) => { + setProfilingQuestionsLogic(state, action.payload); + }, - setProfilingQuestions: (state, action: PayloadAction) => { - setProfilingQuestionsLogic(state, action.payload); - }, + metadataUpdated: ( + state, + action: PayloadAction<{ + question_id: string; + metadata: Partial; + }>, + ) => { + const { question_id, metadata } = action.payload; + const entity = state.entities[question_id]; + if (entity) { + entity._metadata = { ...entity._metadata, ...metadata }; + } + }, - metadataUpdated: ( - state, - action: PayloadAction<{ question_id: string; metadata: Partial }> - ) => { - const { question_id, metadata } = action.payload; - const entity = state.entities[question_id]; - if (entity) { - entity._metadata = { ...entity._metadata, ...metadata }; - } - }, + validationUpdated: ( + state, + action: PayloadAction<{ + question_id: string; + validation: Partial; + }>, + ) => { + const { question_id, validation } = action.payload; + const entity = state.entities[question_id]; + if (entity) { + entity._validation = { ...entity._validation, ...validation }; + } + }, - validationUpdated: ( - state, - action: PayloadAction<{ question_id: string; validation: Partial }> - ) => { - const { question_id, validation } = action.payload; - const entity = state.entities[question_id]; - if (entity) { - entity._validation = { ...entity._validation, ...validation }; - } - }, + // Set one question as active, deactivate all others + setActiveQuestion: (state, action: PayloadAction) => { + const questionId = action.payload; - // Set one question as active, deactivate all others - setActiveQuestion: (state, action: PayloadAction) => { - const questionId = action.payload; - - // Deactivate all questions - Object.values(state.entities).forEach(entity => { - if (entity) { - entity._metadata.isActive = false; - } - }); - - // Activate the selected one - const entity = state.entities[questionId]; - if (entity) { - entity._metadata.isActive = true; - } - }, + // Deactivate all questions + Object.values(state.entities).forEach((entity) => { + if (entity) { + entity._metadata.isActive = false; + } + }); - // Clear active state from all questions - clearActiveQuestion: (state) => { - Object.values(state.entities).forEach(entity => { - if (entity) { - entity._metadata.isActive = false; - } - }); - }, + // Activate the selected one + const entity = state.entities[questionId]; + if (entity) { + entity._metadata.isActive = true; + } + }, - goToNextQuestion: (state) => { - goToNextQuestionLogic(state); - }, + // Clear active state from all questions + clearActiveQuestion: (state) => { + Object.values(state.entities).forEach((entity) => { + if (entity) { + entity._metadata.isActive = false; + } + }); + }, + + goToNextQuestion: (state) => { + goToNextQuestionLogic(state); + }, - // ----------------------- + // ----------------------- - addAnswers(state, action: PayloadAction<{ questionId: string, answers: string[] }>) { - /* When changing the answers in anyway, we want to: + addAnswers( + state, + action: PayloadAction<{ questionId: string; answers: string[] }>, + ) { + /* When changing the answers in anyway, we want to: 1. Add the new answers to the question metadata This does not perform validation, it simply reassigns @@ -325,112 +339,124 @@ const profilingQuestionSlice = createSlice({ 3. Re-calculate the isComplete and isValid state */ - // let new_question = questionUtils.addAnswer(action.payload.question, action.payload.answers) - let entity = state.entities[action.payload.questionId]; - const newMetadata = { ...entity._metadata, answers: action.payload.answers }; + // let new_question = questionUtils.addAnswer(action.payload.question, action.payload.answers) + let entity = state.entities[action.payload.questionId]; + const newMetadata = { + ...entity._metadata, + answers: action.payload.answers, + }; - if (entity) { - entity._metadata = newMetadata; + if (entity) { + entity._metadata = newMetadata; - entity = questionUtils.validate(entity) - entity = questionUtils.assignValidationState(entity) + entity = questionUtils.validate(entity); + entity = questionUtils.assignValidationState(entity); - state.entities[action.payload.questionId] = entity - } - }, + state.entities[action.payload.questionId] = entity; + } }, - - extraReducers: (builder) => { - builder - .addCase(saveAnswer.pending, (state, action) => { - const questionId = action.meta.arg.questionId; - const bpuid = action.meta.arg.bpuid; - assert(bpuid, "Worker must be defined"); - - const question = state.entities[questionId]; - if (question) { - assert(question._validation.isComplete, "Must submit Completed Questions"); - assert(!question._metadata.isProcessing, "Can't submit processing Answer"); - assert(!question._metadata.isSaved, "Can't submit completed Answer"); - question._metadata.isProcessing = true; - } - }) - .addCase(saveAnswer.fulfilled, (state, action) => { - const { questionId, response } = action.payload; - const question = state.entities[questionId]; - - if (question) { - question._metadata.isProcessing = false; - if (response.status === "success") { - question._metadata.isSaved = true; - - // fetchNewQuestions is already being dispatched from - // the THUNK - goToNextQuestionLogic(state); - - } else { - question._metadata.isSaved = false; - } - } - }) - .addCase(saveAnswer.rejected, (state, action) => { - const questionId = action.meta.arg.questionId; - const question = state.entities[questionId]; - - if (question) { - question._metadata.isProcessing = false; - question._metadata.isSaved = false; - } - }) - .addCase(fetchNewQuestions.fulfilled, (state, action) => { - console.log(".addCase(fetchNewQuestions.fulfilled, (state, action) => {") - setProfilingQuestionsLogic(state, action.payload as ProfileQuestion[]); - }); - ; - } - -}) + }, + + extraReducers: (builder) => { + builder + .addCase(saveAnswer.pending, (state, action) => { + const questionId = action.meta.arg.questionId; + const bpuid = action.meta.arg.bpuid; + assert(bpuid, "Worker must be defined"); + + const question = state.entities[questionId]; + if (question) { + assert( + question._validation.isComplete, + "Must submit Completed Questions", + ); + assert( + !question._metadata.isProcessing, + "Can't submit processing Answer", + ); + assert(!question._metadata.isSaved, "Can't submit completed Answer"); + question._metadata.isProcessing = true; + } + }) + .addCase(saveAnswer.fulfilled, (state, action) => { + const { questionId, response } = action.payload; + const question = state.entities[questionId]; + + if (question) { + question._metadata.isProcessing = false; + if (response.status === "success") { + question._metadata.isSaved = true; + + // fetchNewQuestions is already being dispatched from + // the THUNK + goToNextQuestionLogic(state); + } else { + question._metadata.isSaved = false; + } + } + }) + .addCase(saveAnswer.rejected, (state, action) => { + const questionId = action.meta.arg.questionId; + const question = state.entities[questionId]; + + if (question) { + question._metadata.isProcessing = false; + question._metadata.isSaved = false; + } + }) + .addCase(fetchNewQuestions.fulfilled, (state, action) => { + console.log( + ".addCase(fetchNewQuestions.fulfilled, (state, action) => {", + ); + setProfilingQuestionsLogic(state, action.payload as ProfileQuestion[]); + }); + }, +}); export const { - setProfilingQuestions, - metadataUpdated, - setActiveQuestion, - goToNextQuestion, - addAnswers, + setProfilingQuestions, + metadataUpdated, + setActiveQuestion, + goToNextQuestion, + addAnswers, } = profilingQuestionSlice.actions; // Selectors export const questionsSelectors = questionsAdapter.getSelectors( - (state: RootState) => state.profilingQuestions + (state: RootState) => state.profilingQuestions, ); // Custom selector to get the active question export const selectAllQuestions = (state: RootState): ProfileQuestion[] => { - return questionsSelectors.selectAll(state) ?? []; + return questionsSelectors.selectAll(state) ?? []; }; // Custom selector to get the active question -export const selectActiveQuestion = (state: RootState): ProfileQuestion | null => { - const allQuestions = questionsSelectors.selectAll(state); - return allQuestions.find(q => q._metadata.isActive) || null; +export const selectActiveQuestion = ( + state: RootState, +): ProfileQuestion | null => { + const allQuestions = questionsSelectors.selectAll(state); + return allQuestions.find((q) => q._metadata.isActive) || null; }; // Custom selector to get active question ID export const selectActiveQuestionId = (state: RootState): string | null => { - const activeQuestion = selectActiveQuestion(state); - return activeQuestion?.question_id || null; + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?.question_id || null; }; -export const selectActiveQuestionMetadata = (state: RootState): UpkQuestionMetadata | null => { - const activeQuestion = selectActiveQuestion(state); - return activeQuestion?._metadata || null; +export const selectActiveQuestionMetadata = ( + state: RootState, +): UpkQuestionMetadata | null => { + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?._metadata || null; }; -export const selectActiveQuestionValidation = (state: RootState): ValidationResult | null => { - const activeQuestion = selectActiveQuestion(state); - return activeQuestion?._validation || null; +export const selectActiveQuestionValidation = ( + state: RootState, +): ValidationResult | null => { + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?._validation || null; }; - -export default profilingQuestionSlice.reducer - +export default profilingQuestionSlice.reducer; diff --git a/jb-ui/src/models/profilingUtils.ts b/jb-ui/src/models/profilingUtils.ts index 104a5bb..a3ef7d2 100644 --- a/jb-ui/src/models/profilingUtils.ts +++ b/jb-ui/src/models/profilingUtils.ts @@ -1,29 +1,36 @@ -import { PatternValidation, UpkQuestionConfigurationMC, UpkQuestionConfigurationTE } from "@/api_fsb"; +import { + PatternValidation, + UpkQuestionConfigurationMC, + UpkQuestionConfigurationTE, +} from "@/api_fsb"; import { assert } from "@/lib/utils"; -import { ProfileQuestion, ValidationError } from '@/models/profilingQuestionsSlice'; -import { Filter } from 'bad-words'; +import { + ProfileQuestion, + ValidationError, +} from "@/models/profilingQuestionsSlice"; +import { Filter } from "bad-words"; const filter = new Filter(); export const questionUtils = { - - assignValidationState: (question: ProfileQuestion): ProfileQuestion => { - /* This function performs validation on the question's current answers and + assignValidationState: (question: ProfileQuestion): ProfileQuestion => { + /* This function performs validation on the question's current answers and assigns any error messages to question._metadata.error_msg. If the question is MC, validate: */ - const errors = question._validation.errors ?? [] + const errors = question._validation.errors ?? []; - question._validation.isComplete = errors.length === 0 - question._validation.isValid = errors.filter(e => e.severity === 'error').length === 0 + question._validation.isComplete = errors.length === 0; + question._validation.isValid = + errors.filter((e) => e.severity === "error").length === 0; - return question; - }, + return question; + }, - validate: (question: ProfileQuestion): ProfileQuestion => { - /* If the question is MC, validate: + validate: (question: ProfileQuestion): ProfileQuestion => { + /* If the question is MC, validate: - validate selector SA vs MA (1 selected vs >1 selected) - the answers match actual codes in the choices @@ -35,121 +42,132 @@ export const questionUtils = { - validation.patterns */ - assert(question.question_id, "Question must have question_id") - // assert(question.configuration, "Question must have configuration") - const answers = question._metadata.answers ?? [] - - // Declare it here, and they we'll reassign any specific - // configuration types based on the question type. - - // Start fresh without any error messages before validating - const errors: ValidationError[] = [] - - switch (question.question_type) { - case "TE": - - answers.length === 0 && errors.push({ - path: `question_id:${question.question_id}`, - message: "No answer provided", - severity: "warning", - } as ValidationError) - - answers.length > 1 && errors.push({ - path: `question_id:${question.question_id}`, - message: "Only one answer allowed", - severity: "error", - } as ValidationError) - - let answer_text: string = answers[0] - - const te_config = question.configuration as UpkQuestionConfigurationTE - const min_length = te_config.min_length ?? 0 - const max_length = te_config.max_length ?? 100_000 - - answer_text.length < min_length && errors.push({ - path: `question_id:${question.question_id}`, - message: "Answer shorter than allowed", - severity: "warning", - } as ValidationError) - - answer_text.length > max_length && errors.push({ - path: `question_id:${question.question_id}`, - message: "Answer longer than allowed", - severity: "error", - } as ValidationError) - - filter.isProfane(answer_text) && errors.push({ - path: `question_id:${question.question_id}`, - message: "Answer has inappropriate content", - severity: "error", - } as ValidationError) - - const patterns: PatternValidation[] = (question.validation ?? {})["patterns"] ?? [] - patterns.forEach((pv) => { - let re = new RegExp(pv.pattern) - answer_text.search(re) == -1 && errors.push({ - path: `question_id:${question.question_id}`, - message: pv.message, - severity: "error", - } as ValidationError) - - }) - - break; - - case "MC": - - answers.length === 0 && errors.push({ - path: `question_id:${question.question_id}`, - message: "Multiple Choice question with no selected answers", - severity: "warning", - } as ValidationError) - - const choice_codes: string[] = question.choices?.map((c) => c.choice_id) ?? []; - - const mc_config = (question.configuration ?? {}) as UpkQuestionConfigurationMC - const max_select = mc_config.max_select ?? choice_codes.length - - switch (question.selector) { - case "SA": - answers.length > 1 && errors.push({ - path: `question_id:${question.question_id}`, - message: "Single Answer MC question with >1 selected answers", - severity: "error", - } as ValidationError) - - break; - - case "MA": - answers.length > max_select && errors.push({ - path: `question_id:${question.question_id}`, - message: "More options selected than allowed", - severity: "error", - } as ValidationError) - - answers.length === max_select && errors.push({ - path: `question_id:${question.question_id}`, - message: "Selected all the options", - severity: "error", - } as ValidationError) - - break; - } - - // Now validate Multiple Choice answers regardless of if - // they're Single Answer (SA) or Multiple Answer (MA) - !answers.every(item => choice_codes.includes(item)) && errors.push({ - path: `question_id:${question.question_id}`, - message: "Invalid options selected", - severity: "error", - }) - - // const max_select: number = question.configuration?.max_select ?? choice_codes.length - // if (answer.values.length > max_select) { - // answer.error_msg = "More options selected than allowed" - // } - - /* + assert(question.question_id, "Question must have question_id"); + // assert(question.configuration, "Question must have configuration") + const answers = question._metadata.answers ?? []; + + // Declare it here, and they we'll reassign any specific + // configuration types based on the question type. + + // Start fresh without any error messages before validating + const errors: ValidationError[] = []; + + switch (question.question_type) { + case "TE": + answers.length === 0 && + errors.push({ + path: `question_id:${question.question_id}`, + message: "No answer provided", + severity: "warning", + } as ValidationError); + + answers.length > 1 && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Only one answer allowed", + severity: "error", + } as ValidationError); + + let answer_text: string = answers[0]; + + const te_config = question.configuration as UpkQuestionConfigurationTE; + const min_length = te_config.min_length ?? 0; + const max_length = te_config.max_length ?? 100_000; + + answer_text.length < min_length && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer shorter than allowed", + severity: "warning", + } as ValidationError); + + answer_text.length > max_length && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer longer than allowed", + severity: "error", + } as ValidationError); + + filter.isProfane(answer_text) && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Answer has inappropriate content", + severity: "error", + } as ValidationError); + + const patterns: PatternValidation[] = + (question.validation ?? {})["patterns"] ?? []; + patterns.forEach((pv) => { + let re = new RegExp(pv.pattern); + answer_text.search(re) == -1 && + errors.push({ + path: `question_id:${question.question_id}`, + message: pv.message, + severity: "error", + } as ValidationError); + }); + + break; + + case "MC": + answers.length === 0 && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Multiple Choice question with no selected answers", + severity: "warning", + } as ValidationError); + + const choice_codes: string[] = + question.choices?.map((c) => c.choice_id) ?? []; + + const mc_config = (question.configuration ?? + {}) as UpkQuestionConfigurationMC; + const max_select = mc_config.max_select ?? choice_codes.length; + + switch (question.selector) { + case "SA": + answers.length > 1 && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Single Answer MC question with >1 selected answers", + severity: "error", + } as ValidationError); + + break; + + case "MA": + answers.length > max_select && + errors.push({ + path: `question_id:${question.question_id}`, + message: "More options selected than allowed", + severity: "error", + } as ValidationError); + + answers.length === max_select && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Selected all the options", + severity: "error", + } as ValidationError); + + break; + } + + // Now validate Multiple Choice answers regardless of if + // they're Single Answer (SA) or Multiple Answer (MA) + !answers.every((item) => choice_codes.includes(item)) && + errors.push({ + path: `question_id:${question.question_id}`, + message: "Invalid options selected", + severity: "error", + }); + + // const max_select: number = question.configuration?.max_select ?? choice_codes.length + // if (answer.values.length > max_select) { + // answer.error_msg = "More options selected than allowed" + // } + + /* exclusive_choice = next((x for x in question["choices"] if x.get("exclusive")), None) if exclusive_choice: exclusive_choice_id = exclusive_choice["choice_id"] @@ -157,21 +175,20 @@ export const questionUtils = { exclusive_choice_id not in answer, "Invalid exclusive selection" */ - break; - - default: - errors.push({ - path: `question_id:${question.question_id}`, - message: "Incorrect Question Type provided", - severity: "error", - } as ValidationError) - } - - // Assign the errors back to the question's _validation field and - // then return the question - // TODO: does this edit in place, maybe I don't need to return it - question._validation.errors = errors - return question; - }, - -}; \ No newline at end of file + break; + + default: + errors.push({ + path: `question_id:${question.question_id}`, + message: "Incorrect Question Type provided", + severity: "error", + } as ValidationError); + } + + // Assign the errors back to the question's _validation field and + // then return the question + // TODO: does this edit in place, maybe I don't need to return it + question._validation.errors = errors; + return question; + }, +}; -- cgit v1.2.3 From 11d63b171501b9c29398133b795c3bcc1ae85a1b Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Fri, 11 Sep 2026 18:16:06 -0700 Subject: using RTK with session login. start of magic token request --- jb-ui/.vscode/settings.json | 2 +- jb-ui/src/AuthContext.tsx | 28 ++++++++++++ jb-ui/src/JBApp.tsx | 4 +- jb-ui/src/models/authAPI.ts | 23 ++++++++++ jb-ui/src/pages/Work.tsx | 103 ++++++++++++++++++++++++++++++++++++++++---- jb-ui/src/store.ts | 5 +++ jb/views/auth.py | 20 +++++---- 7 files changed, 164 insertions(+), 21 deletions(-) create mode 100644 jb-ui/src/AuthContext.tsx create mode 100644 jb-ui/src/models/authAPI.ts (limited to 'jb-ui/src/models') diff --git a/jb-ui/.vscode/settings.json b/jb-ui/.vscode/settings.json index 4257030..9b8250c 100644 --- a/jb-ui/.vscode/settings.json +++ b/jb-ui/.vscode/settings.json @@ -10,6 +10,6 @@ "editor.formatOnSave": true }, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" } } \ No newline at end of file diff --git a/jb-ui/src/AuthContext.tsx b/jb-ui/src/AuthContext.tsx new file mode 100644 index 0000000..85bcdb0 --- /dev/null +++ b/jb-ui/src/AuthContext.tsx @@ -0,0 +1,28 @@ +// AuthContext.jsx +import { createContext, useContext, useEffect, useState } from "react"; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/me", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then(setUser) + .finally(() => setLoading(false)); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx index 7c99315..fd0eeba 100644 --- a/jb-ui/src/JBApp.tsx +++ b/jb-ui/src/JBApp.tsx @@ -225,10 +225,10 @@ function JBApp() { return ( - } /> }> }> - } /> + } /> + } /> {/* } /> */} } /> diff --git a/jb-ui/src/models/authAPI.ts b/jb-ui/src/models/authAPI.ts new file mode 100644 index 0000000..1ac7886 --- /dev/null +++ b/jb-ui/src/models/authAPI.ts @@ -0,0 +1,23 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +export const authApi = createApi({ + reducerPath: "authApi", + baseQuery: fetchBaseQuery({ + baseUrl: "/auth", + credentials: "include", // sends the httpOnly cookie + }), + tagTypes: ["User"], + endpoints: (builder) => ({ + getMe: builder.query({ + query: () => "/session", + providesTags: ["User"], + }), + // logout: builder.mutation({ + // query: () => ({ url: "/de-session", method: "POST" }), + // invalidatesTags: ["User"], + // }), + }), +}); + +// export const { useGetMeQuery, useLogoutMutation } = authApi; +export const { useGetMeQuery } = authApi; diff --git a/jb-ui/src/pages/Work.tsx b/jb-ui/src/pages/Work.tsx index 32dcc96..e33328e 100644 --- a/jb-ui/src/pages/Work.tsx +++ b/jb-ui/src/pages/Work.tsx @@ -1,3 +1,4 @@ +import { useGetMeQuery } from "@/models/authAPI"; import { BucketTask, OfferwallApi, @@ -828,17 +829,101 @@ const SurveyEnter = () => { ); }; +type RequestState = "idle" | "working" | "sent" | "failed"; + +const Login = () => { + const [email, setEmail] = useState(""); + const [state, setState] = useState("idle"); + const bpuid = useAppSelector((state) => state.app.bpuid); + + // const dispatch = useAppDispatch(); + const submit = async (event: FormEvent) => { + 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); + } catch { + setState("failed"); + } + }; + + return ( +
+

+ Please Login +

+ +
+ setEmail(event.target.value)} + placeholder="hello@jamesbillings67.com" + /> + +
+ + {state === "sent" && ( +

+ Check your email to login. +

+ )} + + {state === "failed" &&

Unable to send the link. Please try again.

} +
+ ); +}; + const Work = () => { const entered = useAppSelector((state) => state.app.currentBucketEntered); - const assignment_id = useAppSelector((state) => state.app.assignment_id); - - if (!assignment_id) { - return ( -

- No Assignment ID found. Please access this page through the Amazon - Mechanical Turk platform. -

- ); + // const assignment_id = useAppSelector((state) => state.app.assignment_id); + // if (!assignment_id) { + // return ( + //

+ // No Assignment ID found. Please access this page through the Amazon + // Mechanical Turk platform. + //

+ // ); + // } + + const { data: user, isLoading, isError } = useGetMeQuery(); + // const location = useLocation(); + + if (isLoading) return
Loading...
; + + if (isError || !user) { + return ; } return <>{entered ? : }; diff --git a/jb-ui/src/store.ts b/jb-ui/src/store.ts index 07cac9e..baf13b7 100644 --- a/jb-ui/src/store.ts +++ b/jb-ui/src/store.ts @@ -1,6 +1,8 @@ import appReducers from "@/models/appSlice"; import grlStatsReducers from "@/models/grlStatsSlice"; import grlEventsReducers from "@/models/grlEventsSlice"; +import { authApi } from "@/models/authAPI"; + import profilingQuestionsReducers from "@/models/profilingQuestionsSlice"; import type { Action, ThunkAction } from "@reduxjs/toolkit"; import { configureStore } from "@reduxjs/toolkit"; @@ -8,10 +10,13 @@ import { configureStore } from "@reduxjs/toolkit"; export const store = configureStore({ reducer: { app: appReducers, + // auth: authReducers, + [authApi.reducerPath]: authApi.reducer, stats: grlStatsReducers, events: grlEventsReducers, profilingQuestions: profilingQuestionsReducers, }, + middleware: (getDefault) => getDefault().concat(authApi.middleware), }); // Infer the type of `store` diff --git a/jb/views/auth.py b/jb/views/auth.py index bf39b51..ce9c1e0 100644 --- a/jb/views/auth.py +++ b/jb/views/auth.py @@ -2,7 +2,7 @@ from typing import Annotated from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, Response, status -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse from jb.api.auth import ( SESSION_COOKIE_NAME, @@ -119,7 +119,7 @@ def link_amt_account(body: AmtAccountLink) -> dict[str, str]: return {} -@auth_router.get("/link-amt/", response_class=HTMLResponse, include_in_schema=False) +@auth_router.get("/debug/", response_class=HTMLResponse, include_in_schema=False) def link_amt_account_landing_page( gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], token: str | None = None, @@ -129,13 +129,15 @@ def link_amt_account_landing_page( # TODO! Try catch any of this, and if it fails, show the user a # TODO! failed HTML page. As of now, it shows them a failed JSON response. - if token is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="token is required", - ) - _response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - _exchange_amt_account_link(token=token, response=_response, gr_api=gr_api) + if settings.debug: + if token is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="token is required", + ) + + _response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) + _exchange_amt_account_link(token=token, response=_response, gr_api=gr_api) return HTMLResponse( BASE_HTML, -- cgit v1.2.3 From f82ac61b6a044467ab37c0645c3fe99b40bedfc5 Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Fri, 11 Sep 2026 23:06:59 -0700 Subject: replace references to bpuid with logged in user --- jb-ui/src/JBApp.tsx | 14 +++----- jb-ui/src/components/Footer.tsx | 7 ++-- jb-ui/src/components/Profiling.tsx | 38 ++++++++++++-------- jb-ui/src/components/Wallet.tsx | 19 +++++----- jb-ui/src/models/authAPI.ts | 3 +- jb-ui/src/pages/Preview.tsx | 3 +- jb-ui/src/pages/Transfer.tsx | 9 ++--- jb-ui/src/pages/Work.tsx | 71 +++++++++----------------------------- jb-ui/src/types/authApi.ts | 6 ++++ 9 files changed, 73 insertions(+), 97 deletions(-) create mode 100644 jb-ui/src/types/authApi.ts (limited to 'jb-ui/src/models') diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx index fd0eeba..a69cc85 100644 --- a/jb-ui/src/JBApp.tsx +++ b/jb-ui/src/JBApp.tsx @@ -16,6 +16,7 @@ import { import Footer from "@/components/Footer"; import { useAppDispatch } from "@/hooks"; import { bpid, routeBasename, tagManager } from "@/lib/utils"; +import { useGetMeQuery } from "@/models/authAPI"; import { setAssignmentID, setProductUserID, @@ -72,24 +73,19 @@ function isPingMessage(msg: Message): msg is PingMessage { function QueryParamProcessor() { const dispatch = useAppDispatch(); const [searchParams] = useSearchParams(); + const { data: user, isLoading, isError } = useGetMeQuery(); useEffect(() => { - const worker_id = searchParams.get("workerId"); const assignment_id = searchParams.get("assignmentId"); const tsid = searchParams.get("tsid"); - const turkSubmitTo = searchParams.get("turkSubmitTo"); - if (turkSubmitTo) { - dispatch(setTurkSubmitTo(turkSubmitTo)); - } - - if (worker_id) { - dispatch(setProductUserID(worker_id)); + if (user?.product_user_id) { + dispatch(setProductUserID(user.product_user_id)); new WalletApi() .getUserWalletBalanceProductIdWalletGet( bpid, // productId - worker_id, // bpuid + user.product_user_id, ) .then((res) => { const response = res.data as UserWalletBalanceResponse; diff --git a/jb-ui/src/components/Footer.tsx b/jb-ui/src/components/Footer.tsx index eede06c..ec458f2 100644 --- a/jb-ui/src/components/Footer.tsx +++ b/jb-ui/src/components/Footer.tsx @@ -1,8 +1,7 @@ -import { useAppSelector } from "@/hooks"; +import { useGetMeQuery } from "@/models/authAPI"; const Footer = () => { - const bpuid = useAppSelector((state) => state.app.bpuid); - const assignment_id = useAppSelector((state) => state.app.assignment_id); + const { data: user, isLoading, isError } = useGetMeQuery(); return (
{

help:{" "} support@jamesbillings67.com diff --git a/jb-ui/src/components/Profiling.tsx b/jb-ui/src/components/Profiling.tsx index 96b06b2..23b8457 100644 --- a/jb-ui/src/components/Profiling.tsx +++ b/jb-ui/src/components/Profiling.tsx @@ -1,4 +1,5 @@ import { UpkQuestionChoice } from "@/api_fsb"; +import { useGetMeQuery } from "@/models/authAPI"; import { ChevronRightIcon } from "lucide-react"; import { KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react"; @@ -46,7 +47,7 @@ const TextEntry = () => { // const selectAnswer = useSelector(selectAnswerForQuestion(question)); const metadata = activeQuestion?._metadata; const validation = activeQuestion?._validation; - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); if (!metadata) return null; if (!validation) return null; @@ -67,11 +68,14 @@ const TextEntry = () => { const handleKeyPress = (event: KeyboardEvent) => { if (!activeQuestion.question_id) return; - if (!bpuid) return; + if (!user?.product_user_id) return; if (event.key === "Enter") { dispatch( - saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }), + saveAnswer({ + questionId: activeQuestion.question_id!, + bpuid: user.product_user_id, + }), ); } }; @@ -109,14 +113,14 @@ export default function useKeyboardMode(rowModel: RowModel) { const dispatch = useAppDispatch(); const activeQuestion = useSelector(selectActiveQuestion); - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); const keyboardSelectionIdxRef = useRef(0); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (!activeQuestion) return; - if (!bpuid) return; + if (!user?.product_user_id) return; // if (!keyboardEnabled) { // return; @@ -141,7 +145,10 @@ export default function useKeyboardMode(rowModel: RowModel) { if (event.key === "Enter") { dispatch( - saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }), + saveAnswer({ + questionId: activeQuestion.question_id!, + bpuid: user.product_user_id, + }), ); } @@ -507,15 +514,18 @@ const ProfilingQuestionSubmitButton = () => { const activeQuestion = useSelector(selectActiveQuestion); const activeQuestionValidation = useSelector(selectActiveQuestionValidation); - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); const handleOnClick = () => { if (!activeQuestion) return; if (!activeQuestionValidation?.isComplete) return; - if (!bpuid) return; + if (!user?.product_user_id) return; dispatch( - saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }), + saveAnswer({ + questionId: activeQuestion.question_id!, + bpuid: user.product_user_id, + }), ); }; @@ -749,14 +759,14 @@ const Profiling = () => { const [showProfiling, setShowProfiling] = useState(false); const dispatch = useAppDispatch(); - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); useEffect(() => { - if (!bpuid) return; - dispatch(fetchNewQuestions(bpuid)); - }, [bpid, bpuid]); + if (!user?.product_user_id) return; + dispatch(fetchNewQuestions(user.product_user_id)); + }, [bpid, user?.product_user_id]); - if (!bpuid) return null; + if (!user?.product_user_id) return null; return (

{ const WalletTransactions = () => { const dispatch = useAppDispatch(); - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); const userLedgerTxs = useAppSelector((state) => state.app.userLedgerTxs); const txPagination = useAppSelector((state) => state.app.txPagination); const txTotalPages = useAppSelector((state) => state.app.txTotalPages); useEffect(() => { - if (!bpuid) return; + if (!user?.product_user_id) return; new WalletApi() .getUserTransactionHistoryProductIdTransactionHistoryGet( bpid, // productId - bpuid, // bpuid + user.product_user_id, undefined, // createdAfter undefined, // createdBefore "-created", // orderBy @@ -215,7 +216,7 @@ const WalletTransactions = () => { dispatch(setTxTotalItems(response.total!)); dispatch(setTxTotalPages(response.pages!)); }); - }, [bpuid, txPagination.pageIndex, txPagination.pageSize]); + }, [user?.product_user_id, txPagination.pageIndex, txPagination.pageSize]); const columnHelper = createColumnHelper(); @@ -438,16 +439,16 @@ const WalletTransactions = () => { const Wallet = () => { const dispatch = useAppDispatch(); - const bpuid = useAppSelector((state) => state.app.bpuid); const pagination = useAppSelector((state) => state.app.txPagination); + const { data: user, isLoading, isError } = useGetMeQuery(); useEffect(() => { - if (!bpuid) return; + if (!user?.product_user_id) return; new WalletApi() .getUserTransactionHistoryProductIdTransactionHistoryGet( bpid, // productId - bpuid, // bpuid + user.product_user_id, undefined, // createdAfter undefined, // createdBefore "-created", // orderBy @@ -468,9 +469,9 @@ const Wallet = () => { dispatch(setTxTotalItems(response.total ?? 0)); dispatch(setTxTotalPages(response.pages ?? 9)); }); - }, [bpuid]); + }, [user?.product_user_id]); - if (!bpuid) return null; + if (!user?.product_user_id) return null; return (
({ - getMe: builder.query({ + getMe: builder.query({ query: () => "/session", providesTags: ["User"], }), diff --git a/jb-ui/src/pages/Preview.tsx b/jb-ui/src/pages/Preview.tsx index 6e50b0d..1926975 100644 --- a/jb-ui/src/pages/Preview.tsx +++ b/jb-ui/src/pages/Preview.tsx @@ -95,7 +95,6 @@ export const Leaderboards = () => { const [payouts, setPayouts] = useState(null); const [completes, setCompletes] = useState(null); - // const bpuid = useAppSelector(state => state.app.bpuid) const bpuid = undefined; useEffect(() => { @@ -105,7 +104,7 @@ export const Leaderboards = () => { LeaderboardCode.CompleteCount, frequency, "us", - bpuid, // bpuid + bpuid, undefined, // within_time 10, ) diff --git a/jb-ui/src/pages/Transfer.tsx b/jb-ui/src/pages/Transfer.tsx index bef40d4..4192eee 100644 --- a/jb-ui/src/pages/Transfer.tsx +++ b/jb-ui/src/pages/Transfer.tsx @@ -1,5 +1,6 @@ import { type FormEvent, useState } from "react"; import { useAppDispatch, useAppSelector } from "@/hooks"; +import { useGetMeQuery } from "@/models/authAPI"; import { Button } from "@/components/ui/button"; import max_lulu from "./max-and-lulu.jpg"; @@ -43,7 +44,7 @@ type RequestState = "idle" | "working" | "sent" | "failed"; const TransferSubmit = function ({ onSubmitSuccess }) { const [email, setEmail] = useState(""); const [state, setState] = useState("idle"); - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); const submit = async (event: FormEvent) => { event.preventDefault(); @@ -56,7 +57,7 @@ const TransferSubmit = function ({ onSubmitSuccess }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: email, - amt_worker_id: bpuid, + amt_worker_id: user?.product_user_id, }), }); @@ -132,10 +133,10 @@ const TransferSubmit = function ({ onSubmitSuccess }) { const Transfer = function () { const assignment_id = useAppSelector((state) => state.app.assignment_id); - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); const [submitted, setSubmitted] = useState(false); - if (!bpuid) { + if (!user?.product_user_id) { return (

No Worker ID found. Please access this page through the Amazon diff --git a/jb-ui/src/pages/Work.tsx b/jb-ui/src/pages/Work.tsx index e33328e..84cb946 100644 --- a/jb-ui/src/pages/Work.tsx +++ b/jb-ui/src/pages/Work.tsx @@ -51,8 +51,7 @@ import Markdown from "react-markdown"; const Report = ({ onClose }: { onClose: () => void }) => { const [showComplete, setShowComplete] = useState(false); const [selectedValues, setSelectedValues] = useState([]); - - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); const ts = useAppSelector((state) => state.app.taskStatus); const handleCheckboxChange = (value: ReportValue, checked: boolean) => { @@ -62,12 +61,12 @@ const Report = ({ onClose }: { onClose: () => void }) => { }; const clicked_submit = () => { - if (!bpuid) return; + if (!user?.product_user_id) return; // ReportValue.NUMBER_0 console.log("selectedValues:", selectedValues); const rt: ReportTask = { - bpuid: bpuid, + bpuid: user.product_user_id, reasons: selectedValues, notes: "", }; @@ -122,9 +121,10 @@ const Report = ({ onClose }: { onClose: () => void }) => { return

Please finish a Task to report its status.

; } - if (showComplete) { - return ; - } + // TODO! What do we do here, redirect back to / with a thank you + wallet refresh? + // if (showComplete) { + // return ; + // } return (
@@ -189,43 +189,6 @@ const Report = ({ onClose }: { onClose: () => void }) => { ); }; -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(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 ( - <> -
- - - - - -
- - ); -}; - const SurveyMonitor = () => { const dispatch = useAppDispatch(); @@ -235,7 +198,7 @@ const SurveyMonitor = () => { const [showComplete, setShowComplete] = useState(false); const [showReport, setShowReport] = useState(false); - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); const entered = useAppSelector((state) => state.app.currentBucketEntered); const isLow = useAppSelector((state) => isLowBalance(state)); @@ -263,7 +226,7 @@ const SurveyMonitor = () => { // }, [ts]); useEffect(() => { - if (!bpuid) return; + if (!user?.product_user_id) return; if (!entered) return; if (!isPolling) return; @@ -271,7 +234,7 @@ const SurveyMonitor = () => { new StatusApi() .listTaskStatusesProductIdStatusGet( bpid, // productId - bpuid, // Worker ID + user.product_user_id, // Worker ID entered, // startedAfter moment.utc().unix(), // startedBefore ) @@ -418,8 +381,9 @@ const SurveyMonitor = () => { Report Survey
- - {showComplete && } + {/* TODO! What do we do here, redirect back to / with wallet */} + {/* TODO! refresh? The confetti is on a different page. */} + {/* {showComplete && } */} {showReport && setShowReport(false)} />} ) : ( @@ -731,7 +695,7 @@ const BucketDetails = () => { const SurveyEnter = () => { const dispatch = useAppDispatch(); const loi = useAppSelector((state) => state.app.loi); - const bpuid = useAppSelector((state) => state.app.bpuid); + const { data: user, isLoading, isError } = useGetMeQuery(); const available_cnt = useAppSelector((state) => getAvailabilityCount(state)); const buckets = useAppSelector((state) => state.app.currentBuckets); @@ -739,12 +703,12 @@ const SurveyEnter = () => { const isLow = useAppSelector((state) => isLowBalance(state)); useEffect(() => { - if (!bpuid) return; + if (!user?.product_user_id) return; new OfferwallApi() .topNPlusBlockOfferwallProductIdOfferwallD48cce47Get( bpid, // productId - bpuid, // bpuid + user.product_user_id, undefined, // ip undefined, // countryIso undefined, // languages @@ -766,7 +730,7 @@ const SurveyEnter = () => { dispatch(setCurrentBuckets(top_n_res.buckets)); dispatch(setOfferwallReasons(top_n_res.offerwall_reasons ?? [])); }); - }, [loi, bpuid]); + }, [loi, user?.product_user_id]); const start_survey = () => { if (!bucket?.uri) return; @@ -834,7 +798,6 @@ type RequestState = "idle" | "working" | "sent" | "failed"; const Login = () => { const [email, setEmail] = useState(""); const [state, setState] = useState("idle"); - const bpuid = useAppSelector((state) => state.app.bpuid); // const dispatch = useAppDispatch(); const submit = async (event: FormEvent) => { diff --git a/jb-ui/src/types/authApi.ts b/jb-ui/src/types/authApi.ts new file mode 100644 index 0000000..19aeee0 --- /dev/null +++ b/jb-ui/src/types/authApi.ts @@ -0,0 +1,6 @@ +export interface User { + email: string; + display_name: string | null; + blocked: boolean; + product_user_id: string; +} -- cgit v1.2.3 From 83821dff9984fb950e53f23baf99d7a74fb88abd Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Sun, 13 Sep 2026 10:41:49 -0700 Subject: refrences to HIT removed. Still can't test. Basic CashoutMethod retrival until backup updated. use of the new Wallets endpoint. --- jb-ui/src/JBApp.tsx | 69 +++++++++++++++++++++++++++------ jb-ui/src/components/Redemption.tsx | 67 ++++++++++++++++++++++++++++++++ jb-ui/src/components/Wallet.tsx | 20 ++++++---- jb-ui/src/models/app.ts | 8 ++++ jb-ui/src/models/appSlice.ts | 30 ++++++++++++++ jb-ui/src/models/cashoutMethodsSlice.ts | 37 ++++++++++++++++++ jb-ui/src/pages/Preview.tsx | 8 ++-- jb-ui/src/pages/Result.tsx | 18 +++------ jb-ui/src/pages/Work.tsx | 33 +++++++--------- jb-ui/src/store.ts | 2 + 10 files changed, 239 insertions(+), 53 deletions(-) create mode 100644 jb-ui/src/components/Redemption.tsx create mode 100644 jb-ui/src/models/cashoutMethodsSlice.ts (limited to 'jb-ui/src/models') diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx index 6c31164..4870e9b 100644 --- a/jb-ui/src/JBApp.tsx +++ b/jb-ui/src/JBApp.tsx @@ -11,7 +11,11 @@ import { TaskStatusResponse, UserWalletBalance, UserWalletBalanceResponse, + UserLedgerWallets, + UserLedgerWalletAccountTypeEnum, + UserDisplayedWalletBalance, WalletApi, + CashoutMethodsResponse, } from "@/api_fsb"; import Footer from "@/components/Footer"; import { useAppDispatch } from "@/hooks"; @@ -20,8 +24,13 @@ import { useGetMeQuery } from "@/models/authAPI"; import { setAssignmentID, setProductUserID, - setTurkSubmitTo, + setUserDisplayedWalletBalance, + setUserWallet, + setUserAttemptCreditWallet, } from "@/models/appSlice"; + +import { addCashoutMethods } from "@/models/cashoutMethodsSlice"; + import { addEvent } from "@/models/grlEventsSlice"; import { addStatsData } from "@/models/grlStatsSlice"; import Home from "@/pages/Home"; @@ -50,6 +59,7 @@ import Wallet from "@/components/Wallet"; import "@/index.css"; import { setTaskStatus, setUserWalletBalance } from "@/models/appSlice"; import { Profiling } from "./components/Profiling"; +import Redemption from "./components/Redemption"; type Message = | PingMessage @@ -76,17 +86,16 @@ function QueryParamProcessor() { const { data: user, isLoading, isError } = useGetMeQuery(); useEffect(() => { - const assignment_id = searchParams.get("assignmentId"); - const tsid = searchParams.get("tsid"); - const worker_id = searchParams.get("workerId"); - - if (worker_id) { - dispatch(setProductUserID(worker_id)); - } - if (user?.product_user_id) { dispatch(setProductUserID(user.product_user_id)); + new WalletApi() + .getCashoutMethodsProductIdCashoutMethodsGet(bpid, user.product_user_id) + .then((res) => { + const response = res.data as CashoutMethodsResponse; + dispatch(addCashoutMethods(response.cashout_methods)); + }); + new WalletApi() .getUserWalletBalanceProductIdWalletGet( bpid, // productId @@ -97,6 +106,43 @@ function QueryParamProcessor() { const balance = response.wallet as UserWalletBalance; dispatch(setUserWalletBalance(balance)); }); + + new WalletApi() + .getUserWalletsProductIdWalletsGet(bpid, user.product_user_id) + .then((res) => { + const response = res.data as UserLedgerWallets; + + if (response.displayed_balances) { + setUserDisplayedWalletBalance(response.displayed_balances[0]); + } + + const userWallet = response.wallets?.find( + (w) => + w.account_type === UserLedgerWalletAccountTypeEnum.UserWallet, + ); + if (userWallet) { + setUserWallet(userWallet); + } + + const userAttemptCreditWallet = response.wallets?.find( + (w) => + w.account_type === + UserLedgerWalletAccountTypeEnum.UserAttemptCredit, + ); + if (userAttemptCreditWallet) { + setUserAttemptCreditWallet(userAttemptCreditWallet); + } + }); + } + }, [user]); + + useEffect(() => { + const assignment_id = searchParams.get("assignmentId"); + const tsid = searchParams.get("tsid"); + const worker_id = searchParams.get("workerId"); + + if (worker_id) { + dispatch(setProductUserID(worker_id)); } if (assignment_id) { @@ -125,8 +171,8 @@ function QueryParamProcessor() { const Layout = () => { const location = useLocation(); - const isWork = useMatch("/work*"); - const isPreview = useMatch("/preview*"); + const isWork = useMatch("/work/*"); + const isPreview = useMatch("/preview/*"); const isSpecialSubview = Boolean(isWork || isPreview); return ( @@ -152,6 +198,7 @@ const Layout = () => { > +
diff --git a/jb-ui/src/components/Redemption.tsx b/jb-ui/src/components/Redemption.tsx new file mode 100644 index 0000000..0a91ab3 --- /dev/null +++ b/jb-ui/src/components/Redemption.tsx @@ -0,0 +1,67 @@ +import { clsx } from "clsx"; +import { useAppDispatch, useAppSelector } from "@/hooks"; +import { UpkQuestionChoice } from "@/api_fsb"; +import { useGetMeQuery } from "@/models/authAPI"; +import { ChevronRightIcon } from "lucide-react"; +import { KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react"; +import { useSelector } from "react-redux"; +import { cashoutMethodsSelectors } from "@/models/cashoutMethodsSlice"; + +const RedemptionHeader = () => { + const cashout_methods = useSelector(cashoutMethodsSelectors.selectAll); + + return ( + <> +

+ {/* {cashout_methods.length} Redemption Methods */} + 30+ Redemption Methods +

+ + ); +}; + +const RedemptionModalBody = () => { + return ( +
+
+ Paypal, Visa, Giftcards will be available. Finishing processing the + transfer from Amazon. +
+
+ ); +}; + +const Redemption = () => { + const dispatch = useAppDispatch(); + const { data: user, isLoading, isError } = useGetMeQuery(); + const [showRedemption, setShowRedemption] = useState(false); + + return ( +
+
setShowRedemption(!showRedemption)} + > + + +

+ {/* (Click to {showRedemption ? "hide" : "show"}) */} + Details within 24hr. +

+
+ + {showRedemption && } +
+ ); +}; + +export default Redemption; diff --git a/jb-ui/src/components/Wallet.tsx b/jb-ui/src/components/Wallet.tsx index 3f5c6a4..3bb23a9 100644 --- a/jb-ui/src/components/Wallet.tsx +++ b/jb-ui/src/components/Wallet.tsx @@ -54,7 +54,13 @@ const getBackgroundColor = (balance: number | undefined): string => { const WalletHeader = () => { const amount = - useAppSelector((state) => state.app.userWalletBalance)?.amount ?? 0; + useAppSelector((state) => state.app.userDisplayedWalletBalance?.amount) ?? + 0; + + const amount_string = + useAppSelector( + (state) => state.app.userDisplayedWalletBalance?.amount_string, + ) ?? "$0.00"; const bgStyle = typeof getBackgroundColor(amount) === "string" && @@ -77,13 +83,9 @@ const WalletHeader = () => { User Wallet: {amount >= 0 ? ( - - {`${formatCentsToUSD(Math.abs(amount))}`} - + {`${amount_string}`} ) : ( - - {`(${formatCentsToUSD(Math.abs(amount))})`} - + {`(${amount_string})`} )}
@@ -93,6 +95,10 @@ const WalletHeader = () => { const WalletSummary = () => { const summary = useAppSelector((state) => state.app.userLedgerSummary); + const userWallet = useAppSelector((state) => state.app.userWallet); + + userWallet; + const survey_completes = summary?.bp_payment?.entry_count ?? 0; const survey_completes_f = (survey_completes ?? 0).toLocaleString("en-US"); diff --git a/jb-ui/src/models/app.ts b/jb-ui/src/models/app.ts index 4c3e69e..db54e0d 100644 --- a/jb-ui/src/models/app.ts +++ b/jb-ui/src/models/app.ts @@ -5,6 +5,8 @@ import { UserLedgerTransactionTypesSummary, UserLedgerTransactionsResponseTransactionsInner, OfferwallReason, + UserLedgerWallet, + UserDisplayedWalletBalance, } from "@/api_fsb"; import { PaginationState } from "@tanstack/react-table"; @@ -33,6 +35,12 @@ export interface App { userLedgerTxCount?: number; userLedgerTxs: UserLedgerTransactionsResponseTransactionsInner[]; + // --- new approach + userDisplayedWalletBalance?: UserDisplayedWalletBalance; + userWallet?: UserLedgerWallet; + userAttemptCreditWallet?: UserLedgerWallet; + // --- + txPagination: PaginationState; txTotalItems?: number; txTotalPages?: number; diff --git a/jb-ui/src/models/appSlice.ts b/jb-ui/src/models/appSlice.ts index 46c6a2f..42a776e 100644 --- a/jb-ui/src/models/appSlice.ts +++ b/jb-ui/src/models/appSlice.ts @@ -4,6 +4,8 @@ import { UserLedgerTransactionsResponseTransactionsInner, UserLedgerTransactionTypesSummary, UserWalletBalance, + UserDisplayedWalletBalance, + UserLedgerWallet, OfferwallReason, } from "@/api_fsb"; import { App } from "@/models/app"; @@ -26,6 +28,12 @@ const initialState: App = { userLedgerTxCount: undefined, userLedgerTxs: [], + // --- new approach + userDisplayedWalletBalance: undefined, + userWallet: undefined, + userAttemptCreditWallet: undefined, + // --- + txPagination: { pageIndex: 0, pageSize: 10 }, txTotalItems: undefined, txTotalPages: undefined, @@ -91,6 +99,24 @@ const appSlice = createSlice({ state.userWalletBalance = action.payload; }, + // --- New approach --- + setUserDisplayedWalletBalance( + state, + action: PayloadAction, + ) { + state.userDisplayedWalletBalance = action.payload; + }, + + setUserWallet(state, action: PayloadAction) { + state.userWallet = action.payload; + }, + + setUserAttemptCreditWallet(state, action: PayloadAction) { + state.userAttemptCreditWallet = action.payload; + }, + + // --- --- + setUserLedgerSummary( state, action: PayloadAction, @@ -138,6 +164,10 @@ export const { setUserLedgerSummary, setUserLedgerTxs, + setUserDisplayedWalletBalance, + setUserWallet, + setUserAttemptCreditWallet, + setTxPagination, setTxTotalItems, setTxTotalPages, diff --git a/jb-ui/src/models/cashoutMethodsSlice.ts b/jb-ui/src/models/cashoutMethodsSlice.ts new file mode 100644 index 0000000..92c03da --- /dev/null +++ b/jb-ui/src/models/cashoutMethodsSlice.ts @@ -0,0 +1,37 @@ +import { CashoutMethodOut } from "@/api_fsb"; +import type { RootState } from "@/store"; +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +// Entity adapter for normalized storage +const cashoutMethodsAdapter = createEntityAdapter({ + selectId: (model: CashoutMethodOut) => model.id!, + sortComparer: (a, b) => { + return (b.min_value ?? 0) - (a.min_value ?? 0); + }, +}); + +const cashoutMethodsSlice = createSlice({ + name: "cashoutMethods", + initialState: cashoutMethodsAdapter.getInitialState(), + + reducers: { + addCashoutMethod: cashoutMethodsAdapter.addOne, + addCashoutMethods: cashoutMethodsAdapter.addMany, + upsertCashoutMethod: cashoutMethodsAdapter.upsertOne, // Add or Update + }, +}); + +export const { addCashoutMethod, addCashoutMethods, upsertCashoutMethod } = + cashoutMethodsSlice.actions; + +export const cashoutMethodsSelectors = cashoutMethodsAdapter.getSelectors( + (state: RootState) => state.cashoutMethods, +); + +export const selectAllCashoutMethods = ( + state: RootState, +): CashoutMethodOut[] => { + return cashoutMethodsSelectors.selectAll(state) ?? []; +}; + +export default cashoutMethodsSlice.reducer; diff --git a/jb-ui/src/pages/Preview.tsx b/jb-ui/src/pages/Preview.tsx index 85e49f2..900621e 100644 --- a/jb-ui/src/pages/Preview.tsx +++ b/jb-ui/src/pages/Preview.tsx @@ -378,10 +378,10 @@ const Preview = () => { 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. + 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.

diff --git a/jb-ui/src/pages/Result.tsx b/jb-ui/src/pages/Result.tsx index 477f77e..6385bc7 100644 --- a/jb-ui/src/pages/Result.tsx +++ b/jb-ui/src/pages/Result.tsx @@ -21,9 +21,7 @@ export const Complete = () => {

{ts?.user_payout_string}

-

- You may close this tab and return to submit the HIT -

+

You may close this tab

); }; @@ -35,9 +33,7 @@ export const Enter = () => {

You're currently in the Survey

-

- You may close this tab and return to submit the HIT -

+

You may close this tab

); }; @@ -50,13 +46,11 @@ export const InComplete = () => { return ( <> -

HIT Complete

+

Attempt Complete

Survey terminated the attempt.

-

- You may close this tab and return to submit the HIT -

+

You may close this tab

); }; @@ -68,9 +62,7 @@ export const Unknown = () => {

You're not currently eligible for any Surveys

-

- You may close this tab and return to submit the HIT -

+

You may close this tab

); }; diff --git a/jb-ui/src/pages/Work.tsx b/jb-ui/src/pages/Work.tsx index dbe3601..50ed930 100644 --- a/jb-ui/src/pages/Work.tsx +++ b/jb-ui/src/pages/Work.tsx @@ -1,4 +1,5 @@ import { useGetMeQuery } from "@/models/authAPI"; +import { Link } from "react-router-dom"; import { BucketTask, OfferwallApi, @@ -183,7 +184,7 @@ const Report = ({ onClose }: { onClose: () => void }) => { hover:cursor-pointer hover:bg-rose-400 hover:text-rose-50" onClick={clicked_submit} > - Submit and Complete HIT + Submit and Complete Attempt @@ -306,7 +307,7 @@ const SurveyMonitor = () => { } // 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. + // COMPLETE or a INCOMPLETE (doesn't matter). They can now submit the Attempt. // // 2 - INCOMPLETE, 3 - COMPLETE return ( @@ -326,7 +327,7 @@ const SurveyMonitor = () => { py-2 text-zinc-500 text-md font-medium text-center" > - You may now submit the HIT. + You may now submit the attempt.

)} @@ -351,7 +352,9 @@ const SurveyMonitor = () => { pt-0 pb-4 text-red-950 text-sm font-semibold text-center" > - Please return the HIT to avoid having your assignment rejected. + + Click to Refresh and Try Again +

@@ -368,7 +371,7 @@ const SurveyMonitor = () => { hover:cursor-pointer hover:bg-emerald-400 hover:text-emerald-50" onClick={() => setShowComplete(true)} > - Submit HIT + Finish Attempt