aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMax Nanis2025-06-03 20:06:44 +0700
committerMax Nanis2025-06-03 20:06:44 +0700
commit62456f41288eb0f03c5092d36610d566f275c8b5 (patch)
tree20b0170545f6b6c1ae1af233aca795d1d4aac2ef
parentbf027779eb4c839420406e3547f22f5665ec08ab (diff)
downloadpanel-ui-62456f41288eb0f03c5092d36610d566f275c8b5.tar.gz
panel-ui-62456f41288eb0f03c5092d36610d566f275c8b5.zip
selectQuestionById, useMemo selector (MultiChoice.choices makes a lot of lookup). Setting up the Questions view to do Pagination to tab through available questions and view one at a time
-rw-r--r--src/Widget.tsx6
-rw-r--r--src/components/app-sidebar.tsx84
-rw-r--r--src/components/nav-main.tsx6
-rw-r--r--src/components/site-header.tsx2
-rw-r--r--src/models/answerSlice.ts86
-rw-r--r--src/models/bucketSlice.ts1
-rw-r--r--src/models/questionSlice.ts22
-rw-r--r--src/pages/Questions.tsx154
-rw-r--r--src/store.ts10
9 files changed, 154 insertions, 217 deletions
diff --git a/src/Widget.tsx b/src/Widget.tsx
index 99c2a33..c3b1f8f 100644
--- a/src/Widget.tsx
+++ b/src/Widget.tsx
@@ -45,9 +45,9 @@ const Widget = () => {
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<div className="px-4 lg:px-6">
- {app.currentPage === 'offerwall' && <Offerwall/>}
- {app.currentPage === 'questions' && <QuestionsPage/>}
- {app.currentPage === 'cashouts' && <CashoutMethodsPage/>}
+ {app.currentPage === 'offerwall' && <Offerwall />}
+ {app.currentPage === 'questions' && <QuestionsPage />}
+ {app.currentPage === 'cashouts' && <CashoutMethodsPage />}
</div>
</div>
</div>
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index d2bc03e..e8cbd4c 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -1,19 +1,9 @@
"use client"
import * as React from "react"
-import {CircleDollarSign, MessageCircle, MoreVerticalIcon, SquareStack, UserCircleIcon} from "lucide-react"
+import {CircleDollarSign, MessageCircle, SquareStack} from "lucide-react"
import {NavMain} from "@/components/nav-main"
-import {Avatar, AvatarFallback, AvatarImage,} from "@/components/ui/avatar"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
import {
Sidebar,
SidebarContent,
@@ -34,15 +24,11 @@ export function AppSidebar({...props}: React.ComponentProps<typeof Sidebar>) {
const {isMobile} = useSidebar()
- // <button onClick={() => setActiveView('offerwall')}>Offerwall</button>
- // <button onClick={() => setActiveView('questions')}>Questions</button>
- // <button onClick={() => setActiveView('cashout')}>Cashout Methods</button>
-
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
- <SidebarMenuItem>
+ <SidebarMenuItem key="panel_name">
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
@@ -87,72 +73,6 @@ export function AppSidebar({...props}: React.ComponentProps<typeof Sidebar>) {
</SidebarGroup>
</SidebarContent>
- <SidebarGroupContent>
- <SidebarMenu>
- <SidebarMenuItem key="support">
- <SidebarMenuButton asChild>
- <a href="#">
- <MessageCircle/>
- <span>Support</span>
- </a>
- </SidebarMenuButton>
- </SidebarMenuItem>
- </SidebarMenu>
- </SidebarGroupContent>
-
-
- <SidebarFooter>
- <DropdownMenu>
- <DropdownMenuTrigger>
- <SidebarMenuButton
- size="lg"
- className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
- >
- <Avatar className="h-8 w-8 rounded-lg grayscale">
- <AvatarImage src="#" alt="foo"/>
- <AvatarFallback className="rounded-lg">IW</AvatarFallback>
- </Avatar>
- <div className="grid flex-1 text-left text-sm leading-tight">
- <span className="truncate font-medium">Ironwood User</span>
- <span className="truncate text-xs text-muted-foreground">
- ironwood@example.com
- </span>
- </div>
- <MoreVerticalIcon className="ml-auto size-4"/>
- </SidebarMenuButton>
- </DropdownMenuTrigger>
- <DropdownMenuContent
- className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
- side={isMobile ? "bottom" : "right"}
- align="end"
- sideOffset={4}
- >
- <DropdownMenuLabel className="p-0 font-normal">
- <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
- <Avatar className="h-8 w-8 rounded-lg">
- <AvatarImage src="#" alt="Ironwood User"/>
- <AvatarFallback className="rounded-lg">IW</AvatarFallback>
- </Avatar>
- <div className="grid flex-1 text-left text-sm leading-tight">
- <span className="truncate font-medium">Ironwood User</span>
- <span className="truncate text-xs text-muted-foreground">
- ironwood@example.com
- </span>
- </div>
- </div>
- </DropdownMenuLabel>
- <DropdownMenuSeparator/>
- <DropdownMenuGroup>
- <DropdownMenuItem>
- <UserCircleIcon/>
- Account
- </DropdownMenuItem>
- </DropdownMenuGroup>
- <DropdownMenuSeparator/>
- </DropdownMenuContent>
- </DropdownMenu>
- </SidebarFooter>
-
</Sidebar>
)
}
diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx
index 873d236..12a60aa 100644
--- a/src/components/nav-main.tsx
+++ b/src/components/nav-main.tsx
@@ -19,7 +19,7 @@ export function NavMain() {
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
- <SidebarMenuItem key="Surveys"
+ <SidebarMenuItem key="surveys"
onClick={() => dispatch(setPage("offerwall"))}
>
<SidebarMenuButton tooltip="Surveys">
@@ -29,7 +29,7 @@ export function NavMain() {
</SidebarMenuItem>
- <SidebarMenuItem key="Questions"
+ <SidebarMenuItem key="questions"
onClick={() => dispatch(setPage("questions"))}
>
<SidebarMenuButton tooltip="Questions">
@@ -38,7 +38,7 @@ export function NavMain() {
</SidebarMenuButton>
</SidebarMenuItem>
- <SidebarMenuItem key="Community">
+ <SidebarMenuItem key="community">
<SidebarMenuButton tooltip="Community">
<Users/>
<span>Community</span>
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx
index 5bc5ca7..0000e5f 100644
--- a/src/components/site-header.tsx
+++ b/src/components/site-header.tsx
@@ -14,7 +14,7 @@ const SiteHeader = () => {
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
- <h1 className="text-base font-medium">"activeView"</h1>
+ <h1 className="text-base font-medium">Offerwall</h1>
</div>
</header>
diff --git a/src/models/answerSlice.ts b/src/models/answerSlice.ts
index 368072b..f271288 100644
--- a/src/models/answerSlice.ts
+++ b/src/models/answerSlice.ts
@@ -1,13 +1,8 @@
-import {createSlice, PayloadAction} from '@reduxjs/toolkit'
-// import type {RootState} from '@/store'
+import {createSelector, createSlice, PayloadAction} from '@reduxjs/toolkit'
// import {Answer} from "@/models/answer.ts";
// import {stringify} from "querystring";
-// import {PatternType} from "@/types.ts";
-// import {UserQuestionAnswerIn} from "@/api"
import {RootState} from '@/store'; // your root state type
import {PatternValidation, UpkQuestion} from "@/api";
-import {Answer} from "@/models/answer.ts"
-
interface Answer {
values: string[];
@@ -52,29 +47,26 @@ const answerSlice = createSlice({
*/
switch (question.question_type) {
- case "MC":
- answer.values.push(val);
- break
- }
-
- switch (question.question_type) {
case "TE":
answer.values = [val]
if (answer.values.length > 1) {
answer.error_msg = "Only one answer allowed"
+ break;
}
let answer_text: string = answer.values[0]
if (answer_text.length <= 0) {
answer.error_msg = "Must provide answer"
+ break;
}
const max_length: number = (question.configuration ?? {})["max_length"] ?? 100000
if (answer_text.length > max_length) {
answer.error_msg = "Answer longer than allowed"
+ break;
}
const patterns: PatternValidation[] = (question.validation ?? {})["patterns"] ?? []
@@ -82,13 +74,30 @@ const answerSlice = createSlice({
let re = new RegExp(pv.pattern)
if (answer_text.search(re) == -1) {
answer.error_msg = pv.message
+ return;
}
})
answer.error_msg = ""
- break
+ break;
case "MC":
+ switch (question.selector) {
+ case "SA": // Single Answer
+ answer.values = [val]
+ break
+ case "MA": /// Multi Answer
+ if (answer.values.includes(val)) {
+ // The item has already been selected
+ answer.values = answer.values.filter(value => value !== val);
+ } else {
+ // It's a new selection
+ answer.values.push(val);
+ }
+ break
+ }
+
+
if (answer.values.length == 0) {
answer.error_msg = "MC question with no selected answers"
}
@@ -100,26 +109,23 @@ const answerSlice = createSlice({
if (answer.values.length > 1) {
answer.error_msg = "Single Answer MC question with >1 selected answers"
}
- break
+ break;
case "MA":
if (answer.values.length > choice_codes.length) {
answer.error_msg = "More options selected than allowed"
}
- break
+ break;
}
- // if (!every(qa.values, (v) => {
- // return includes(choice_codes, v["value"])
- // })) {
- // this.error_msg = "Invalid Options Selected"
- // return false
- // }
- //
- // const max_select: number = (this.configuration ?? {})["max_select"] ?? choice_codes.length
- // if (qa.values.length > max_select) {
- // this.error_msg = "More options selected than allowed"
- // return false
- // }
+
+ if (!answer.values.every(val => choice_codes.includes(val))) {
+ answer.error_msg = "Invalid Options Selected";
+ }
+
+ 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)
@@ -130,18 +136,17 @@ const answerSlice = createSlice({
*/
answer.error_msg = ""
- break
+ break;
default:
throw new Error("Incorrect Question Type provided");
}
- state[question.question_id] = question;
+ state[question.question_id] = answer
},
// removeAnswer(val: string): null {
// switch (this.getType()) {
- //
// // You can only remove a value from a MultiChoice
// case "MC":
// // TODO: implement this
@@ -149,15 +154,12 @@ const answerSlice = createSlice({
// // current_values.push(val)
// // this._answer = new ProfilingAnswer(this.questionId, current_values);
// break
- //
// default:
// throw new Error("Incorrect Question Type provided");
// }
- //
// this.validate()
// }
-
// save() {
// let question: ProfilingQuestion = this;
// // @ts-ignore
@@ -196,9 +198,21 @@ const answerSlice = createSlice({
}
})
-// Export the generated reducer function
export const {addAnswer, setAnswer, setQuestions, questionAdded, questionUpdated} = answerSlice.actions;
export default answerSlice.reducer
-export const selectAnswerCount = (state: RootState) => state.answers.size
-// export const selectAnswerByQuestionId = (state: RootState, questionId: string) => state.answers.find(a => q.questionId === questionId) \ No newline at end of file
+
+export const makeSelectChoicesByQuestion = (question: UpkQuestion) =>
+ createSelector(
+ (state: RootState) => state.answers,
+ (answers) => {
+ // const question = questions.find(q => q.id === questionId);
+ // return question?.choices ?? [];
+ return answers[question.question_id] ?? {
+ values: [],
+ error_msg: "",
+ complete: false,
+ processing: false
+ } as Answer;
+ }
+ ); \ No newline at end of file
diff --git a/src/models/bucketSlice.ts b/src/models/bucketSlice.ts
index 3a2c0d1..1a15263 100644
--- a/src/models/bucketSlice.ts
+++ b/src/models/bucketSlice.ts
@@ -23,7 +23,6 @@ export default bucketSlice.reducer
export const selectBucketsStatus = (state: RootState) => state.buckets.status
export const selectBucketsError = (state: RootState) => state.buckets.error
-
export const selectAllBuckets = (state: RootState) => state.buckets
export const selectBucketById = (state: RootState, bucketId: string | null) =>
diff --git a/src/models/questionSlice.ts b/src/models/questionSlice.ts
index e3674be..814caf9 100644
--- a/src/models/questionSlice.ts
+++ b/src/models/questionSlice.ts
@@ -1,4 +1,4 @@
-import {createSlice, PayloadAction} from '@reduxjs/toolkit'
+import {createSelector, createSlice, PayloadAction} from '@reduxjs/toolkit'
import type {RootState} from '@/store'
import {UpkQuestion} from "@/api";
@@ -14,22 +14,18 @@ const questionSlice = createSlice({
questionAdded(state, action: PayloadAction<UpkQuestion>) {
state.push(action.payload);
},
- setAnswer(state, action: PayloadAction<{ questionId: string, val: string }>) {
- const {questionId, val} = action.payload
- console.log(questionId, val)
- const existingQuestion = state.find(q => q.questionId === action.payload.questionId)
- if (existingQuestion) {
- // existingQuestion.addAnswer(action.payload.val)
- // existingQuestion.error_msg = "yess"
- }
- }
}
})
-// Export the generated reducer function
export const {setAnswer, setQuestions, questionAdded, questionUpdated} = questionSlice.actions;
export default questionSlice.reducer
-export const selectAllQuestions = (state: RootState) => state.questions
+// export const selectAllQuestions = (state: RootState) => state.questions
-export const selectQuestionById = (state: RootState, questionId: string) => state.questions.find(q => q.id === questionId) \ No newline at end of file
+export const selectQuestionById = (questionId: string) =>
+ createSelector(
+ (state: RootState) => state.questions,
+ (questions) => {
+ return questions.find(q => q.question_id === questionId);
+ }
+ ); \ No newline at end of file
diff --git a/src/pages/Questions.tsx b/src/pages/Questions.tsx
index 0a3f43e..9eb7cba 100644
--- a/src/pages/Questions.tsx
+++ b/src/pages/Questions.tsx
@@ -1,23 +1,29 @@
-import React from 'react'
-import {UpkQuestion} from "@/api";
-import {UpkQuestionChoice} from "@/api/models/upk-question-choice.ts";
+import React, {useMemo, useState} from 'react'
+import {UpkQuestion, UpkQuestionChoice} from "@/api";
import {Card, CardContent, CardHeader} from "@/components/ui/card.tsx";
-import {useAppSelector} from "@/hooks.ts";
+import {useAppDispatch, useAppSelector} from "@/hooks.ts";
+import {addAnswer, makeSelectChoicesByQuestion} from "@/models/answerSlice.ts";
+import {selectQuestionById} from "@/models/questionSlice.ts";
+import {useSelector} from "react-redux";
+import {Button} from "@/components/ui/button"
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+} from "@/components/ui/pagination"
const TextEntry: React.FC<{ question: UpkQuestion }> = ({question}) => {
- // const dispatch = useAppDispatch()
- // const buckets = useAppSelector(state => state.buckets)
-
- // const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
- // // Don't allow any input changes after they triggered submission...
- // if (question._complete || question._processing) {
- // return
- // }
- //
- // // Assign the input value as an answer to the question
- // const newValue = event.target.value;
- // dispatch(setAnswer({questionId: question.questionId, val: newValue}))
- // };
+ const dispatch = useAppDispatch()
+ const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]);
+ const answer = useSelector(selectAnswer);
+
+ const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ dispatch(addAnswer({question: question, val: event.target.value}))
+ };
+
+ console.log("TextEntry.answer", answer)
return (
<Card className="@container/card">
@@ -30,83 +36,52 @@ const TextEntry: React.FC<{ question: UpkQuestion }> = ({question}) => {
id="text-entry-input"
aria-describedby=""
placeholder=""
- // onInput={handleInputChange}
+ onKeyDown={handleInputChange}
/>
- {/*<small id="text-entry-msg">{question.error_msg}</small>*/}
+ <small id="text-entry-msg">{answer.error_msg}</small>
</CardContent>
</Card>
)
}
const MultiChoiceItem: React.FC<{ question: UpkQuestion, choice: UpkQuestionChoice }> = ({question, choice}) => {
-
- // const onclick = function () {
- // // Assign the input value as an answer to the question
- // let answer = getAnswer;
- // let click_value = childView.model.get("choice_id")
- //
- // switch (question.selector) {
- //
- // case "SA": /// Single Answer
- // answer.set("values", [{"value": click_value}]);
- // break
- //
- // case "MA": /// Multi Answer
- // let current_values: AnswerValueItemType[] = answer.get("values") ?? [];
- //
- // if (includes(map(current_values, "value"), click_value)) {
- // // The item has already been selected
- // current_values = remove(current_values, (v) => {
- // return v["value"] != click_value
- // })
- //
- // } else {
- // // It's a new selection
- // current_values.push({"value": click_value})
- // }
- //
- // answer.set("values", current_values);
- // break
- // }
- // childView.render();
- //
- // // Validate the answer and show any information
- // let res: QuestionValidationResult = this.model.validateAnswer()
- // this.ui.message.text(res["message"]);
- // }
-
- // const render = function () {
- // let answer = this.getOption("answer");
- // let current_values: AnswerValueItemType[] = answer.get("values") ?? [];
- // let current_values_values = map(current_values, "value");
- //
- // if (includes(current_values_values, this.model.get("choice_id"))) {
- // this.$el.addClass("selected");
- // }
- // }
+ const dispatch = useAppDispatch()
+ const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]);
+ const answer = useSelector(selectAnswer);
+ const selected = answer.values.includes(choice.choice_id)
return (
- <>
+ <Button
+ onClick={() => dispatch(addAnswer({question: question, val: choice.choice_id}))}
+ variant={selected ? "default" : "secondary"}
+ >
{choice.choice_text}
- </>
+ </Button>
)
}
const MultipleChoice: React.FC<{ question: UpkQuestion }> = ({question}) => {
+ const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]);
+ const answer = useSelector(selectAnswer);
return (
<Card>
<CardHeader>
{question.question_text}
- {/*<small id="text-entry-msg">{question.error_msg}</small>*/}
+ <small id="text-entry-msg">{answer.error_msg}</small>
</CardHeader>
<CardContent>
- {
- question.choices.map(c => {
- return <MultiChoiceItem key={`${question.question_id}-${c.choice_id}`} question={question} choice={c}/>
- })
- }
+ <ol>
+ {
+ question.choices.map(c => {
+ return <MultiChoiceItem
+ key={`${question.question_id}-${c.choice_id}`}
+ question={question}
+ choice={c}/>
+ })
+ }
+ </ol>
</CardContent>
</Card>
)
@@ -128,6 +103,12 @@ const ProfileQuestionFull: React.FC<UpkQuestion> = ({question}) => {
const QuestionsPage = () => {
const questions = useAppSelector(state => state.questions)
+ const [activeQuestionID, setQuestionID] = useState(() => questions[0].question_id);
+
+ const selectQuestion = useMemo(() => selectQuestionById(activeQuestionID), [questions]);
+ const question = useSelector(selectQuestion);
+
+ console.log(activeQuestionID, questions)
return (
<div>
@@ -135,11 +116,30 @@ const QuestionsPage = () => {
A total of {questions.length} questions are available.
</p>
- {
- questions.map(q => {
- return <ProfileQuestionFull key={q.question_id} question={q} className="mt-4 mb-4"/>;
- })
- }
+ <Pagination>
+ <PaginationContent>
+ {
+ questions.map((q, i) => {
+ return (
+ <PaginationItem>
+ <PaginationLink
+ onClick={() => setQuestionID(q.question_id)}
+ >
+ {i+1}
+ </PaginationLink>
+ </PaginationItem>
+ )
+ })
+ }
+
+ <PaginationItem>
+ <PaginationNext/>
+ </PaginationItem>
+ </PaginationContent>
+ </Pagination>
+
+
+ <ProfileQuestionFull key={question.question_id} question={question} className="mt-4 mb-4"/>
</div>
);
}
diff --git a/src/store.ts b/src/store.ts
index 4bd54ca..4e56b39 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -3,12 +3,20 @@ import {configureStore} from '@reduxjs/toolkit'
import bucketReducers from "@/models/bucketSlice.ts"
import questionReducers from "@/models/questionSlice.ts"
import appReducers from "@/models/appSlice.ts"
+import answerReducers from "@/models/answerSlice.ts"
export const store = configureStore({
reducer: {
app: appReducers,
+
+ // - Read Only
+ // -- These act as API cache stores to allow background loading
buckets: bucketReducers,
- questions: questionReducers
+ questions: questionReducers,
+
+ // - Read Write
+ // -- This stores user engagement (eg: answering any questions)
+ answers: answerReducers
}
})