diff options
| author | Max Nanis | 2025-06-09 16:05:52 +0700 |
|---|---|---|
| committer | Max Nanis | 2025-06-09 16:05:52 +0700 |
| commit | 74890e251dee3e0f195583431cb48b9f3a58ecc9 (patch) | |
| tree | a27ceee03999f18fd3ef2e0d44ba7deb39f0b6c8 | |
| parent | a674d2e03de3bd048714d9c06e4bba9d9ecdb328 (diff) | |
| download | panel-ui-74890e251dee3e0f195583431cb48b9f3a58ecc9.tar.gz panel-ui-74890e251dee3e0f195583431cb48b9f3a58ecc9.zip | |
Cashout Methods page: adding walletSlice and cashoutmethodsSlice so they're in the stored state. Iterating with fix vs variable filters. Pulling old validators from old code and setting up the wallet fetch.
| -rw-r--r-- | src/Widget.tsx | 22 | ||||
| -rw-r--r-- | src/components/app-sidebar.tsx | 31 | ||||
| -rw-r--r-- | src/models/app.ts | 2 | ||||
| -rw-r--r-- | src/models/cashoutMethodSlice.ts | 78 | ||||
| -rw-r--r-- | src/models/walletSlice.ts | 23 | ||||
| -rw-r--r-- | src/pages/CashoutMethods.tsx | 207 | ||||
| -rw-r--r-- | src/pages/Offerwall.tsx | 89 | ||||
| -rw-r--r-- | src/store.ts | 7 |
8 files changed, 383 insertions, 76 deletions
diff --git a/src/Widget.tsx b/src/Widget.tsx index 3f7902c..f355194 100644 --- a/src/Widget.tsx +++ b/src/Widget.tsx @@ -6,9 +6,11 @@ import {Offerwall} from "@/pages/Offerwall.tsx" import {QuestionsPage} from "@/pages/Questions.tsx"; import {useAppDispatch, useAppSelector} from "@/hooks.ts"; -import {OfferwallApi, ProfilingQuestionsApi} from "@/api"; +import {CashoutMethodOut, OfferwallApi, ProfilingQuestionsApi, UserWalletBalance, WalletApi} from "@/api"; import {ProfileQuestion, setQuestions} from "@/models/questionSlice.ts"; import {setBuckets} from "@/models/bucketSlice.ts"; +import {setCashoutMethods} from "@/models/cashoutMethodSlice.ts"; +import {setWallet} from "@/models/walletSlice.ts" import {CashoutMethodsPage} from "@/pages/CashoutMethods.tsx"; import {setAvailabilityCount, setOfferwallId} from "@/models/appSlice.ts" @@ -24,7 +26,6 @@ const Widget = () => { // https://fsb.generalresearch.com/{product_id}/offerwall/37d1da64/?country new OfferwallApi().offerwallSoftpairProductIdOfferwall37d1da64Get(app.bpid, app.bpuid, "104.9.125.144") .then(res => { - // We want to set these questions first, because the Bucket Component views may do // some redux lookups const objects: ProfileQuestion[] = Object.values(res.data.offerwall.question_info) as ProfileQuestion[] @@ -41,6 +42,21 @@ const Widget = () => { dispatch(setQuestions(res.data.questions as ProfileQuestion[])) }) .catch(err => console.log(err)); + + new WalletApi().getCashoutMethodsProductIdCashoutMethodsGet(app.bpid, app.bpuid) + .then(res => { + dispatch(setCashoutMethods(res.data.cashout_methods as CashoutMethodOut[])) + }) + .catch(err => console.log(err)) + + new WalletApi().getUserWalletBalanceProductIdWalletGet(app.bpid, app.bpuid) + .then(res => { + dispatch(setWallet(res.data.wallet as UserWalletBalance)) + }) + .catch(err => { + // TODO: Wallet mode is likely off + }) + }, []); // ← empty array means "run once" @@ -56,7 +72,7 @@ const Widget = () => { <div className="px-4 lg:px-6"> {app.currentPage === 'offerwall' && <Offerwall/>} {app.currentPage === 'questions' && <QuestionsPage/>} - {app.currentPage === 'cashouts' && <CashoutMethodsPage/>} + {app.currentPage === 'cashout_methods' && <CashoutMethodsPage/>} </div> </div> </div> diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index e8cbd4c..f314c9f 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -1,13 +1,12 @@ "use client" import * as React from "react" -import {CircleDollarSign, MessageCircle, SquareStack} from "lucide-react" +import {CircleDollarSign, SquareStack} from "lucide-react" import {NavMain} from "@/components/nav-main" import { Sidebar, SidebarContent, - SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, @@ -17,10 +16,16 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar" -import {useAppSelector} from "@/hooks.ts"; +import {useAppDispatch, useAppSelector} from "@/hooks.ts"; +import {setPage} from "@/models/appSlice.ts"; +import {Badge} from "@/components/ui/badge.tsx"; +import {useSelector} from "react-redux"; +import {selectCashoutMethods} from "@/models/cashoutMethodSlice.ts"; export function AppSidebar({...props}: React.ComponentProps<typeof Sidebar>) { const app = useAppSelector(state => state.app) + const dispatch = useAppDispatch() + const cashoutMethods = useSelector(selectCashoutMethods) const {isMobile} = useSidebar() @@ -50,12 +55,24 @@ export function AppSidebar({...props}: React.ComponentProps<typeof Sidebar>) { <SidebarGroupContent> <SidebarMenu> - <SidebarMenuItem key="cashout_methods"> + <SidebarMenuItem + key="cashout_methods" + className="cursor-pointer" + > <SidebarMenuButton asChild> - <a href="#"> + <a + onClick={() => dispatch(setPage("cashout_methods"))} + > <CircleDollarSign/> - <span>Methods</span> - </a> + <span> + Methods <Badge + className="absolute top-2 right-2 h-5 min-w-5 rounded-full px-1 font-mono tabular-nums cursor-pointer" + variant="outline" + title={`${cashoutMethods.length.toLocaleString()} cashout methods available`} + >{cashoutMethods.length.toLocaleString()}</Badge> + </span> </a> + + </SidebarMenuButton> </SidebarMenuItem> diff --git a/src/models/app.ts b/src/models/app.ts index 62da7bb..25b2412 100644 --- a/src/models/app.ts +++ b/src/models/app.ts @@ -1,4 +1,4 @@ -export type Page = 'offerwall' | 'questions' | 'cashouts'; +export type Page = 'offerwall' | 'questions' | 'cashout_methods'; export interface App { targetId: string, diff --git a/src/models/cashoutMethodSlice.ts b/src/models/cashoutMethodSlice.ts new file mode 100644 index 0000000..a6d0211 --- /dev/null +++ b/src/models/cashoutMethodSlice.ts @@ -0,0 +1,78 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit' +import type {RootState} from '@/store' +import {CashoutMethodOut} from "@/api"; + + +const initialState: CashoutMethodOut[] = [] + +const cashoutMethodSlice = createSlice({ + name: 'cashoutMethods', + initialState, + reducers: { + setCashoutMethods(state, action: PayloadAction<CashoutMethodOut[]>) { + return action.payload; + }, + redeem: { + // let res = {'status': false, 'msg': ''}; + // let cashout_method = this.collection.getCashoutMethod(); + // let req_amt = +this.ui.amount.val(); + // + // // Generic checks + // if (!cashout_method) { + // res['msg'] = "Cashout method not selected"; + // return res + // } + // + // if (isNaN(req_amt)) { + // res['msg'] = "Invalid amount (numbers only)"; + // return res; + // } + // + // if (!this.WALLET) { + // res['msg'] = "Unknown wallet balance"; + // return res + // } + // + // let balance: number = this.WALLET.get("redeemable_amount"); + // + // // Limit checks + // if (balance < cashout_method.get("min_value")) { + // res["msg"] = "Wallet balance not large enough"; + // return res; + // } + // + // let req_amount = this.getIntCentsValue() + // + // if (req_amount < cashout_method.get("min_value")) { + // res["msg"] = "Requested amount not large enough"; + // return res; + // } + // + // if (req_amount > cashout_method.get("max_value")) { + // res["msg"] = "Amount too large for payout method"; + // return res; + // } + // + // if (req_amount > balance) { + // res["msg"] = "Amount is more than wallet balance"; + // return res; + // } + // + // res["status"] = true; + // return res; + // }, + } + } +}) + +export const { + setCashoutMethods, +} = cashoutMethodSlice.actions; +export default cashoutMethodSlice.reducer + +export const selectCashoutMethods = (state: RootState) => state.cashoutMethods + +export const selectFixedCashoutMethods = (state: RootState) => + state.cashoutMethods.filter(cm => cm.data.value_type === "fixed") +export const selectVariableCashoutMethods = (state: RootState) => + state.cashoutMethods.filter(cm => cm.data.value_type === "variable") diff --git a/src/models/walletSlice.ts b/src/models/walletSlice.ts new file mode 100644 index 0000000..83eda62 --- /dev/null +++ b/src/models/walletSlice.ts @@ -0,0 +1,23 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit' + +import {UserWalletBalance} from "@/api"; + + +const initialState: UserWalletBalance = {}; + + +const walletSlice = createSlice({ + name: 'wallet', + initialState, + reducers: { + setWallet(state, action: PayloadAction<UserWalletBalance>) { + return action.payload; + } + } +}) + +export const { + setWallet, +} = walletSlice.actions; +export default walletSlice.reducer + diff --git a/src/pages/CashoutMethods.tsx b/src/pages/CashoutMethods.tsx index 9656087..fca6af0 100644 --- a/src/pages/CashoutMethods.tsx +++ b/src/pages/CashoutMethods.tsx @@ -1,45 +1,202 @@ -import React, {useEffect, useState} from 'react' +import React, {useState} from 'react' +import {Card, CardContent, CardHeader,} from "@/components/ui/card" +import {Tabs, TabsContent, TabsList, TabsTrigger,} from "@/components/ui/tabs" +import {Badge} from "@/components/ui/badge" + +import {selectFixedCashoutMethods, selectVariableCashoutMethods} from "@/models/cashoutMethodSlice.ts"; +import {CashoutMethodOut, UserWalletBalance} from "@/api" +import {formatCentsToUSD} from "@/lib/utils.ts"; +import {useSelector} from 'react-redux' +import {motion} from "framer-motion"; +import {Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle,} from "@/components/ui/drawer" +import {useAppSelector} from "@/hooks.ts"; + +const CashoutAcknowledgement = () => { + return ( + <> + <p>Your request has been successfully submitted!</p> + <div className="form-group"> + <label htmlFor="cashout-acknowledgement-transaction-id-input">Transaction ID</label> + <input type="email" className="form-control" + id="cashout-acknowledgement-transaction-id-input" + aria-describedby="transactionIdHelp" + readOnly/> + <small id="transactionIdHelp" className="form-text">Request Status: + <strong>Pending</strong></small> + </div> + <p>Your redemption link will be on the <a href="/history/">history page</a>.</p> + </> + ) +} + +const CashoutReview: React.FC<{ cashout_method: CashoutMethodOut }> = ({cashout_method}) => { + return ( + <> + <p>You are about to redeem <strong id="cashout-review-redeem-amount"></strong> to a card.</p> + <img src="<%= image_url %>" alt="..."/> + <p>{cashout_method.name}</p> + {/*<p>{data.terms}</p>*/} + <button id="cashout-review-cancel" type="button" + className="btn btn-block">Cancel + </button> + <button id="cashout-review-submit" type="button" + className="btn btn-block">Submit + </button> + <p id="cashout-review-msg"></p> + </> + ) +} + +const VariableCashoutMethodPreview: React.FC<{ cashout_method: CashoutMethodOut }> = ({cashout_method}) => { + return ( + <> + <motion.h1 + initial={{opacity: 0, scale: 0.8, y: 10}} + animate={{opacity: 1, scale: 1, y: 0}} + transition={{ + duration: 0.25, + type: "spring", + stiffness: 100, + damping: 10, + }} + className="font-bold text-center" + > + {cashout_method.name} + </motion.h1> + + <motion.h2 + initial={{opacity: 0, scale: 0.8, y: 10}} + animate={{opacity: 1, scale: 1, y: 0}} + transition={{ + duration: 0.25, + type: "spring", + stiffness: 100, + damping: 10, + }} + className="text-center" + > + {formatCentsToUSD(cashout_method.min_value)} – {formatCentsToUSD(cashout_method.max_value)} + </motion.h2> + </> + ) +} + +const FixedCashoutMethodPreview: React.FC<{ cashout_method: CashoutMethodOut }> = ({cashout_method}) => { + return ( + <> + <motion.h1 + initial={{opacity: 0, scale: 0.8, y: 10}} + animate={{opacity: 1, scale: 1, y: 0}} + transition={{ + duration: 0.25, + type: "spring", + stiffness: 100, + damping: 10, + }} + className="font-bold text-center" + > + {cashout_method.name} + </motion.h1> + </> + ) +} -import {Card, CardContent, CardHeader} from "@/components/ui/card.tsx"; -import {CashoutMethodOut, CashoutMethodsResponse, WalletApi} from "@/api" const CashoutMethodPreview: React.FC<{ cashout_method: CashoutMethodOut }> = ({cashout_method}) => { + const [open, setOpen] = useState(false) + + const renderContent = () => { + switch (cashout_method.data.value_type) { + case 'fixed': + return <FixedCashoutMethodPreview cashout_method={cashout_method}/> + case 'variable': + return <VariableCashoutMethodPreview cashout_method={cashout_method}/> + } + }; + return ( - <Card key={cashout_method.id}> + <Card + key={cashout_method.id} + className="@container/card relative overflow-hidden h-full min-h-[140px] flex flex-col justify-between cursor-pointer" + > <CardHeader> - {cashout_method.name} + {renderContent()} </CardHeader> <CardContent> - <img className="blur-xs grayscale" src={cashout_method.imageUrl}/> + <img + className="grayscale blur-[1px] transition-all duration-300 hover:grayscale-0 hover:blur-none" + alt="Cashout method" + src={cashout_method.image_url}/> </CardContent> + + <Badge + className="absolute bottom-1 right-1 h-5 min-w-5 rounded-full px-1 font-mono tabular-nums cursor-pointer" + variant="outline" + title="Cashout Details" + onClick={() => setOpen(true)} + >Details + </Badge> + + <Drawer open={open} onOpenChange={setOpen}> + <DrawerContent> + <DrawerHeader> + <DrawerTitle>{cashout_method.name} Details</DrawerTitle> + <DrawerDescription> + <div dangerouslySetInnerHTML={{__html: cashout_method.description}}/> + </DrawerDescription> + </DrawerHeader> + </DrawerContent> + </Drawer> + </Card> ) } -const CashoutMethodsPage: React.FC<GRLWidgetSettings> = ({settings}) => { - const [cashoutMethods, setCashoutMethods] = useState([]); - useEffect(() => { - const x = new WalletApi(); - x.getCashoutMethodsProductIdCashoutMethodsGet(settings.bpid, settings.bpuid) - .then(res => { - const data: CashoutMethodsResponse = res.data; - setCashoutMethods(data.cashout_methods); - }) - .catch(err => console.log(err)); - }, []); // ← empty array means "run once" +const CashoutMethodsPage = () => { + const variableCashoutMethods = useSelector(selectVariableCashoutMethods) + const fixedCashoutMethods = useSelector(selectFixedCashoutMethods) + const wallet: UserWalletBalance = useAppSelector(state => state.wallet) + return ( - <div className="grid grid-cols-3 gap-1 p-1"> - { - cashoutMethods.map((m, index) => { - return <CashoutMethodPreview key={index} cashout_method={m}/>; - }) - } - </div> - ); + <> + <p>Your balance is <strong>{formatCentsToUSD(wallet.amount)}</strong>.</p> + <p>You can redeem <strong>{formatCentsToUSD(wallet.redeemable_amount)}</strong> now.</p> + <p><small>(a portion of each survey is delayed by 30 days)</small></p> + + <Tabs defaultValue="dynamic"> + <TabsList + className="cursor-pointer" + > + <TabsTrigger value="dynamic">Variable</TabsTrigger> + <TabsTrigger value="fixed">Fixed</TabsTrigger> + </TabsList> + + <TabsContent value="dynamic"> + <div className="grid grid-cols-3 gap-1 p-1"> + { + variableCashoutMethods.map((m, index) => { + return <CashoutMethodPreview key={index} cashout_method={m}/>; + }) + } + </div> + </TabsContent> + <TabsContent value="fixed"> + <div className="grid grid-cols-3 gap-1 p-1"> + { + fixedCashoutMethods.map((m, index) => { + return <CashoutMethodPreview key={index} cashout_method={m}/>; + }) + } + </div> + </TabsContent> + </Tabs> + </> + ) } + export {CashoutMethodsPage}
\ No newline at end of file diff --git a/src/pages/Offerwall.tsx b/src/pages/Offerwall.tsx index 0d0534e..19c4515 100644 --- a/src/pages/Offerwall.tsx +++ b/src/pages/Offerwall.tsx @@ -1,5 +1,5 @@ import React, {useState} from 'react' -import { motion } from "framer-motion"; +import {motion} from "framer-motion"; import {Badge} from "@/components/ui/badge" import {Link} from '@mui/material'; import {Tabs, TabsContent} from "@/components/ui/tabs" @@ -18,14 +18,13 @@ import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/ import {useSelector} from "react-redux"; import {Switch} from "@/components/ui/switch" import {Card, CardContent, CardFooter} from "@/components/ui/card.tsx"; -import {makeSelectQuestionsByIds, ProfileQuestion} from "@/models/questionSlice.ts" +import {makeSelectQuestionsByIds} from "@/models/questionSlice.ts" import {CheckIcon, MessageCircleQuestionIcon, XIcon} from "lucide-react" import {useAppDispatch, useAppSelector} from '@/hooks' import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,} from "@/components/ui/sheet" import {ProfileQuestionFull} from "@/pages/Questions.tsx" import {Answer, selectAnswerForQuestion, submitAnswer} from "@/models/answerSlice.ts"; import {assert, formatCentsToUSD, formatSeconds} from "@/lib/utils.ts"; -import {IQRBoxPlot} from "@/lib/snippets.tsx" import {setAvailabilityCount, setOfferwallId} from "@/models/appSlice.ts"; import {setBuckets} from "@/models/bucketSlice.ts"; @@ -54,12 +53,12 @@ const ContentsGrid: React.FC<SoftPairBucket> = ({bucket}) => { { accessorKey: "loi", header: "Length", - cell: ({ getValue }) => formatSeconds(getValue() as number), + cell: ({getValue}) => formatSeconds(getValue() as number), }, { accessorKey: "payout", header: "Payout", - cell: ({ getValue}) => formatCentsToUSD(getValue() as number) + cell: ({getValue}) => formatCentsToUSD(getValue() as number) }, ] @@ -73,7 +72,7 @@ const ContentsGrid: React.FC<SoftPairBucket> = ({bucket}) => { return ( <div className="max-h-[120px] overflow-y-auto border rounded-md"> - <Table className="text-sm [&_th]:px-2 [&_td]:px-2 [&_th]:py-1 [&_td]:py-1" > + <Table className="text-sm [&_th]:px-2 [&_td]:px-2 [&_th]:py-1 [&_td]:py-1"> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> @@ -123,6 +122,7 @@ const ConditionalQuestions: React.FC<SoftPairBucket> = ({bucket}) => { const app = useAppSelector(state => state.app) console.log("Conditional bucket:", questions, question, answer) + const [open, setOpen] = useState(false) const submitEvt = () => { dispatch(submitAnswer({question: question})) @@ -153,32 +153,41 @@ const ConditionalQuestions: React.FC<SoftPairBucket> = ({bucket}) => { } return ( - <Sheet> - <SheetTrigger>Open</SheetTrigger> - <SheetContent - side="right" - className="md:w-[900px], lg:w-[1000px]"> - - <SheetHeader> - <SheetTitle>Bucket Questions</SheetTitle> - <SheetDescription> - This survey has some unanswered questions. Answer these to determine if you're - eligible for the Survey Bucket - </SheetDescription> - </SheetHeader> - - { - questions.map(q => { - return <ProfileQuestionFull - key={q.question_id} - question={q} - submitAnswerEvt={submitEvt} - className="mt-4 m-2"/> - }) - } + <> + <Button + variant="secondary" + className="w-full h-8 cursor-pointer" + onClick={() => setOpen(true)} + > + Check Eligibility + </Button> + + <Sheet open={open} onOpenChange={setOpen}> + <SheetContent + side="right" + className="md:w-[900px], lg:w-[1000px] p-5"> + + <SheetHeader> + <SheetTitle>Bucket Questions</SheetTitle> + <SheetDescription> + This survey has some unanswered questions. Answer these to determine if you're + eligible for the Survey Bucket + </SheetDescription> + </SheetHeader> - </SheetContent> - </Sheet> + { + questions.map(q => { + return <ProfileQuestionFull + key={q.question_id} + question={q} + submitAnswerEvt={submitEvt} + className="mt-4 m-2"/> + }) + } + + </SheetContent> + </Sheet> + </> ) } @@ -201,22 +210,24 @@ const CallToAction: React.FC<SoftPairBucket> = ({bucket}) => { </div> ) case "conditional": - // return <ConditionalQuestions bucket={bucket}/> + return ( + <div className="absolute bottom-2 left-1/2 transform -translate-x-1/2 w-[90%]"> + <ConditionalQuestions bucket={bucket}/> + </div> + ) + + case "ineligible": return ( <div className="absolute bottom-2 left-1/2 transform -translate-x-1/2 w-[90%]"> <Button - variant="secondary" className="w-full h-8 cursor-pointer" + variant="outline" + disabled > - Check Eligibility + Sorry, you're ineligible </Button> </div> ) - - case "ineligible": - return <button type="button"> - Ineligible Survey - </button>; } } diff --git a/src/store.ts b/src/store.ts index 4e56b39..cfa64ec 100644 --- a/src/store.ts +++ b/src/store.ts @@ -4,6 +4,8 @@ import bucketReducers from "@/models/bucketSlice.ts" import questionReducers from "@/models/questionSlice.ts" import appReducers from "@/models/appSlice.ts" import answerReducers from "@/models/answerSlice.ts" +import cashoutMethodReducers from "@/models/cashoutMethodSlice.ts" +import walletReducers from "@/models/walletSlice.ts" export const store = configureStore({ reducer: { @@ -16,7 +18,10 @@ export const store = configureStore({ // - Read Write // -- This stores user engagement (eg: answering any questions) - answers: answerReducers + answers: answerReducers, + + cashoutMethods: cashoutMethodReducers, + wallet: walletReducers } }) |
