diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Widget.tsx | 6 | ||||
| -rw-r--r-- | src/components/app-sidebar.tsx | 84 | ||||
| -rw-r--r-- | src/components/nav-main.tsx | 6 | ||||
| -rw-r--r-- | src/components/site-header.tsx | 2 | ||||
| -rw-r--r-- | src/models/answerSlice.ts | 86 | ||||
| -rw-r--r-- | src/models/bucketSlice.ts | 1 | ||||
| -rw-r--r-- | src/models/questionSlice.ts | 22 | ||||
| -rw-r--r-- | src/pages/Questions.tsx | 154 | ||||
| -rw-r--r-- | src/store.ts | 10 |
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 } }) |
