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
+
+
+
+
+ {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
{
className="
text-red-600 text-lg font-semibold text-center"
>
- Your balance is low. You cannot submit the HIT at this time.
-
-
-
- Please return the HIT to avoid having your assignment rejected.
+ Your balance is low.
)}
@@ -709,6 +704,8 @@ const SurveyEnter = () => {
new OfferwallApi()
.topNPlusBlockOfferwallProductIdOfferwallD48cce47Get(
bpid, // productId
+ // "7d9240132c695c05cd0ba47e2580158719f8215b",
+ // "71.225.51.26",
user.product_user_id,
undefined, // ip
undefined, // countryIso
@@ -760,12 +757,12 @@ const SurveyEnter = () => {
Your balance is low.
- 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.
+ You may continue to attempt surveys, but you will not earn an
+ attempt credit if your survey attempt does not result in a complete.
- HIT submissions with a balance exceeding -$1.00 will return in
- rejected assignments.
+ Attempts with a balance exceeding -$1.00 will not be issued an
+ attempt credit.
)}
diff --git a/jb-ui/src/store.ts b/jb-ui/src/store.ts
index baf13b7..081de9f 100644
--- a/jb-ui/src/store.ts
+++ b/jb-ui/src/store.ts
@@ -4,6 +4,7 @@ import grlEventsReducers from "@/models/grlEventsSlice";
import { authApi } from "@/models/authAPI";
import profilingQuestionsReducers from "@/models/profilingQuestionsSlice";
+import cashoutMethodReducers from "@/models/cashoutMethodsSlice";
import type { Action, ThunkAction } from "@reduxjs/toolkit";
import { configureStore } from "@reduxjs/toolkit";
@@ -15,6 +16,7 @@ export const store = configureStore({
stats: grlStatsReducers,
events: grlEventsReducers,
profilingQuestions: profilingQuestionsReducers,
+ cashoutMethods: cashoutMethodReducers,
},
middleware: (getDefault) => getDefault().concat(authApi.middleware),
});
--
cgit v1.2.3