aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/index.css3
-rw-r--r--src/lib/snippets.tsx80
-rw-r--r--src/lib/utils.ts26
-rw-r--r--src/models/questionSlice.ts7
-rw-r--r--src/pages/Offerwall.tsx285
5 files changed, 332 insertions, 69 deletions
diff --git a/src/index.css b/src/index.css
index 9e80986..e3beb23 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,5 +1,8 @@
@import "tailwindcss";
@import "tw-animate-css";
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
#testD4rN {
max-width: 800px;
diff --git a/src/lib/snippets.tsx b/src/lib/snippets.tsx
new file mode 100644
index 0000000..5e95cd2
--- /dev/null
+++ b/src/lib/snippets.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+
+type IQRData = {
+ min: number;
+ q1: number;
+ median: number;
+ q3: number;
+ max: number;
+};
+
+type IQRBoxPlotProps = {
+ data: IQRData;
+};
+
+export const IQRBoxPlot: React.FC<IQRBoxPlotProps> = ({data}) => {
+ const {min, q1, median, q3, max} = data;
+
+ const scale = (value: number): number =>
+ ((value - min) / (max - min)) * 100;
+
+ return (
+ <div className="w-full p-4">
+ <div className="text-sm mb-2 text-muted-foreground">Box Plot</div>
+ <svg viewBox="0 0 100 20" className="w-full h-12">
+ <line
+ x1={scale(min)}
+ x2={scale(q1)}
+ y1={10}
+ y2={10}
+ stroke="#999"
+ strokeWidth={1}
+ />
+
+ <line
+ x1={scale(q3)}
+ x2={scale(max)}
+ y1={10}
+ y2={10}
+ stroke="#999"
+ strokeWidth={1}
+ />
+
+ <rect
+ x={scale(q1)}
+ y={5}
+ width={scale(q3) - scale(q1)}
+ height={10}
+ fill="#e2e8f0"
+ stroke="#64748b"
+ strokeWidth={1}
+ />
+
+ <line
+ x1={scale(median)}
+ x2={scale(median)}
+ y1={5}
+ y2={15}
+ stroke="#1e293b"
+ stroke-width={1.5}
+ />
+
+ <line
+ x1={scale(min)}
+ x2={scale(min)}
+ y1={7}
+ y2={13}
+ stroke="#666"
+ strokeWidth={1}/>
+
+ <line
+ x1={scale(max)}
+ x2={scale(max)}
+ y1={7}
+ y2={13}
+ stroke="#666"
+ strokeWidth={1}/>
+ </svg>
+ </div>
+ )
+}; \ No newline at end of file
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 8525468..b1cd120 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,5 +1,6 @@
import {type ClassValue, clsx} from "clsx"
import {twMerge} from "tailwind-merge"
+import React from "react";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -9,4 +10,27 @@ export function assert(condition: any, msg?: string): asserts condition {
if (!condition) {
throw new Error(msg);
}
-} \ No newline at end of file
+}
+
+export function formatSecondsVerbose(seconds: number): string {
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ const parts = []
+ if (mins > 0) parts.push(`${mins} min`)
+ if (secs > 0 || mins === 0) parts.push(`${secs} sec`)
+ return parts.join(" ")
+}
+
+export function formatSeconds(seconds: number): string {
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ const paddedSecs = secs.toString().padStart(2, '0')
+ return `${mins}:${paddedSecs}`
+}
+
+export function formatCentsToUSD(cents: number): string {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ }).format(cents / 100)
+}
diff --git a/src/models/questionSlice.ts b/src/models/questionSlice.ts
index a617234..3ef2218 100644
--- a/src/models/questionSlice.ts
+++ b/src/models/questionSlice.ts
@@ -15,8 +15,13 @@ const questionSlice = createSlice({
name: 'questions',
initialState,
reducers: {
+ // setQuestions(state, action: PayloadAction<ProfileQuestion[]>) {
+ // return action.payload;
+ // },
setQuestions(state, action: PayloadAction<ProfileQuestion[]>) {
- return action.payload;
+ const existingIds = new Set(state.map(q => q.question_id));
+ const newQuestions = action.payload.filter(q => !existingIds.has(q.question_id));
+ state.push(...newQuestions);
},
questionAdded(state, action: PayloadAction<ProfileQuestion>) {
state.push(action.payload);
diff --git a/src/pages/Offerwall.tsx b/src/pages/Offerwall.tsx
index 178e762..0d0534e 100644
--- a/src/pages/Offerwall.tsx
+++ b/src/pages/Offerwall.tsx
@@ -1,30 +1,46 @@
-import React from 'react'
-import {Separator} from "@/components/ui/separator"
+import React, {useState} from 'react'
+import { motion } from "framer-motion";
+import {Badge} from "@/components/ui/badge"
import {Link} from '@mui/material';
-import {useSelector} from "react-redux";
-
-import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx";
-import {ScrollArea} from "@/components/ui/scroll-area.tsx";
-import {makeSelectQuestionsByIds, setNextQuestion, setQuestions} from "@/models/questionSlice.ts"
-import {CheckIcon, MessageCircleQuestionIcon, XIcon} from "lucide-react"
+import {Tabs, TabsContent} from "@/components/ui/tabs"
+import {Button} from "@/components/ui/button"
import {
BodyOfferwallSoftpairPostProductIdOfferwall37d1da64OfferwallIdPost,
+ BucketTask,
OfferwallApi,
SoftPairBucket,
UserQuestionAnswerIn
} from "@/api"
+
+import {ColumnDef, flexRender, getCoreRowModel, useReactTable,} from "@tanstack/react-table"
+
+import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
+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 {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, saveAnswer, selectAnswerForQuestion, submitAnswer} from "@/models/answerSlice.ts";
-import {assert} from "@/lib/utils.ts";
+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";
+interface DataTableProps<TData, TValue> {
+ columns: ColumnDef<TData, TValue>[]
+ data: TData[]
+}
+
const BucketStatus: React.FC<SoftPairBucket> = ({bucket}) => {
switch (bucket.eligibility) {
- case "eligible":
- return <CheckIcon/>;
+ case "unconditional":
+ return <
+ CheckIcon
+ xlinkTitle="you are good"
+ />;
case "conditional":
return <MessageCircleQuestionIcon/>;
case "ineligible":
@@ -32,6 +48,72 @@ const BucketStatus: React.FC<SoftPairBucket> = ({bucket}) => {
}
}
+const ContentsGrid: React.FC<SoftPairBucket> = ({bucket}) => {
+
+ const columns: ColumnDef<BucketTask>[] = [
+ {
+ accessorKey: "loi",
+ header: "Length",
+ cell: ({ getValue }) => formatSeconds(getValue() as number),
+ },
+ {
+ accessorKey: "payout",
+ header: "Payout",
+ cell: ({ getValue}) => formatCentsToUSD(getValue() as number)
+ },
+ ]
+
+ const data: BucketTask[] = bucket.contents
+
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ 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" >
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => {
+ return (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ )
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
+
+ <TableBody>
+ {
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ }
+ </TableBody>
+ </Table>
+ </div>
+ )
+}
+
const ConditionalQuestions: React.FC<SoftPairBucket> = ({bucket}) => {
const dispatch = useAppDispatch()
@@ -102,14 +184,34 @@ const ConditionalQuestions: React.FC<SoftPairBucket> = ({bucket}) => {
const CallToAction: React.FC<SoftPairBucket> = ({bucket}) => {
switch (bucket.eligibility) {
- case "eligible":
- return <Link href={bucket.uri}>
- <button type="button">
- Start Survey
- </button>
- </Link>;
+ case "unconditional":
+ return (
+ <div className="absolute bottom-2 left-1/2 transform -translate-x-1/2 w-[90%]">
+ <Link
+ href={bucket.uri}
+ target="_blank"
+ className="w-full h-8 cursor-pointer"
+ >
+ <Button
+ className="w-full h-8 cursor-pointer"
+ >
+ Start Survey
+ </Button>
+ </Link>
+ </div>
+ )
case "conditional":
- return <ConditionalQuestions bucket={bucket}/>
+ // return <ConditionalQuestions bucket={bucket}/>
+ 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"
+ >
+ Check Eligibility
+ </Button>
+ </div>
+ )
case "ineligible":
return <button type="button">
@@ -118,63 +220,112 @@ const CallToAction: React.FC<SoftPairBucket> = ({bucket}) => {
}
}
-const Offerwall = () => {
- const buckets = useAppSelector(state => state.buckets)
+const ConditionalBucket: React.FC<SoftPairBucket> = ({bucket}) => {
+ const [tab, setTab] = useState("bucket_cta")
+ const toggleTab = () => setTab(tab === "bucket_cta" ? "bucket_details" : "bucket_cta")
return (
- <div className="grid grid-cols-2 gap-2 p-2">
+ <Card
+ key={bucket.id}
+ className="@container/card relative overflow-hidden h-full min-h-[140px] flex flex-col justify-between"
+
+ >
+
+ <Badge
+ className="absolute top-1 left-1 h-5 min-w-5 rounded-full px-1 font-mono tabular-nums cursor-pointer"
+ variant="outline"
+ title={`There are ${bucket.contents.length.toLocaleString()} surveys in this bucket`}
+ >{bucket.contents.length.toLocaleString()}
+ </Badge>
+
+ <div
+ className="absolute top-0.5 right-0.5 h-4"
+ >
+ <Switch id="mode"
+ checked={tab === "bucket_details"}
+ onCheckedChange={toggleTab}/>
+ </div>
+
+ <CardContent className="flex items-center gap-2">
+ <Tabs
+ value={tab} onValueChange={setTab}
+ defaultValue="bucket_cta"
+ className="w-full"
+ >
+ <TabsContent value="bucket_cta">
+ <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="text-2xl font-bold text-center"
+ >
+ {formatCentsToUSD(bucket.payout)}
+ </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-2xl font-bold text-center"
+ >
+ {formatSeconds(bucket.loi)}
+ </motion.h2>
- {buckets.map((bucket) => (
- <Card key={`${bucket.id}`}>
- <CardHeader>
- <BucketStatus bucket={bucket}/>
- <CardTitle>Card 1</CardTitle>
- </CardHeader>
-
- <CardContent className="flex items-center gap-2">
- {/*<StarIcon className="w-5 h-5 fill-[#FFD700]"/>*/}
- {/*<span className="text-sm">4.5</span>*/}
<CallToAction bucket={bucket}/>
- </CardContent>
-
- <CardFooter>
- {/*<PieChart width={100} height=60}>*/}
- {/* <Pie*/}
- {/* dataKey="p"*/}
- {/* startAngle={180}*/}
- {/* endAngle={0}*/}
- {/* data={bucket.category}*/}
- {/* cx="50%"*/}
- {/* cy="50%"*/}
- {/* // onMouseEnter={this.onPieEnter}*/}
- {/* // outerRadius={80}*/}
- {/* // label*/}
- {/* />*/}
- {/*</PieChart>*/}
-
- <ScrollArea className="w-48 rounded-md border">
- <div className="p-4">
- <h4 className="mb-4 text-sm font-medium leading-none">Tags</h4>
- {bucket.contents.map((survey) => (
- <>
- <div key={`${bucket.id}-${survey.id}`} className="text-sm">
- {survey.id_code} - {survey.loi} seconds - {survey.payout} cents
- </div>
- <Separator className="my-2"/>
- </>
- ))}
- </div>
- </ScrollArea>
+ </TabsContent>
- </CardFooter>
+ <TabsContent value="bucket_details">
+ <ContentsGrid bucket={bucket}></ContentsGrid>
+ </TabsContent>
+ </Tabs>
- </Card>
- ))}
+ {/*<StarIcon className="w-5 h-5 fill-[#FFD700]"/>*/}
+ {/*<span className="text-sm">4.5</span>*/}
- </div>
+ </CardContent>
+
+ <CardFooter>
+ {/*<PieChart width={100} height=60}>*/}
+ {/* <Pie*/}
+ {/* dataKey="p"*/}
+ {/* startAngle={180}*/}
+ {/* endAngle={0}*/}
+ {/* data={bucket.category}*/}
+ {/* cx="50%"*/}
+ {/* cy="50%"*/}
+ {/* // onMouseEnter={this.onPieEnter}*/}
+ {/* // outerRadius={80}*/}
+ {/* // label*/}
+ {/* />*/}
+ {/*</PieChart>*/}
+ </CardFooter>
+ </Card>
+ )
+}
+
+const Offerwall = () => {
+ const buckets = useAppSelector(state => state.buckets)
+
+ return (
+ <div className="grid grid-cols-3 gap-2 p-1">
+ {buckets.map((bucket) => (
+ <ConditionalBucket key={bucket.id} bucket={bucket}/>
+ ))}
+ </div>
)
}
-export {Offerwall} \ No newline at end of file
+export {
+ Offerwall
+} \ No newline at end of file