aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMax Nanis2025-06-09 04:44:45 +0700
committerMax Nanis2025-06-09 04:44:45 +0700
commita674d2e03de3bd048714d9c06e4bba9d9ecdb328 (patch)
tree130a07e0cc631a81b560c847ed7794d470c25e22
parent438585f6e6cdebc3089739ddf77382aebe09fac1 (diff)
downloadpanel-ui-a674d2e03de3bd048714d9c06e4bba9d9ecdb328.tar.gz
panel-ui-a674d2e03de3bd048714d9c06e4bba9d9ecdb328.zip
Offerwall page - using datatables to show the bucket contents, setting up tabs to allow overview and detail insights of buckets, formatting of height and layout, playing with iqr, connection to conditionally eligible sidebar
-rw-r--r--package.json1
-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
-rw-r--r--tailwind.config.js22
7 files changed, 355 insertions, 69 deletions
diff --git a/package.json b/package.json
index 42126e8..aeebba9 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"@radix-ui/react-tooltip": "^1.2.4",
"@reduxjs/toolkit": "^2.8.2",
"@tailwindcss/vite": "^4.1.4",
+ "@tanstack/react-table": "^8.21.3",
"@toolpad/core": "^0.14.0",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
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
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..2acc435
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,22 @@
+// tailwind.config.js
+module.exports = {
+ content: [
+ "./src/**/*.{js,ts,jsx,tsx}",
+ "./components/**/*.{js,ts,jsx,tsx}"
+ ],
+ plugins: [],
+
+ theme: {
+ extend: {
+ keyframes: {
+ glow: {
+ '0%, 100%': { background: 'rgba(0, 255, 255, 0.0)' },
+ '50%': { background: 'rgba(0, 255, 255, 0.6)' },
+ },
+ },
+ animation: {
+ glow: 'glow 2s ease-in-out infinite',
+ },
+ },
+ },
+}