aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Widget.tsx22
-rw-r--r--src/components/app-sidebar.tsx31
-rw-r--r--src/models/app.ts2
-rw-r--r--src/models/cashoutMethodSlice.ts78
-rw-r--r--src/models/walletSlice.ts23
-rw-r--r--src/pages/CashoutMethods.tsx207
-rw-r--r--src/pages/Offerwall.tsx89
-rw-r--r--src/store.ts7
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
}
})