aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMax Nanis2025-06-06 16:32:17 +0700
committerMax Nanis2025-06-06 16:32:17 +0700
commit696dee6a6a9506fcf771d0ec4911dcc82a279fda (patch)
tree3e8d0bde7cded0f3e1fba82e3af1f2253c06bcbf
parent2f675eecec576b1ab17260e2513e1eec187a81d2 (diff)
downloadpanel-ui-696dee6a6a9506fcf771d0ec4911dcc82a279fda.tar.gz
panel-ui-696dee6a6a9506fcf771d0ec4911dcc82a279fda.zip
Lots of reducer work to organize active Question in redux state (rather than useState). Various UX/CSS checks for Pagination state.
-rw-r--r--src/Widget.tsx2
-rw-r--r--src/components/site-header.tsx9
-rw-r--r--src/counterSlice.ts44
-rw-r--r--src/lib/utils.ts12
-rw-r--r--src/models/answerSlice.ts48
-rw-r--r--src/models/appSlice.ts6
-rw-r--r--src/models/questionSlice.ts72
-rw-r--r--src/pages/Questions.tsx101
-rw-r--r--src/types.ts74
9 files changed, 195 insertions, 173 deletions
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 (
<header
@@ -14,7 +17,11 @@ const SiteHeader = () => {
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
- <h1 className="text-base font-medium">Offerwall</h1>
+ <h1 className="text-base font-medium">
+ {app.currentPage === 'offerwall' && "Offerwall"}
+ {app.currentPage === 'questions' && "Profiling Questions"}
+ {app.currentPage === 'cashouts' && "Cashout Methods"}
+ </h1>
</div>
</header>
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<number>) => {
- 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<Page>) {
- 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<UpkQuestion[]>) {
+ setQuestions(state, action: PayloadAction<ProfileQuestion[]>) {
return action.payload;
},
- questionAdded(state, action: PayloadAction<UpkQuestion>) {
+ questionAdded(state, action: PayloadAction<ProfileQuestion>) {
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<ProfileQuestion>) {
+ state.forEach((q) => {
+ q.active = q.question_id === action.payload.question_id
+ })
+ },
+ updateQuestion(state, action: PayloadAction<{ question_id: string, updates: Partial<ProfileQuestion> }>) {
+ 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<UpkQuestion> = ({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<UpkQuestion> = ({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<UpkQuestion> = ({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<UpkQuestion> = ({question}) => {
type="submit"
className="w-1/3 cursor-pointer"
disabled={!can_submit}
- onClick={submitAnswer}
+ onClick={submitAnswerEvt}
>
Submit
</Button>
@@ -171,27 +181,38 @@ const ProfileQuestionFull: React.FC<UpkQuestion> = ({question}) => {
</Card>
)
}
-// 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 (
<PaginationItem>
<PaginationLink
href="#"
title={question.question_text}
- isActive={question.question_id === activeQuestionID}
- aria-disabled={answer?.complete}
+ isActive={question.active}
+ aria-disabled={completed}
- onClick={(e) => 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}
</PaginationLink>
</PaginationItem>
@@ -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 (
<div>
@@ -214,20 +244,27 @@ const QuestionsPage = () => {
<PaginationContent>
{
questions.slice(0, 5).map((q, i) => {
- return <PaginationIcon key={q.question_id} question={q} idx={i}
- onSetQuestionID={setQuestionID}/>
+ return <PaginationIcon
+ key={q.question_id}
+ question={q}
+ idx={i}
+ />
})
}
<PaginationItem>
<PaginationNext
+ onClick={clickNext}
href="#"
/>
</PaginationItem>
</PaginationContent>
</Pagination>
- <ProfileQuestionFull key={question.question_id} question={question} className="mt-4 mb-4"/>
+ <ProfileQuestionFull
+ key={question.question_id}
+ question={question}
+ className="mt-4 mb-4"/>
</div>
)
}
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