aboutsummaryrefslogtreecommitdiff
path: root/jb-ui/src/components
diff options
context:
space:
mode:
authorMax Nanis2026-02-18 20:42:03 -0500
committerMax Nanis2026-02-18 20:42:03 -0500
commit3eaa56f0306ead818f64c3d99fc6d230d9b970a4 (patch)
tree9fecc2f1456e6321572e0e65f57106916df173e2 /jb-ui/src/components
downloadamt-jb-3eaa56f0306ead818f64c3d99fc6d230d9b970a4.tar.gz
amt-jb-3eaa56f0306ead818f64c3d99fc6d230d9b970a4.zip
HERE WE GO, HERE WE GO, HERE WE GO
Diffstat (limited to 'jb-ui/src/components')
-rw-r--r--jb-ui/src/components/Footer.tsx36
-rw-r--r--jb-ui/src/components/Leaderboard.tsx0
-rw-r--r--jb-ui/src/components/Profiling.tsx759
-rw-r--r--jb-ui/src/components/Wallet.tsx464
-rw-r--r--jb-ui/src/components/ui/button.tsx64
-rw-r--r--jb-ui/src/components/ui/checkbox.tsx30
-rw-r--r--jb-ui/src/components/ui/confetti.tsx146
-rw-r--r--jb-ui/src/components/ui/dot-pattern.tsx156
-rw-r--r--jb-ui/src/components/ui/field.tsx246
-rw-r--r--jb-ui/src/components/ui/label.tsx22
-rw-r--r--jb-ui/src/components/ui/magic-card.tsx101
-rw-r--r--jb-ui/src/components/ui/pagination.tsx127
-rw-r--r--jb-ui/src/components/ui/separator.tsx28
-rw-r--r--jb-ui/src/components/ui/skeleton.tsx13
14 files changed, 2192 insertions, 0 deletions
diff --git a/jb-ui/src/components/Footer.tsx b/jb-ui/src/components/Footer.tsx
new file mode 100644
index 0000000..9248ea0
--- /dev/null
+++ b/jb-ui/src/components/Footer.tsx
@@ -0,0 +1,36 @@
+import { useAppSelector } from "@/hooks";
+
+const Footer = () => {
+ const bpuid = useAppSelector(state => state.app.bpuid)
+ const assignment_id = useAppSelector(state => state.app.assignment_id)
+
+ return (
+ <div className="grid gap-1 grid-cols-1
+ md:grid-cols-3
+ text-xs">
+ <div>
+ <p>help: <a
+ href={`mailto:support@jamesbillings67.com?subject=AMT Support Request: (${(bpuid ?? '-')}) ${(assignment_id ?? '–')}`}>
+ support@jamesbillings67.com
+ </a>
+ </p>
+ </div>
+
+ <div>
+ <p>made with <span className="text-rose-500">♥</span> by <a
+ className="hover:cursor-pointer hover:text-pink"
+ href="https://instagram.com/x0xMaximus"
+ target="_blank">Max Nanis</a>
+ </p>
+ </div>
+
+ <div>
+ <p>open source</p>
+ </div>
+
+ </div>
+ )
+
+}
+
+export default Footer \ No newline at end of file
diff --git a/jb-ui/src/components/Leaderboard.tsx b/jb-ui/src/components/Leaderboard.tsx
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/jb-ui/src/components/Leaderboard.tsx
diff --git a/jb-ui/src/components/Profiling.tsx b/jb-ui/src/components/Profiling.tsx
new file mode 100644
index 0000000..9cefdda
--- /dev/null
+++ b/jb-ui/src/components/Profiling.tsx
@@ -0,0 +1,759 @@
+import {
+ UpkQuestionChoice
+} from "@/api_fsb";
+import {
+ ChevronRightIcon
+} from "lucide-react";
+import { KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react";
+
+import { useAppDispatch, useAppSelector } from "@/hooks";
+import { bpid } from "@/lib/utils";
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ RowModel,
+ RowSelectionState,
+ useReactTable,
+} from '@tanstack/react-table';
+import { clsx } from "clsx";
+
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink
+} from "@/components/ui/pagination";
+
+import {
+ addAnswers,
+ fetchNewQuestions,
+ goToNextQuestion,
+ ProfileQuestion,
+ questionsSelectors,
+ saveAnswer,
+ selectActiveQuestion,
+ selectActiveQuestionValidation,
+ selectAllQuestions,
+ setActiveQuestion
+} from "@/models/profilingQuestionsSlice";
+import { useSelector } from "react-redux";
+// import {motion} from "framer-motion"
+
+const TextEntry = () => {
+ const dispatch = useAppDispatch()
+
+ const activeQuestion = useSelector(selectActiveQuestion);
+ // const selectAnswer = useMemo(() => selectAnswerForQuestion(question), [question]);
+ // const selectAnswer = useSelector(selectAnswerForQuestion(question));
+ const metadata = activeQuestion?._metadata
+ const validation = activeQuestion?._validation
+ const bpuid = useAppSelector(state => state.app.bpuid)
+
+ if (!metadata) return null
+ if (!validation) return null
+
+ const error = validation?.errors.length > 0
+
+ const handleInputChange = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (!activeQuestion.question_id) return;
+
+ const target = event.target as HTMLInputElement;
+ dispatch(addAnswers({ questionId: activeQuestion.question_id!, answers: [target.value] as string[] }))
+ };
+
+ const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>) => {
+ if (!activeQuestion.question_id) return;
+ if (!bpuid) return;
+
+ if (event.key === 'Enter') {
+ dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }))
+ }
+ };
+
+ return (
+ <div className="px-6 my-6">
+ <input type="text"
+ id="text-entry-input"
+ aria-describedby=""
+ // defaultValue={answer?.values.length ? answer?.values[0] : ""}
+ onKeyUp={handleInputChange}
+ onKeyDown={handleKeyPress} // Use onKeyDown to listen for the key press
+ // title={error ? answer?.error_msg : ""}
+ className={clsx(
+ 'w-full border-1 rounded px-2 py-1',
+ error ?
+ 'border-red-500 focus-visible:ring-red-500' :
+ 'border-blue-50'
+ )}
+ />
+
+ {
+ validation.errors.map(e => {
+ return (
+ <p className="text-sm text-red-100 my-2">{e.message}</p>
+ )
+ })
+ }
+ </div>
+ )
+}
+
+
+export default function useKeyboardMode(
+ rowModel: RowModel<UpkQuestionChoice>,
+) {
+ // const [keyboardEnabled, setKeyboardEnabled] = useState(
+ // localStorage.getItem(keyboardMode) === "enabled",
+ // );
+ const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
+
+ const dispatch = useAppDispatch()
+ const activeQuestion = useSelector(selectActiveQuestion);
+ const bpuid = useAppSelector(state => state.app.bpuid)
+
+ const keyboardSelectionIdxRef = useRef(0);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+
+ if (!activeQuestion) return;
+ if (!bpuid) return;
+
+ // if (!keyboardEnabled) {
+ // return;
+ // }
+
+ const { key } = event;
+ const totalRows = rowRefs.current.length;
+ const prevIdx = keyboardSelectionIdxRef.current;
+ let nextIdx = prevIdx;
+
+ if (key === "ArrowDown") nextIdx = Math.min(prevIdx + 1, totalRows - 1);
+ if (key === "ArrowUp") nextIdx = Math.max(prevIdx - 1, 0);
+ if (key === " ") {
+ const active = document.activeElement as HTMLElement | null;
+
+ if (active?.tabIndex === 0) {
+ return;
+ } else {
+ rowModel.rows[keyboardSelectionIdxRef.current].toggleSelected();
+ }
+ }
+
+ if (event.key === 'Enter') {
+ dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }))
+ }
+
+
+ if (nextIdx !== prevIdx) {
+
+ rowRefs.current[prevIdx]?.classList.remove("bg-blue-400");
+ rowRefs.current[nextIdx]?.classList.add("bg-blue-400");
+
+ rowRefs.current[nextIdx]?.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ keyboardSelectionIdxRef.current = nextIdx;
+ }
+ };
+
+ // I really don't know, and I don't really care enough to make this
+ // technically type'd correctly. - Max
+
+ // @ts-ignore
+ window.addEventListener("keydown", handleKeyDown);
+ // @ts-ignore
+ return () => window.removeEventListener("keydown", handleKeyDown);
+
+ }, [rowModel.rows]);
+
+ return {
+ // keyboardEnabled,
+ // setKeyboardEnabled,
+ rowRefs,
+ keyboardSelectionIdxRef,
+ };
+}
+
+const MultipleChoice = () => {
+ const dispatch = useAppDispatch()
+ const activeQuestion = useSelector(selectActiveQuestion);
+
+ // const error: Boolean = answer?.error_msg.length > 0
+ // console.log("Rendering MultipleChoice with answer:", answer, error)
+
+ // TODO! Retrieve store values to use as the initial state for the table
+ // This is useful for if they tab around before submitting the answer
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+ const [globalFilter, setGlobalFilter] = useState('');
+
+ const handleRowSelectionChange = (updaterOrValue: RowSelectionState | ((old: RowSelectionState) => RowSelectionState)) => {
+ setRowSelection((oldSelection) => {
+ // Get the new selection state
+ const newSelection = typeof updaterOrValue === 'function'
+ ? updaterOrValue(oldSelection)
+ : updaterOrValue;
+
+ // Find which row was just selected/deselected
+ const changedRowId = Object.keys(newSelection).find(
+ key => newSelection[key] !== oldSelection[key]
+ );
+
+ if (!changedRowId) return newSelection;
+
+ // Get the actual row data
+ const row = table.getRow(changedRowId);
+ const rowData = row.original; // Your row data object
+
+ // Check if "None of the Above" was selected
+ const normalized = rowData.choice_text.trim().toLowerCase();
+ const isNoneOfTheAbove = (normalized === "none of the above") || (rowData.exclusive ?? false);
+ if (isNoneOfTheAbove && newSelection[changedRowId]) {
+ // Clear all other selections, keep only this one
+ return { [changedRowId]: true };
+ }
+
+ // Check if a regular option was selected while "None of the Above" exists
+ const noneOfTheAboveId = Object.keys(newSelection).find(id => {
+ const r = table.getRow(id);
+ const rowData = r.original as UpkQuestionChoice;
+
+ const normalized = rowData.choice_text.trim().toLowerCase();
+ const isNoneOfTheAbove = (normalized === "none of the above") || (rowData.exclusive ?? false);
+
+ return isNoneOfTheAbove && newSelection[id];
+ });
+
+ if (noneOfTheAboveId && changedRowId !== noneOfTheAboveId) {
+ // Remove "None of the Above" selection
+ const { [noneOfTheAboveId]: _, ...rest } = newSelection;
+ return rest;
+ }
+
+ return newSelection;
+ });
+ };
+
+ useEffect(() => {
+ if (!activeQuestion?.question_id) return;
+ // Run this anytime a rowSelection changes, to update
+ // the answer in the store
+ const selectedChoices = Object.keys(rowSelection)
+ dispatch(addAnswers({ questionId: activeQuestion.question_id, answers: selectedChoices }))
+ }, [rowSelection])
+
+ // Define columns
+ const columnHelper = createColumnHelper<UpkQuestionChoice>()
+ const columns = [
+ columnHelper.display({
+ id: 'select',
+ cell: ({ row }) => {
+ const canSelected = row.getCanSelect()
+
+ return (
+ <input
+ type="checkbox"
+ checked={row.getIsSelected()}
+ disabled={!row.getCanSelect()}
+ onChange={row.getToggleSelectedHandler()}
+ className={`w-4 h-4 rounded-0 border-slate-300 cursor-pointer
+ focus:outline-none focus:border-blue-500 focus:ring-blue-500
+ ${canSelected ? ' ' : 'hidden'}
+ `}
+ />
+ )
+ },
+ size: 20,
+ meta: {
+ align: 'center'
+ }
+ }),
+
+ columnHelper.accessor("choice_text", {
+ header: () => 'Choice Text',
+ cell: (info) => {
+ const text = info.getValue();
+ const selected = info.row.getIsSelected()
+ const canSelected = info.row.getCanSelect()
+
+ return (
+ <p className={`
+ w-max truncate
+ text-sm text-left pl-1
+ ${selected ? 'font-bold' : 'font-medium'}
+ ${canSelected ? 'text-blue-950' : 'text-blue-600'}
+ `}>
+ {text}
+ </p>
+ )
+ },
+ size: 380,
+ meta: {
+ align: 'left'
+ }
+ }),
+ ]
+
+ const table = useReactTable({
+ 'data': activeQuestion!.choices ?? [],
+ columns,
+ getRowId: (row) => row.choice_id,
+ state: {
+ rowSelection,
+ globalFilter,
+ },
+ // enableRowSelection: true,
+ enableRowSelection: (row) => {
+ // Allow deselection always, but limit new selections
+ const currentSelectionCount = Object.keys(rowSelection).length;
+ const isRowSelected = rowSelection[row.id];
+
+ const max_selection = activeQuestion?.selector === "SA" ? 1 : activeQuestion?.choices?.length ?? 10
+
+ // Allow if: row is already selected OR we haven't hit the limit
+ return isRowSelected || currentSelectionCount < max_selection;
+ },
+ onRowSelectionChange: handleRowSelectionChange,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ });
+
+ const selectedCount = table.getSelectedRowModel().rows.length;
+
+ //const { rowRefs, setKeyboardEnabled, keyboardSelectionIdxRef } = useKeyboardMode(table.getRowModel());
+ const { rowRefs } = useKeyboardMode(table.getRowModel());
+
+ return (
+ <>
+
+ {/* Controls */}
+ <div className="py-1">
+ <div className="flex flex-wrap gap-2">
+
+ {/* Search */}
+ <div className="flex-1">
+ <input
+ type="text"
+ placeholder="Search choices..."
+ value={globalFilter ?? ''}
+ onChange={e => setGlobalFilter(e.target.value)}
+ className="w-full px-4 py-1
+ border border-blue-900 bg-blue-400 rounded
+ text-white text-sm
+ focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ />
+ </div>
+
+ {/* Selected Count */}
+ {selectedCount > 0 && (
+ <div className="flex items-center gap-2 px-2 py-1
+ border border-blue-900 text-blue-700 rounded font-medium
+ text-[10px]
+ hover:cursor-pointer hover:bg-blue-800 hover:text-white
+ "
+ onClick={() => setRowSelection({})}>
+ <span>{selectedCount} selected</span>
+ <button className="m-0 p-0 font-bold">✕</button>
+ </div>
+ )}
+
+ </div>
+ </div>
+
+ {/* Table */}
+ <div className="border border-blue-200 rounded max-h-[400px] overflow-y-auto
+ [&::-webkit-scrollbar]:h-[4px]
+ [&::-webkit-scrollbar]:w-1
+
+ [&::-webkit-scrollbar-track]:bg-blue-100
+ [&::-webkit-scrollbar-thumb]:bg-blue-300
+ ">
+ <table className="text-xs table-fixed border-collapse w-full">
+ <tbody className="w-full">
+ {table.getRowModel().rows.map((row, idx) => (
+ <tr key={row.id}
+ ref={(el) => {
+ rowRefs.current[idx] = el;
+ }}
+ onClick={() => row.toggleSelected()}
+ className={`cursor-pointer transition-colors w-full
+ ${row.getIsSelected() ? 'bg-blue-600 ' : ' '
+ }`}
+ >
+
+ {row.getVisibleCells().map((cell) => {
+ const align = cell.column.columnDef.meta?.align || 'left';
+ const alignClass = align === 'center' ? 'text-center' :
+ align === 'right' ? 'text-right' : 'text-left';
+
+ return (
+ <td key={cell.id}
+ className={`py-2 ${alignClass}`}
+ style={{
+ width: `${cell.column.getSize()}px`,
+ }}
+ >
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </td>
+ )
+ })}
+
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ {/* Empty State */}
+ {table.getRowModel().rows.length === 0 && (
+ <div className="p-12 text-center text-blue-50 font-bold">
+ No items found matching your search
+ </div>
+ )}
+
+ {/* Footer */}
+ <div className="px-2 py-2 flex justify-between items-center
+ text-xs font-mono font-semibold text-blue-950
+ ">
+ <span>
+ Showing {table.getRowModel().rows.length} of {activeQuestion?.choices?.length} choices
+ </span>
+ {selectedCount > 0 && (
+ <span>
+ {selectedCount} choice{selectedCount !== 1 ? 's' : ''} selected
+ </span>
+ )}
+ </div>
+
+ </>
+ )
+
+}
+
+const ProfilingQuestionTitle = () => {
+ const activeQuestion = useSelector(selectActiveQuestion);
+ const question_active_tasks = activeQuestion?.importance?.task_count ?? 0
+
+ if (!activeQuestion) return null;
+
+ let text = activeQuestion?.question_text
+
+ if (activeQuestion.explanation_template && activeQuestion._validation?.isComplete && activeQuestion._metadata.answers.length == 1) {
+ const template = activeQuestion.explanation_template;
+ const pre_code = activeQuestion._metadata.answers[0];
+
+ const choice = activeQuestion.choices?.find(c => c.choice_id === pre_code)
+ const choice_text = choice ? choice.choice_text : "your answer"
+
+ text = template.replace("{answer}", choice_text)
+ }
+
+
+ return (
+ <div className="flex justify-between items-center px-2">
+
+ <h3 className="text-left text-sm font-bold m-0 p-0
+ inline-block
+ ">
+ {text}
+ </h3>
+
+ <div className="max-w-[70px] text-center m-0 p-0">
+ <h4 className="text-xs font-bold">
+ {question_active_tasks.toLocaleString()}
+ </h4>
+ <h5 className="text-[10px]">
+ Surveys use
+ this question
+ </h5>
+ </div>
+ </div>
+ )
+}
+
+const ProfilingQuestionSubmitButton = () => {
+ const dispatch = useAppDispatch()
+
+ const activeQuestion = useSelector(selectActiveQuestion);
+ const activeQuestionValidation = useSelector(selectActiveQuestionValidation)
+ const bpuid = useAppSelector(state => state.app.bpuid)
+
+ const handleOnClick = () => {
+ if (!activeQuestion) return
+ if (!activeQuestionValidation?.isComplete) return
+ if (!bpuid) return;
+
+ dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }))
+ }
+
+ return (
+ <button
+ type="submit"
+ className="cursor-pointer text-sm font-semibold
+ bg-blue-700 text-white py-1 px-4 rounded
+ hover:bg-blue-900 min-w-[148px]
+
+ disabled:bg-blue-700 disabled:text-rose-300
+ disabled:cursor-not-allowed
+ "
+ disabled={!activeQuestionValidation?.isComplete}
+ onClick={handleOnClick}
+ >
+ Answer Question
+ </button>
+ )
+}
+
+
+export const ProfileQuestionFull = () => {
+ const activeQuestion = useSelector(selectActiveQuestion);
+
+ const renderContent = () => {
+ if (!activeQuestion) return null;
+ switch (activeQuestion.question_type) {
+ case 'TE':
+ return <TextEntry />
+ case 'MC':
+ return <MultipleChoice />
+ default:
+ return <MultipleChoice />
+ }
+ };
+
+ if (!activeQuestion) return null
+
+ return (
+ <>
+ {/* {answer && answer.processing && (
+ <motion.div
+ className="bg-gray-300"
+ initial={{ width: "0%" }}
+ animate={{ width: "100%" }}
+ transition={{ duration: 1, ease: "easeInOut" }}
+ // onAnimationComplete={() => setLoading(false)}
+ />
+ )} */}
+
+ {renderContent()}
+ </>
+ )
+}
+
+const PaginationIcon: React.FC<{
+ question: ProfileQuestion, idx: number,
+}> = ({ question, idx }) => {
+ const dispatch = useAppDispatch()
+
+ // const answers = useAppSelector(state => state.answers)
+ const isActive = question._metadata?.isActive ?? false
+ const completed = question._validation?.isComplete ?? false
+
+ const handleSetQuestion = (evt: MouseEvent<HTMLAnchorElement>) => {
+ if (completed) {
+ evt.preventDefault()
+ } else {
+ dispatch(setActiveQuestion(question.question_id!))
+ }
+ }
+
+ return (
+ <PaginationItem>
+ <PaginationLink
+ href="#"
+ title={question.question_text}
+ isActive={isActive}
+ aria-disabled={!!completed}
+
+ onClick={handleSetQuestion}
+ className={clsx("cursor-pointer rounded border border-blue-300 text-blue-300 text-[10px] w-7 h-4",
+ {
+ "pointer-events-none cursor-not-allowed": completed,
+ "bg-blue-800 border-blue-800": isActive,
+ })}
+ >
+ {idx + 1}
+ </PaginationLink>
+ </PaginationItem>
+ )
+}
+
+const ProfilingQuestionPagination = () => {
+ const dispatch = useAppDispatch()
+
+ const questions = useSelector(selectAllQuestions)
+ const activeQuestion = useSelector(selectActiveQuestion)
+
+ // All the variables needed for a sliding window
+ const q_idx = questions.findIndex(q => q.question_id === activeQuestion!.question_id)
+
+ const handleNextClick = () => {
+ dispatch(goToNextQuestion());
+ };
+
+ const questionsWindow = (
+ items: ProfileQuestion[], currentIndex: number, windowSize: number = 5
+ ): ProfileQuestion[] => {
+ const half: number = Math.floor(windowSize / 2)
+ const total: number = items.length
+ let start: number = currentIndex - half
+ let end: number = currentIndex + half + 1
+
+ if (start < 0) {
+ end += Math.abs(start)
+ start = 0
+ }
+
+ // Adjust if window goes past the end
+ if (end > total) {
+ const overflow: number = end - total
+ start = Math.max(0, start - overflow)
+ end = total
+ }
+
+ return items.slice(start, end)
+ }
+
+ return (
+ <Pagination>
+ <PaginationContent>
+ {
+ questionsWindow(questions, q_idx).map(q => {
+ return <PaginationIcon
+ key={q.question_id}
+ question={q}
+ idx={questions.findIndex(qq => qq.question_id === q.question_id)}
+ />
+ })
+ }
+
+ <PaginationItem>
+ <PaginationLink
+ aria-label="Go to next page"
+ size="default"
+
+ className="rounded text-[10px] p-0 m-0
+ text-blue-300
+ border border-blue-100 text-blue-100
+ hover:bg-blue-800 hover:border-blue-800 hover:text-white
+ gap-1 px-2.5 sm:pr-2.5
+ h-4
+ w-13 sm:w-14
+ "
+ onClick={handleNextClick}
+ href="#"
+ >
+ <span className="block">Next</span>
+ <ChevronRightIcon />
+ </PaginationLink>
+
+ </PaginationItem>
+ </PaginationContent>
+ </Pagination>
+ )
+}
+
+const ProfilingHeader = () => {
+ const questions = useSelector(questionsSelectors.selectAll);
+
+ return (
+ <h2 className="text-xs font-mono font-bold p-0 m-0">
+ {questions.length} Profiling Questions
+ </h2>
+ )
+}
+
+const ProfilingModalBody = () => {
+ const activeQuestion = useSelector(selectActiveQuestion);
+
+ if (!activeQuestion) {
+ return (
+ <div className="p-5">
+ <h3 className="text-lg font-bold">No Profiling Questions Available</h3>
+ <p className="my-0 text-sm text-gray-600">Please check back later.</p>
+ </div>
+ )
+ }
+
+ return (
+ <div className="relative flex flex-col">
+ {/* <ProfilingQuestionPagination /> */}
+
+ <div className="flex-shrink-0">
+ <ProfilingQuestionTitle />
+ </div>
+
+ {/* Scrollable Content */}
+ <div className="flex-1 p-1">
+ <ProfileQuestionFull key={activeQuestion.question_id} />
+ </div>
+
+ {/* Sticky Footer */}
+ <div className="flex-shrink-0 bg-blue-600 p-1 flex justify-between
+ ">
+ <div className="justify-start">
+ <ProfilingQuestionPagination />
+ </div>
+ <div className="justify-end">
+ <ProfilingQuestionSubmitButton />
+ </div>
+ </div>
+
+ </div>
+ )
+}
+
+const Profiling = () => {
+ /* This is the main UI for any User Profiling (before entering a task). It's
+ primary window and position should be dedictated by it's parent so this
+ can be positioned as a window or a sidebar as needed.
+ */
+ const [showProfiling, setShowProfiling] = useState(false);
+
+ const dispatch = useAppDispatch()
+ const bpuid = useAppSelector(state => state.app.bpuid)
+
+ useEffect(() => {
+ if (!bpuid) return;
+ dispatch(fetchNewQuestions(bpuid));
+ }, [bpid, bpuid])
+
+ if (!bpuid) return null;
+
+ return (
+ <div className={clsx(
+ "fixed rounded bg-blue-500 text-white shadow-lg z-60 \
+ bottom-1 sm:bottom-6 \
+ left-1 sm:left-2",
+ showProfiling ? "w-[98vw] sm:w-[400px]" : "w-[220px]"
+ )}
+ >
+
+ <div className="bg-blue-600 rounded border-white-300
+ cursor-pointer py-1 px-4
+ hover:bg-blue-700 transition-colors duration-200"
+ onClick={() => setShowProfiling(!showProfiling)}
+ >
+ <ProfilingHeader />
+
+ <h4 className="text-[10px] text-center italic text-white-300 py-0">
+ (Click to {showProfiling ? 'hide' : 'show'})
+ </h4>
+ </div>
+
+ {showProfiling && (
+ <ProfilingModalBody />
+ )}
+
+ </div>
+ )
+}
+
+export {
+ Profiling
+};
diff --git a/jb-ui/src/components/Wallet.tsx b/jb-ui/src/components/Wallet.tsx
new file mode 100644
index 0000000..e4fc694
--- /dev/null
+++ b/jb-ui/src/components/Wallet.tsx
@@ -0,0 +1,464 @@
+import {
+ UserLedgerTransactionsResponse,
+ UserLedgerTransactionsResponseTransactionsInner,
+ WalletApi
+} from "@/api_fsb";
+import { useAppDispatch, useAppSelector } from "@/hooks";
+import { bpid, formatCentsToUSD, } from "@/lib/utils";
+import {
+ setTxPagination, setTxTotalItems, setTxTotalPages,
+ setUserLedgerSummary,
+ setUserLedgerTxs
+} from "@/models/appSlice";
+import {
+ RowData, createColumnHelper, flexRender, getCoreRowModel,
+ useReactTable
+} from "@tanstack/react-table";
+import moment from "moment";
+import { useEffect, useState } from "react";
+
+declare module '@tanstack/react-table' {
+ interface ColumnMeta<TData extends RowData, TValue> {
+ align?: 'left' | 'center' | 'right';
+ }
+}
+
+// Function to calculate background color based on balance
+const getBackgroundColor = (balance: number | undefined): string => {
+ if (balance === undefined || balance === null) return 'bg-blue-600';
+
+ if (balance > 0) {
+ return 'bg-blue-600';
+ } else if (balance <= -100) {
+ return 'bg-red-600';
+ } else {
+ // Interpolate between blue and red for values between 0 and -100
+ // progress goes from 0 (at balance = 0) to 1 (at balance = -100)
+ const progress = Math.abs(balance) / 100;
+
+ // Blue RGB: (37, 99, 235) - Tailwind blue-600
+ // Red RGB: (220, 38, 38) - Tailwind red-600
+ const r = Math.round(37 + (220 - 37) * progress);
+ const g = Math.round(99 + (38 - 99) * progress);
+ const b = Math.round(235 + (38 - 235) * progress);
+
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+};
+
+
+const WalletHeader = () => {
+ const amount = useAppSelector(state => state.app.userWalletBalance)?.amount ?? 0
+
+ const bgStyle = typeof getBackgroundColor(amount) === 'string' &&
+ getBackgroundColor(amount).startsWith('bg-')
+ ? {}
+ : { backgroundColor: getBackgroundColor(amount) };
+
+ const bgClass = typeof getBackgroundColor(amount) === 'string' &&
+ getBackgroundColor(amount).startsWith('bg-')
+ ? getBackgroundColor(amount)
+ : '';
+
+ return (
+ <div className={`w-full py-1 px-1 text-white rounded ${bgClass}`}
+ style={bgStyle}
+ >
+ <h2 className="text-xs font-semibold flex justify-between items-center">
+ <span className="text-left">User Wallet:</span>
+
+ {amount >= 0 ? (
+ <span className='text-emerald-50'>
+ {`${formatCentsToUSD(Math.abs(amount))}`}
+ </span>
+ ) : (
+ <span className='text-rose-50'>
+ {`(${formatCentsToUSD(Math.abs(amount))})`}
+ </span>
+ )}
+
+ </h2>
+ </div>
+ )
+};
+
+const WalletSummary = () => {
+ const summary = useAppSelector(state => state.app.userLedgerSummary)
+
+ const survey_completes = summary?.bp_payment?.entry_count ?? 0
+ const survey_completes_f = (survey_completes ?? 0).toLocaleString('en-US')
+
+ const survey_adjustments = summary?.bp_adjustment?.entry_count ?? 0
+ const survey_adjustments_f = (survey_adjustments ?? 0).toLocaleString('en-US')
+
+ const min_payout: number = summary?.bp_payment?.min_amount ?? 0
+ const max_payout: number = summary?.bp_payment?.max_amount ?? 0
+ const adj_total: number = summary?.bp_adjustment?.total_amount ?? 0
+ const user_payments_total: number = (summary?.user_payout_request?.total_amount ?? 0) * -1
+
+ return (
+ <div className="grid grid-cols-[auto_1fr] text-xs">
+ <div className="label text-left">Completes:</div>
+ <div className="value text-right">{survey_completes_f} surveys</div>
+
+ <div className="label text-left">Avg Payouts:</div>
+ <div className="value text-right">{formatCentsToUSD(min_payout)} – {formatCentsToUSD(max_payout)}</div>
+
+ <div className="label text-left">Survey Adjustments:</div>
+ <div className="value text-right">{survey_adjustments_f}</div>
+
+ <div className="label text-left">Adjustment Amount:</div>
+ <div className="value text-right">
+ {adj_total >= 0 ? (
+ <span className='text-emerald-300'>
+ {`${formatCentsToUSD(adj_total)}`}
+ </span>
+ ) : (
+ <span className='text-rose-300'>
+ {`(${formatCentsToUSD(Math.abs(adj_total))})`}
+ </span>
+ )}
+ </div>
+
+ <div className="label text-left">User Payments:</div>
+ <div className="value text-right">
+ {user_payments_total >= 0 ? (
+ <span className='text-emerald-300'>
+ {`${formatCentsToUSD(user_payments_total)}`}
+ </span>
+ ) : (
+ <span className='text-rose-300'>
+ {`(${formatCentsToUSD(Math.abs(user_payments_total))})`}
+ </span>
+ )}
+
+ </div>
+ </div >
+ )
+};
+
+const WalletTransactionsHeader = () => {
+ const tx_cnt = useAppSelector(state => state.app.txTotalItems)
+ const txt_cnt_f = (tx_cnt ?? 0).toLocaleString('en-US')
+
+ const [showTx, setShowTx] = useState(false);
+
+ return (
+ <div className="w-full bg-blue-600 text-white rounded text-left">
+ <div className="hover:cursor-pointer hover:bg-blue-700 transition-colors duration-200"
+ onClick={() => setShowTx(!showTx)}>
+
+ <h2 className="text-xs font-semibold rounded flex justify-between
+ py-1 px-1"
+ onClick={() => setShowTx(!showTx)}
+ >
+ <span>Total Transactions:</span> <span>{txt_cnt_f}</span>
+ </h2>
+
+ <h4 className="text-[10px] text-center italic text-blue-300 py-1"
+ onClick={() => setShowTx(!showTx)}
+ >
+ (Click to {showTx ? 'hide' : 'show'})
+ </h4>
+
+ </div>
+
+ {showTx && (
+ <>
+ <WalletTransactions />
+ </>
+ )}
+
+ </div>
+ )
+};
+
+
+const WalletTransactions = () => {
+ const dispatch = useAppDispatch()
+ const bpuid = useAppSelector(state => state.app.bpuid)
+ const userLedgerTxs = useAppSelector(state => state.app.userLedgerTxs);
+
+ const txPagination = useAppSelector(state => state.app.txPagination);
+ const txTotalPages = useAppSelector(state => state.app.txTotalPages);
+
+ useEffect(() => {
+ if (!bpuid) return;
+
+ new WalletApi().getUserTransactionHistoryProductIdTransactionHistoryGet(
+ bpid, // productId
+ bpuid, // bpuid
+ undefined, // createdAfter
+ undefined, // createdBefore
+ "-created", // orderBy
+ txPagination.pageIndex + 1, // page
+ txPagination.pageSize // pageSize
+ ).then(res => {
+ const response = res.data as UserLedgerTransactionsResponse;
+ console.log("Wallet: fetched user ledger", response);
+ dispatch(setUserLedgerSummary(response.summary));
+
+ dispatch(setUserLedgerTxs(response.transactions ?? []))
+ dispatch(setTxTotalItems(response.total!))
+ dispatch(setTxTotalPages(response.pages!))
+ });
+
+ }, [bpuid, txPagination.pageIndex, txPagination.pageSize]);
+
+ const columnHelper = createColumnHelper<UserLedgerTransactionsResponseTransactionsInner>()
+ const columns = [
+ columnHelper.accessor('created', {
+ header: () => 'Date',
+ cell: (info) => moment(info.getValue()).format('M/D/YY H:mm'),
+ size: 110,
+ meta: {
+ align: 'left'
+ }
+ }),
+ columnHelper.accessor('description', {
+ header: () => 'Type',
+ cell: props => {
+ const desc = props.getValue();
+ switch (desc) {
+ case 'Task Complete':
+ return 'Survey 🎉';
+ case 'Task Adjustment':
+ return 'Reject ⚠️';
+ case 'Compensation Bonus':
+ return 'Bonus 🎁';
+ case 'HIT Bonus':
+ return 'Bonus';
+ case 'HIT Reward':
+ return 'Assignment';
+ default:
+ return desc;
+ }
+ },
+ size: 80,
+ meta: {
+ align: 'left'
+ }
+ }),
+ columnHelper.accessor('amount', {
+ header: () => 'Amount',
+ cell: (props) => {
+ const val = props.renderValue() as number;
+ const isPositive = val >= 0;
+ const emoji = isPositive ? "⬆\uFE0E" : "⬇\uFE0E";
+ const colorClass = isPositive ? 'font-variant-emoji-text text-emerald-300' : 'font-variant-emoji-text text-rose-300';
+
+ return (
+ <span className={colorClass}>
+ {`${emoji} ${formatCentsToUSD(Math.abs(val))}`}
+ </span>
+ )
+ },
+ size: 70,
+ meta: {
+ align: 'center'
+ }
+ }),
+ columnHelper.accessor('balance_after', {
+ header: () => 'Balance',
+ cell: (props) => {
+ const val = props.renderValue() as number;
+ const isPositive = val >= 0;
+ const colorClass = isPositive ? 'text-emerald-300' : 'text-rose-300';
+
+ return (
+ <>
+ {isPositive ? (
+ <span className={colorClass}>
+ {`${formatCentsToUSD(Math.abs(val))}`}
+ </span>
+ ) : (
+ <span className={colorClass}>
+ {`(${formatCentsToUSD(Math.abs(val))})`}
+ </span>
+ )}
+ </>
+ )
+ },
+ size: 70,
+ meta: {
+ align: 'center'
+ }
+ }),
+ ]
+
+ const table = useReactTable({
+ 'data': userLedgerTxs,
+ 'columns': columns,
+ getCoreRowModel: getCoreRowModel(),
+ manualPagination: true,
+ pageCount: txTotalPages,
+ onPaginationChange: (updater) => {
+ dispatch(setTxPagination(
+ typeof updater === 'function' ? updater(txPagination) : updater
+ ));
+ },
+ state: {
+ pagination: txPagination,
+ },
+ });
+
+ return (
+ <div className="w-full border-t-1 border-blue-800 p-2">
+ <table className="text-xs table-fixed border-collapse">
+ <thead className="font-bold">
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ <tr key={headerGroup.id}>
+ {headerGroup.headers.map((header) => {
+ const align = header.column.columnDef.meta?.align || 'left';
+ const alignClass = align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left';
+
+ return (
+ <th
+ key={header.id}
+ className={`p-0 m-0 ${alignClass}`}
+ style={{
+ width: `${header.column.getSize()}px`,
+ }}
+ >
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+ </th>
+ )
+ })}
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {table.getRowModel().rows.map((row) => (
+ <tr key={row.id}>
+ {row.getVisibleCells().map((cell) => {
+ const align = cell.column.columnDef.meta?.align || 'left';
+ const alignClass = align === 'center' ? 'text-center' :
+ align === 'right' ? 'text-right' : 'text-left';
+
+ return (
+ <td key={cell.id}
+ className={`p-0 ${alignClass}`}
+ style={{
+ width: `${cell.column.getSize()}px`,
+ }}
+ >
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </td>
+ )
+ })}
+ </tr>
+ ))}
+ </tbody>
+
+ <tfoot>
+ <tr>
+ <td colSpan={columns.length} className="p-2">
+ <div className="flex items-center justify-between text-xs">
+
+ {/* Page info */}
+ <div className="text-blue-50">
+ Page {table.getState().pagination.pageIndex + 1} of{' '}
+ {table.getPageCount()}
+ </div>
+
+ {/* Navigation buttons */}
+ <div className="flex gap-8 text-xs">
+ <button
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ className="px-3 py-1 border border-blue-200 rounded
+ font-variant-emoji-text
+ hover:cursor-pointer hover:bg-blue-700
+ disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {'⬅\uFE0E'}
+ </button>
+ <button
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ className="px-3 py-1 border border-blue-200 rounded
+ font-variant-emoji-text
+ hover:cursor-pointer hover:bg-blue-700
+ disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {'⮕\uFE0E'}
+ </button>
+ </div>
+
+ {/* Page size selector */}
+ <select
+ value={table.getState().pagination.pageSize}
+ onChange={e => table.setPageSize(Number(e.target.value))}
+ className="px-2 py-1 border border-blue-200 rounded
+ hover:cursor-pointer hover:bg-blue-700"
+ >
+ {[10, 20, 30, 40].map(pageSize => (
+ <option key={pageSize} value={pageSize}>
+ Show {pageSize}
+ </option>
+ ))}
+ </select>
+ </div>
+ </td>
+ </tr>
+ </tfoot>
+
+ </table>
+ </div>
+ )
+};
+
+
+const Wallet = () => {
+ const dispatch = useAppDispatch()
+ const bpuid = useAppSelector(state => state.app.bpuid)
+ const pagination = useAppSelector(state => state.app.txPagination);
+
+ useEffect(() => {
+ if (!bpuid) return;
+
+ new WalletApi().getUserTransactionHistoryProductIdTransactionHistoryGet(
+ bpid, // productId
+ bpuid, // bpuid
+ undefined, // createdAfter
+ undefined, // createdBefore
+ "-created", // orderBy
+ pagination.pageIndex + 1, // page
+ pagination.pageSize // pageSize
+ ).then(res => {
+ const response = res.data as UserLedgerTransactionsResponse;
+ dispatch(setUserLedgerSummary(response.summary));
+ dispatch(setUserLedgerTxs(response.transactions ?? []))
+
+ dispatch(setTxPagination({
+ pageIndex: (response.page ?? 1) - 1,
+ pageSize: response.size ?? 10,
+ }))
+ dispatch(setTxTotalItems(response.total ?? 0))
+ dispatch(setTxTotalPages(response.pages ?? 9))
+ });
+
+ }, [bpuid])
+
+ if (!bpuid) return null;
+
+ return (
+ <div className="fixed top-2 left-2 p-2 rounded
+ bg-blue-500 text-white shadow-lg z-50
+ font-mono
+ max-h-[calc(100vh-2.5rem)] overflow-y-auto
+ max-w-[96vw]
+ ">
+ <WalletHeader />
+ <WalletSummary />
+ <WalletTransactionsHeader />
+ </div>
+ )
+};
+
+
+export default Wallet; \ No newline at end of file
diff --git a/jb-ui/src/components/ui/button.tsx b/jb-ui/src/components/ui/button.tsx
new file mode 100644
index 0000000..b5ea4ab
--- /dev/null
+++ b/jb-ui/src/components/ui/button.tsx
@@ -0,0 +1,64 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps<typeof buttonVariants> & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+ <Comp
+ data-slot="button"
+ data-variant={variant}
+ data-size={size}
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ />
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/jb-ui/src/components/ui/checkbox.tsx b/jb-ui/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..0e2a6cd
--- /dev/null
+++ b/jb-ui/src/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+ return (
+ <CheckboxPrimitive.Root
+ data-slot="checkbox"
+ className={cn(
+ "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ data-slot="checkbox-indicator"
+ className="grid place-content-center text-current transition-none"
+ >
+ <CheckIcon className="size-3.5" />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+ )
+}
+
+export { Checkbox }
diff --git a/jb-ui/src/components/ui/confetti.tsx b/jb-ui/src/components/ui/confetti.tsx
new file mode 100644
index 0000000..e0d353a
--- /dev/null
+++ b/jb-ui/src/components/ui/confetti.tsx
@@ -0,0 +1,146 @@
+import type { ReactNode } from "react"
+import React, {
+ createContext,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+} from "react"
+import type {
+ GlobalOptions as ConfettiGlobalOptions,
+ CreateTypes as ConfettiInstance,
+ Options as ConfettiOptions,
+} from "canvas-confetti"
+import confetti from "canvas-confetti"
+
+import { Button } from "@/components/ui/button"
+
+type Api = {
+ fire: (options?: ConfettiOptions) => void
+}
+
+type Props = React.ComponentPropsWithRef<"canvas"> & {
+ options?: ConfettiOptions
+ globalOptions?: ConfettiGlobalOptions
+ manualstart?: boolean
+ children?: ReactNode
+}
+
+export type ConfettiRef = Api | null
+
+const ConfettiContext = createContext<Api>({} as Api)
+
+// Define component first
+const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
+ const {
+ options,
+ globalOptions = { resize: true, useWorker: true },
+ manualstart = false,
+ children,
+ ...rest
+ } = props
+ const instanceRef = useRef<ConfettiInstance | null>(null)
+
+ const canvasRef = useCallback(
+ (node: HTMLCanvasElement) => {
+ if (node !== null) {
+ if (instanceRef.current) return
+ instanceRef.current = confetti.create(node, {
+ ...globalOptions,
+ resize: true,
+ })
+ } else {
+ if (instanceRef.current) {
+ instanceRef.current.reset()
+ instanceRef.current = null
+ }
+ }
+ },
+ [globalOptions]
+ )
+
+ const fire = useCallback(
+ async (opts = {}) => {
+ try {
+ await instanceRef.current?.({ ...options, ...opts })
+ } catch (error) {
+ console.error("Confetti error:", error)
+ }
+ },
+ [options]
+ )
+
+ const api = useMemo(
+ () => ({
+ fire,
+ }),
+ [fire]
+ )
+
+ useImperativeHandle(ref, () => api, [api])
+
+ useEffect(() => {
+ if (!manualstart) {
+ ;(async () => {
+ try {
+ await fire()
+ } catch (error) {
+ console.error("Confetti effect error:", error)
+ }
+ })()
+ }
+ }, [manualstart, fire])
+
+ return (
+ <ConfettiContext.Provider value={api}>
+ <canvas ref={canvasRef} {...rest} />
+ {children}
+ </ConfettiContext.Provider>
+ )
+})
+
+// Set display name immediately
+ConfettiComponent.displayName = "Confetti"
+
+// Export as Confetti
+export const Confetti = ConfettiComponent
+
+interface ConfettiButtonProps extends React.ComponentProps<"button"> {
+ options?: ConfettiOptions &
+ ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }
+}
+
+const ConfettiButtonComponent = ({
+ options,
+ children,
+ ...props
+}: ConfettiButtonProps) => {
+ const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
+ try {
+ const rect = event.currentTarget.getBoundingClientRect()
+ const x = rect.left + rect.width / 2
+ const y = rect.top + rect.height / 2
+ await confetti({
+ ...options,
+ origin: {
+ x: x / window.innerWidth,
+ y: y / window.innerHeight,
+ },
+ })
+ } catch (error) {
+ console.error("Confetti button error:", error)
+ }
+ }
+
+ return (
+ <Button onClick={handleClick} {...props}>
+ {children}
+ </Button>
+ )
+}
+
+ConfettiButtonComponent.displayName = "ConfettiButton"
+
+export const ConfettiButton = ConfettiButtonComponent
diff --git a/jb-ui/src/components/ui/dot-pattern.tsx b/jb-ui/src/components/ui/dot-pattern.tsx
new file mode 100644
index 0000000..9522b7f
--- /dev/null
+++ b/jb-ui/src/components/ui/dot-pattern.tsx
@@ -0,0 +1,156 @@
+import React, { useEffect, useId, useRef, useState } from "react"
+import { motion } from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+/**
+ * DotPattern Component Props
+ *
+ * @param {number} [width=16] - The horizontal spacing between dots
+ * @param {number} [height=16] - The vertical spacing between dots
+ * @param {number} [x=0] - The x-offset of the entire pattern
+ * @param {number} [y=0] - The y-offset of the entire pattern
+ * @param {number} [cx=1] - The x-offset of individual dots
+ * @param {number} [cy=1] - The y-offset of individual dots
+ * @param {number} [cr=1] - The radius of each dot
+ * @param {string} [className] - Additional CSS classes to apply to the SVG container
+ * @param {boolean} [glow=false] - Whether dots should have a glowing animation effect
+ */
+interface DotPatternProps extends React.SVGProps<SVGSVGElement> {
+ width?: number
+ height?: number
+ x?: number
+ y?: number
+ cx?: number
+ cy?: number
+ cr?: number
+ className?: string
+ glow?: boolean
+ [key: string]: unknown
+}
+
+/**
+ * DotPattern Component
+ *
+ * A React component that creates an animated or static dot pattern background using SVG.
+ * The pattern automatically adjusts to fill its container and can optionally display glowing dots.
+ *
+ * @component
+ *
+ * @see DotPatternProps for the props interface.
+ *
+ * @example
+ * // Basic usage
+ * <DotPattern />
+ *
+ * // With glowing effect and custom spacing
+ * <DotPattern
+ * width={20}
+ * height={20}
+ * glow={true}
+ * className="opacity-50"
+ * />
+ *
+ * @notes
+ * - The component is client-side only ("use client")
+ * - Automatically responds to container size changes
+ * - When glow is enabled, dots will animate with random delays and durations
+ * - Uses Motion for animations
+ * - Dots color can be controlled via the text color utility classes
+ */
+
+export function DotPattern({
+ width = 16,
+ height = 16,
+ x = 0,
+ y = 0,
+ cx = 1,
+ cy = 1,
+ cr = 1,
+ className,
+ glow = false,
+ ...props
+}: DotPatternProps) {
+ const id = useId()
+ const containerRef = useRef<SVGSVGElement>(null)
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
+
+ useEffect(() => {
+ const updateDimensions = () => {
+ if (containerRef.current) {
+ const { width, height } = containerRef.current.getBoundingClientRect()
+ setDimensions({ width, height })
+ }
+ }
+
+ updateDimensions()
+ window.addEventListener("resize", updateDimensions)
+ return () => window.removeEventListener("resize", updateDimensions)
+ }, [])
+
+ const dots = Array.from(
+ {
+ length:
+ Math.ceil(dimensions.width / width) *
+ Math.ceil(dimensions.height / height),
+ },
+ (_, i) => {
+ const col = i % Math.ceil(dimensions.width / width)
+ const row = Math.floor(i / Math.ceil(dimensions.width / width))
+ return {
+ x: col * width + cx,
+ y: row * height + cy,
+ delay: Math.random() * 5,
+ duration: Math.random() * 3 + 2,
+ }
+ }
+ )
+
+ return (
+ <svg
+ ref={containerRef}
+ aria-hidden="true"
+ className={cn(
+ "pointer-events-none absolute inset-0 h-full w-full text-neutral-400/80",
+ className
+ )}
+ {...props}
+ >
+ <defs>
+ <radialGradient id={`${id}-gradient`}>
+ <stop offset="0%" stopColor="currentColor" stopOpacity="1" />
+ <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
+ </radialGradient>
+ </defs>
+ {dots.map((dot) => (
+ <motion.circle
+ key={`${dot.x}-${dot.y}`}
+ cx={dot.x}
+ cy={dot.y}
+ r={cr}
+ fill={glow ? `url(#${id}-gradient)` : "currentColor"}
+ initial={glow ? { opacity: 0.4, scale: 1 } : {}}
+ animate={
+ glow
+ ? {
+ opacity: [0.4, 1, 0.4],
+ scale: [1, 1.5, 1],
+ }
+ : {}
+ }
+ transition={
+ glow
+ ? {
+ duration: dot.duration,
+ repeat: Infinity,
+ repeatType: "reverse",
+ delay: dot.delay,
+ ease: "easeInOut",
+ }
+ : {}
+ }
+ />
+ ))}
+ </svg>
+ )
+}
diff --git a/jb-ui/src/components/ui/field.tsx b/jb-ui/src/components/ui/field.tsx
new file mode 100644
index 0000000..db0dc12
--- /dev/null
+++ b/jb-ui/src/components/ui/field.tsx
@@ -0,0 +1,246 @@
+import { useMemo } from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+import { Separator } from "@/components/ui/separator"
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ <fieldset
+ data-slot="field-set"
+ className={cn(
+ "flex flex-col gap-6",
+ "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+ <legend
+ data-slot="field-legend"
+ data-variant={variant}
+ className={cn(
+ "mb-3 font-medium",
+ "data-[variant=legend]:text-base",
+ "data-[variant=label]:text-sm",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-group"
+ className={cn(
+ "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const fieldVariants = cva(
+ "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
+ {
+ variants: {
+ orientation: {
+ vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
+ horizontal: [
+ "flex-row items-center",
+ "[&>[data-slot=field-label]]:flex-auto",
+ "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ ],
+ responsive: [
+ "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
+ "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
+ "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ ],
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ }
+)
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
+ return (
+ <div
+ role="group"
+ data-slot="field"
+ data-orientation={orientation}
+ className={cn(fieldVariants({ orientation }), className)}
+ {...props}
+ />
+ )
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-content"
+ className={cn(
+ "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps<typeof Label>) {
+ return (
+ <Label
+ data-slot="field-label"
+ className={cn(
+ "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
+ "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-label"
+ className={cn(
+ "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ <p
+ data-slot="field-description"
+ className={cn(
+ "text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
+ "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
+ "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+ <div
+ data-slot="field-separator"
+ data-content={!!children}
+ className={cn(
+ "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
+ className
+ )}
+ {...props}
+ >
+ <Separator className="absolute inset-0 top-1/2" />
+ {children && (
+ <span
+ className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
+ data-slot="field-separator-content"
+ >
+ {children}
+ </span>
+ )}
+ </div>
+ )
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children
+ }
+
+ if (!errors?.length) {
+ return null
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ]
+
+ if (uniqueErrors?.length == 1) {
+ return uniqueErrors[0]?.message
+ }
+
+ return (
+ <ul className="ml-4 flex list-disc flex-col gap-1">
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && <li key={index}>{error.message}</li>
+ )}
+ </ul>
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+ <div
+ role="alert"
+ data-slot="field-error"
+ className={cn("text-destructive text-sm font-normal", className)}
+ {...props}
+ >
+ {content}
+ </div>
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/jb-ui/src/components/ui/label.tsx b/jb-ui/src/components/ui/label.tsx
new file mode 100644
index 0000000..ef7133a
--- /dev/null
+++ b/jb-ui/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+ return (
+ <LabelPrimitive.Root
+ data-slot="label"
+ className={cn(
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Label }
diff --git a/jb-ui/src/components/ui/magic-card.tsx b/jb-ui/src/components/ui/magic-card.tsx
new file mode 100644
index 0000000..e6503d8
--- /dev/null
+++ b/jb-ui/src/components/ui/magic-card.tsx
@@ -0,0 +1,101 @@
+import React, { useCallback, useEffect } from "react"
+import { motion, useMotionTemplate, useMotionValue } from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+interface MagicCardProps {
+ children?: React.ReactNode
+ className?: string
+ gradientSize?: number
+ gradientColor?: string
+ gradientOpacity?: number
+ gradientFrom?: string
+ gradientTo?: string
+}
+
+export function MagicCard({
+ children,
+ className,
+ gradientSize = 200,
+ gradientColor = "#262626",
+ gradientOpacity = 0.8,
+ gradientFrom = "#9E7AFF",
+ gradientTo = "#FE8BBB",
+}: MagicCardProps) {
+ const mouseX = useMotionValue(-gradientSize)
+ const mouseY = useMotionValue(-gradientSize)
+ const reset = useCallback(() => {
+ mouseX.set(-gradientSize)
+ mouseY.set(-gradientSize)
+ }, [gradientSize, mouseX, mouseY])
+
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const rect = e.currentTarget.getBoundingClientRect()
+ mouseX.set(e.clientX - rect.left)
+ mouseY.set(e.clientY - rect.top)
+ },
+ [mouseX, mouseY]
+ )
+
+ useEffect(() => {
+ reset()
+ }, [reset])
+
+ useEffect(() => {
+ const handleGlobalPointerOut = (e: PointerEvent) => {
+ if (!e.relatedTarget) {
+ reset()
+ }
+ }
+
+ const handleVisibility = () => {
+ if (document.visibilityState !== "visible") {
+ reset()
+ }
+ }
+
+ window.addEventListener("pointerout", handleGlobalPointerOut)
+ window.addEventListener("blur", reset)
+ document.addEventListener("visibilitychange", handleVisibility)
+
+ return () => {
+ window.removeEventListener("pointerout", handleGlobalPointerOut)
+ window.removeEventListener("blur", reset)
+ document.removeEventListener("visibilitychange", handleVisibility)
+ }
+ }, [reset])
+
+ return (
+ <div
+ className={cn("group relative rounded-[inherit]", className)}
+ onPointerMove={handlePointerMove}
+ onPointerLeave={reset}
+ onPointerEnter={reset}
+ >
+ <motion.div
+ className="bg-border pointer-events-none absolute inset-0 rounded-[inherit] duration-300 group-hover:opacity-100"
+ style={{
+ background: useMotionTemplate`
+ radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
+ ${gradientFrom},
+ ${gradientTo},
+ var(--border) 100%
+ )
+ `,
+ }}
+ />
+ <div className="bg-background absolute inset-px rounded-[inherit]" />
+ <motion.div
+ className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
+ style={{
+ background: useMotionTemplate`
+ radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
+ `,
+ opacity: gradientOpacity,
+ }}
+ />
+ <div className="relative">{children}</div>
+ </div>
+ )
+}
diff --git a/jb-ui/src/components/ui/pagination.tsx b/jb-ui/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..1dcfb0c
--- /dev/null
+++ b/jb-ui/src/components/ui/pagination.tsx
@@ -0,0 +1,127 @@
+import * as React from "react"
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ MoreHorizontalIcon,
+} from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants, type Button } from "@/components/ui/button"
+
+function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+ <nav
+ role="navigation"
+ aria-label="pagination"
+ data-slot="pagination"
+ className={cn("mx-auto flex w-full justify-center", className)}
+ {...props}
+ />
+ )
+}
+
+function PaginationContent({
+ className,
+ ...props
+}: React.ComponentProps<"ul">) {
+ return (
+ <ul
+ data-slot="pagination-content"
+ className={cn("flex flex-row items-center gap-1", className)}
+ {...props}
+ />
+ )
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+ return <li data-slot="pagination-item" {...props} />
+}
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick<React.ComponentProps<typeof Button>, "size"> &
+ React.ComponentProps<"a">
+
+function PaginationLink({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) {
+ return (
+ <a
+ aria-current={isActive ? "page" : undefined}
+ data-slot="pagination-link"
+ data-active={isActive}
+ className={cn(
+ buttonVariants({
+ variant: isActive ? "outline" : "ghost",
+ size,
+ }),
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function PaginationPrevious({
+ className,
+ ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+ return (
+ <PaginationLink
+ aria-label="Go to previous page"
+ size="default"
+ className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
+ {...props}
+ >
+ <ChevronLeftIcon />
+ <span className="hidden sm:block">Previous</span>
+ </PaginationLink>
+ )
+}
+
+function PaginationNext({
+ className,
+ ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+ return (
+ <PaginationLink
+ aria-label="Go to next page"
+ size="default"
+ className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
+ {...props}
+ >
+ <span className="hidden sm:block">Next</span>
+ <ChevronRightIcon />
+ </PaginationLink>
+ )
+}
+
+function PaginationEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ aria-hidden
+ data-slot="pagination-ellipsis"
+ className={cn("flex size-9 items-center justify-center", className)}
+ {...props}
+ >
+ <MoreHorizontalIcon className="size-4" />
+ <span className="sr-only">More pages</span>
+ </span>
+ )
+}
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
diff --git a/jb-ui/src/components/ui/separator.tsx b/jb-ui/src/components/ui/separator.tsx
new file mode 100644
index 0000000..275381c
--- /dev/null
+++ b/jb-ui/src/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+ return (
+ <SeparatorPrimitive.Root
+ data-slot="separator"
+ decorative={decorative}
+ orientation={orientation}
+ className={cn(
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Separator }
diff --git a/jb-ui/src/components/ui/skeleton.tsx b/jb-ui/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..32ea0ef
--- /dev/null
+++ b/jb-ui/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="skeleton"
+ className={cn("bg-accent animate-pulse rounded-md", className)}
+ {...props}
+ />
+ )
+}
+
+export { Skeleton }