diff options
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/index.css | 3 | ||||
| -rw-r--r-- | src/lib/snippets.tsx | 80 | ||||
| -rw-r--r-- | src/lib/utils.ts | 26 | ||||
| -rw-r--r-- | src/models/questionSlice.ts | 7 | ||||
| -rw-r--r-- | src/pages/Offerwall.tsx | 285 | ||||
| -rw-r--r-- | tailwind.config.js | 22 |
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', + }, + }, + }, +} |
