From 696dee6a6a9506fcf771d0ec4911dcc82a279fda Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Fri, 6 Jun 2025 16:32:17 +0700 Subject: Lots of reducer work to organize active Question in redux state (rather than useState). Various UX/CSS checks for Pagination state. --- src/Widget.tsx | 2 +- src/components/site-header.tsx | 9 +++- src/counterSlice.ts | 44 ------------------ src/lib/utils.ts | 12 +++-- src/models/answerSlice.ts | 48 +++++++++++++++----- src/models/appSlice.ts | 6 ++- src/models/questionSlice.ts | 72 +++++++++++++++++++++++++++-- src/pages/Questions.tsx | 101 ++++++++++++++++++++++++++++------------- src/types.ts | 74 ------------------------------ 9 files changed, 195 insertions(+), 173 deletions(-) delete mode 100644 src/counterSlice.ts delete mode 100644 src/types.ts (limited to 'src') diff --git a/src/Widget.tsx b/src/Widget.tsx index c3b1f8f..3c7f613 100644 --- a/src/Widget.tsx +++ b/src/Widget.tsx @@ -27,7 +27,7 @@ const Widget = () => { }) .catch(err => console.log(err)); - new ProfilingQuestionsApi().getProfilingQuestionsProductIdProfilingQuestionsGet(app.bpid, app.bpuid, "104.9.125.144") + new ProfilingQuestionsApi().getProfilingQuestionsProductIdProfilingQuestionsGet(app.bpid, app.bpuid, "104.9.125.144", undefined, undefined, 1000 ) .then(res => { dispatch(setQuestions(res.data.questions)) }) diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 0000e5f..f4c42ec 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -1,8 +1,11 @@ import {Separator} from "@/components/ui/separator" import {SidebarTrigger} from "@/components/ui/sidebar" import React from "react"; +import {useAppSelector} from "@/hooks.ts"; +import {Offerwall} from "@/pages/Offerwall.tsx"; const SiteHeader = () => { + const app = useAppSelector(state => state.app) return (
{ orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" /> -

Offerwall

+

+ {app.currentPage === 'offerwall' && "Offerwall"} + {app.currentPage === 'questions' && "Profiling Questions"} + {app.currentPage === 'cashouts' && "Cashout Methods"} +

diff --git a/src/counterSlice.ts b/src/counterSlice.ts deleted file mode 100644 index 1482de1..0000000 --- a/src/counterSlice.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' -import type { PayloadAction } from '@reduxjs/toolkit' - -// Define the TS type for the counter slice's state -export interface CounterState { - value: number - status: 'idle' | 'loading' | 'failed' -} - -// Define the initial value for the slice state -const initialState: CounterState = { - value: 0, - status: 'idle' -} - -// Slices contain Redux reducer logic for updating state, and -// generate actions that can be dispatched to trigger those updates. -export const counterSlice = createSlice({ - name: 'counter', - initialState, - // The `reducers` field lets us define reducers and generate associated actions - reducers: { - increment: state => { - // Redux Toolkit allows us to write "mutating" logic in reducers. It - // doesn't actually mutate the state because it uses the Immer library, - // which detects changes to a "draft state" and produces a brand new - // immutable state based off those changes - state.value += 1 - }, - decrement: state => { - state.value -= 1 - }, - // Use the PayloadAction type to declare the contents of `action.payload` - incrementByAmount: (state, action: PayloadAction) => { - state.value += action.payload - } - } -}) - -// Export the generated action creators for use in components -export const { increment, decrement, incrementByAmount } = counterSlice.actions - -// Export the slice reducer for use in the store configuration -export default counterSlice.reducer \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..8525468 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,12 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import {type ClassValue, clsx} from "clsx" +import {twMerge} from "tailwind-merge" export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)) } + +export function assert(condition: any, msg?: string): asserts condition { + if (!condition) { + throw new Error(msg); + } +} \ No newline at end of file diff --git a/src/models/answerSlice.ts b/src/models/answerSlice.ts index e0f9931..aada48c 100644 --- a/src/models/answerSlice.ts +++ b/src/models/answerSlice.ts @@ -2,7 +2,9 @@ import {createSelector, createSlice, PayloadAction} from '@reduxjs/toolkit' // import {Answer} from "@/models/answer.ts"; // import {stringify} from "querystring"; import {RootState} from '@/store'; // your root state type -import {PatternValidation, UpkQuestion} from "@/api"; +import {PatternValidation} from "@/api"; +import {assert} from "@/lib/utils.ts" +import {ProfileQuestion} from "@/models/questionSlice.ts" export interface Answer { values: string[]; @@ -23,7 +25,7 @@ const answerSlice = createSlice({ name: 'answers', initialState, reducers: { - addAnswer(state, action: PayloadAction<{ question: UpkQuestion, val: string }>) { + addAnswer(state, action: PayloadAction<{ question: ProfileQuestion, val: string }>) { /* If the question is MC, validate: - validate selector SA vs MA (1 selected vs >1 selected) - the answers match actual codes in the choices @@ -34,7 +36,7 @@ const answerSlice = createSlice({ - configuration.max_length - validation.patterns */ - let question: UpkQuestion = action.payload.question; + let question: ProfileQuestion = action.payload.question; let val: string = action.payload.val.trim(); let answer: Answer = state[question.question_id] ?? { values: [], @@ -140,31 +142,55 @@ const answerSlice = createSlice({ state[question.question_id] = answer }, - saveAnswer(state, action: PayloadAction<{ question: UpkQuestion }>) { - let question: UpkQuestion = action.payload.question; - let answer: Answer = state[question.question_id] + submitAnswer(state, action: PayloadAction<{ question: ProfileQuestion }>) { + const question: ProfileQuestion = action.payload.question; + const answer: Answer = state[question.question_id] + + assert(!answer.complete, "Can't submit completed Answer") + assert(!answer.processing, "Can't submit processing Answer") + assert(answer.error_msg.length == 0, "Can't submit Answer with error message") state[question.question_id] = { 'values': answer.values, - 'error_msg': "", - 'processing': false, + 'error_msg': answer.error_msg, + 'processing': true, 'complete': false } as Answer + }, + + saveAnswer(state, action: PayloadAction<{ question: ProfileQuestion }>) { + const question: ProfileQuestion = action.payload.question; + const answer: Answer = state[question.question_id] + + assert(!answer.complete, "Can't submit completed Answer") + assert(answer.processing, "Answer must be processing") + console.assert(answer.error_msg.length == 0, "Can't submit Answer with error message") + + state[question.question_id] = { + 'values': answer.values, + 'error_msg': answer.error_msg, + 'processing': false, + 'complete': true + } as Answer } } }) -export const {addAnswer, saveAnswer} = answerSlice.actions; +export const { + addAnswer, + saveAnswer, + submitAnswer +} = answerSlice.actions; export default answerSlice.reducer -export const answerForQuestion = (state: RootState, question: UpkQuestion) => state.answers[question.question_id] ?? { +export const answerForQuestion = (state: RootState, question: ProfileQuestion) => state.answers[question.question_id] ?? { values: [], error_msg: "", complete: false, processing: false } as Answer; -export const makeSelectChoicesByQuestion = (question: UpkQuestion) => +export const makeSelectChoicesByQuestion = (question: ProfileQuestion) => createSelector( (state: RootState) => state.answers, (answers) => { diff --git a/src/models/appSlice.ts b/src/models/appSlice.ts index d7e4a0b..3adf316 100644 --- a/src/models/appSlice.ts +++ b/src/models/appSlice.ts @@ -13,11 +13,13 @@ const appSlice = createSlice({ return action.payload; }, setPage(state, action: PayloadAction) { - console.log("setPage.state", state.currentPage, action.payload) state.currentPage = action.payload; } } }) -export const {setApp, setPage} = appSlice.actions; +export const { + setApp, + setPage +} = appSlice.actions; export default appSlice.reducer \ No newline at end of file diff --git a/src/models/questionSlice.ts b/src/models/questionSlice.ts index 814caf9..ec9f84a 100644 --- a/src/models/questionSlice.ts +++ b/src/models/questionSlice.ts @@ -1,26 +1,88 @@ import {createSelector, createSlice, PayloadAction} from '@reduxjs/toolkit' import type {RootState} from '@/store' import {UpkQuestion} from "@/api"; +import {Answer} from "@/models/answerSlice.ts"; -const initialState: UpkQuestion[] = [] +export interface ProfileQuestion extends UpkQuestion { + active: false +} + +const initialState: ProfileQuestion[] = [] const questionSlice = createSlice({ name: 'questions', initialState, reducers: { - setQuestions(state, action: PayloadAction) { + setQuestions(state, action: PayloadAction) { return action.payload; }, - questionAdded(state, action: PayloadAction) { + questionAdded(state, action: PayloadAction) { state.push(action.payload); }, + setNextQuestion(state) { + const item = state.find((i) => i.active) + + const index = state.findIndex(q => q.question_id === item.question_id) + const nextQuestion = index !== -1 ? state[index + 1] ?? null : null + + state.forEach((q) => { + q.active = q.question_id === nextQuestion.question_id + }) + + }, + setQuestionActive(state, action: PayloadAction) { + state.forEach((q) => { + q.active = q.question_id === action.payload.question_id + }) + }, + updateQuestion(state, action: PayloadAction<{ question_id: string, updates: Partial }>) { + const item = state.find((i) => i.question_id === action.payload.question_id) + if (item) { + Object.assign(item, action.payload.updates) + } + } } }) -export const {setAnswer, setQuestions, questionAdded, questionUpdated} = questionSlice.actions; +export const { + setQuestions, + setQuestionActive, + setNextQuestion, + questionAdded, + updateQuestion +} = questionSlice.actions; export default questionSlice.reducer -// export const selectAllQuestions = (state: RootState) => state.questions +// We need to fetch the next available Question that either doesn't have an Answer, or the Answer +// isn't Answer.completed +export const selectQuestions = (state: RootState) => state.questions +export const selectActiveQuestion = (state: RootState) => state.questions.find(i => i.active) +export const selectAnswers = (state: RootState) => state.answers + +export const selectNextAvailableQuestion = createSelector( + [selectQuestions, selectAnswers], + (questions, answers) => { + + // -- Check if there are any questions marked as active + const active = questions.find(q => q.active) + if (active) { + return active + } + + let res = questions.filter(q => { + const a: Answer | undefined = answers[q.question_id] + return !a || a.complete === false + }) + + // return res.reduce((min, q) => + // !min || q.order < min.order ? q : min, + // null as typeof res[0] | null + // ) + + return res[0] || null + } +) + export const selectQuestionById = (questionId: string) => createSelector( diff --git a/src/pages/Questions.tsx b/src/pages/Questions.tsx index e9abe31..1056144 100644 --- a/src/pages/Questions.tsx +++ b/src/pages/Questions.tsx @@ -1,14 +1,13 @@ -import React, {useMemo, useState} from 'react' +import React, {useMemo} from 'react' import { BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, ProfilingQuestionsApiFactory, - UpkQuestion, UpkQuestionChoice, UserQuestionAnswerIn } from "@/api"; import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx"; import {useAppDispatch, useAppSelector} from "@/hooks.ts"; -import answerSlice, {addAnswer, Answer,answerForQuestion, makeSelectChoicesByQuestion, saveAnswer} from "@/models/answerSlice.ts"; +import {addAnswer, Answer, makeSelectChoicesByQuestion, saveAnswer, submitAnswer} from "@/models/answerSlice.ts"; import {useSelector} from "react-redux"; import {Button} from "@/components/ui/button" import { @@ -20,8 +19,17 @@ import { } from "@/components/ui/pagination" import {Input} from "@/components/ui/input" import {Badge} from "@/components/ui/badge" +import clsx from "clsx" +import { + ProfileQuestion, + selectNextAvailableQuestion, + selectQuestions, + setNextQuestion, + setQuestionActive +} from "@/models/questionSlice.ts"; +import {assert} from "@/lib/utils.ts"; -const TextEntry: React.FC<{ question: UpkQuestion }> = ({question}) => { +const TextEntry: React.FC<{ question: ProfileQuestion }> = ({question}) => { const dispatch = useAppDispatch() const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]); const answer: Answer = useSelector(selectAnswer); @@ -50,7 +58,7 @@ const TextEntry: React.FC<{ question: UpkQuestion }> = ({question}) => { ) } -const MultiChoiceItem: React.FC<{ question: UpkQuestion, choice: UpkQuestionChoice }> = ({question, choice}) => { +const MultiChoiceItem: React.FC<{ question: ProfileQuestion, choice: UpkQuestionChoice }> = ({question, choice}) => { const dispatch = useAppDispatch() const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]); const answer: Answer = useSelector(selectAnswer); @@ -69,7 +77,7 @@ const MultiChoiceItem: React.FC<{ question: UpkQuestion, choice: UpkQuestionChoi ) } -const MultipleChoice: React.FC<{ question: UpkQuestion }> = ({question}) => { +const MultipleChoice: React.FC<{ question: ProfileQuestion }> = ({question}) => { const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]); const answer: Answer = useSelector(selectAnswer); const error: Boolean = answer.error_msg.length > 0 @@ -95,8 +103,12 @@ const MultipleChoice: React.FC<{ question: UpkQuestion }> = ({question}) => { } -const ProfileQuestionFull: React.FC = ({question}) => { +const ProfileQuestionFull: React.FC<{ + question: ProfileQuestion, +}> = ({question}) => { + const dispatch = useAppDispatch() + const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]); const answer: Answer = useSelector(selectAnswer); const app = useAppSelector(state => state.app) @@ -114,14 +126,12 @@ const ProfileQuestionFull: React.FC = ({question}) => { } }; - const submitAnswer = () => { - if (!can_submit) { - return; - } + const submitAnswerEvt = () => { + dispatch(submitAnswer({question: question})) - if (answer.complete || answer.processing) { - return - } + assert(!answer.complete, "Can't submit completed Answer") + assert(!answer.processing, "Can't submit processing Answer") + assert(answer.error_msg.length == 0, "Can't submit Answer with error message") let body: BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost = { 'answers': [{ @@ -130,11 +140,11 @@ const ProfileQuestionFull: React.FC = ({question}) => { } as UserQuestionAnswerIn ] } - console.log("submitAnswers", body) new ProfilingQuestionsApiFactory().submitProfilingQuestionsProductIdProfilingQuestionsPost(app.bpid, app.bpuid, body) .then(res => { if (res.status == 200) { dispatch(saveAnswer({question: question})) + dispatch(setNextQuestion()) } else { // let error_msg = res.data.msg } @@ -163,7 +173,7 @@ const ProfileQuestionFull: React.FC = ({question}) => { type="submit" className="w-1/3 cursor-pointer" disabled={!can_submit} - onClick={submitAnswer} + onClick={submitAnswerEvt} > Submit @@ -171,27 +181,38 @@ const ProfileQuestionFull: React.FC = ({question}) => { ) } -// type Props = { -// onSetQuestionID: (name: string) => void -// } const PaginationIcon: React.FC<{ - question: UpkQuestion, activeQuestionID: string, idx: number, onSetQuestionID: () => void -}> = ({question, activeQuestionID, idx, onSetQuestionID}) => { + question: ProfileQuestion, idx: number, +}> = ({question, idx}) => { + const dispatch = useAppDispatch() const answers = useAppSelector(state => state.answers) - const answer = answers[question.question_id] + const completed: Boolean = Boolean(answers[question.question_id]?.complete) + + const setQuestion = (evt) => { + if (completed) { + evt.preventDefault() + } else { + dispatch(setQuestionActive(question)) + } + } return ( answer?.complete ? e.preventDefault() : onSetQuestionID(question.question_id)} - className={answer?.complete ? "pointer-events-none opacity-50 cursor-not-allowed" : ""}> + onClick={setQuestion} + className={clsx("cursor-pointer border border-gray-100", + { + "pointer-events-none opacity-50 cursor-not-allowed": completed, + "opacity-100 border-gray-200": question.active, + })} + > {idx + 1} @@ -199,10 +220,19 @@ const PaginationIcon: React.FC<{ } const QuestionsPage = () => { - const questions = useAppSelector(state => state.questions) + const dispatch = useAppDispatch() - const [activeQuestionID, setQuestionID] = useState(() => questions[0].question_id); - const question = questions.find(q => q.question_id === activeQuestionID); + const questions = useSelector(selectQuestions) + const question = useSelector(selectNextAvailableQuestion) + dispatch(setQuestionActive(question)) + + const clickNext = () => { + // TODO: if nextQuestion was already submitted, skip it! + + const index = questions.findIndex(q => q.question_id === question.question_id) + const nextQuestion = index !== -1 ? questions[index + 1] ?? null : null + dispatch(setQuestionActive(nextQuestion)) + } return (
@@ -214,20 +244,27 @@ const QuestionsPage = () => { { questions.slice(0, 5).map((q, i) => { - return + return }) } - +
) } diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index b6b46b9..0000000 --- a/src/types.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* ------ Generic ------ */ -export interface GRLWidgetSettings { - targetId: string, - bpid: string; - bpuid: string; - offerwall: string; - walletMode: boolean; - panelName: string | null; - leaderboard: boolean; -} - -/* ------ Buckets ------ */ - -/* ------ Profiling Questions ------ */ -export type QuestionType = "MC" | "TE" -export type SelectorType = "SL" | "ML" | "SA" | "MA" - -export interface PatternType { - message: string - pattern: string -} - -export interface ValidationType { - patterns: PatternType[] -} - -export interface ChoiceType { - order: number; // 0 index - choice_id: string; // string of a number - choice_text: string - - exclusive?: boolean - task_count?: number - task_score?: number -} - -export interface ConfigurationType { - max_length?: number - max_select?: number -} - -export interface ProfilingQuestionType { - $comment?: string - - choices: ChoiceType[] - validation: ValidationType - configuration: ConfigurationType - - country_iso: string - language_iso: string - - question_id: string - question_text: string - question_type: QuestionType - - selector: SelectorType - task_count?: number - task_score: number - p: number - - // Private! - _complete: boolean - _processing: boolean - _answer: string[] -} - -export interface AnswerValueItemType { - value: string -} - -export interface AnswerType { - question_id: string - values: AnswerValueItemType[] -} \ No newline at end of file -- cgit v1.2.3