diff options
| author | Max Nanis | 2026-02-18 20:42:03 -0500 |
|---|---|---|
| committer | Max Nanis | 2026-02-18 20:42:03 -0500 |
| commit | 3eaa56f0306ead818f64c3d99fc6d230d9b970a4 (patch) | |
| tree | 9fecc2f1456e6321572e0e65f57106916df173e2 /jb-ui/src/components | |
| download | amt-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.tsx | 36 | ||||
| -rw-r--r-- | jb-ui/src/components/Leaderboard.tsx | 0 | ||||
| -rw-r--r-- | jb-ui/src/components/Profiling.tsx | 759 | ||||
| -rw-r--r-- | jb-ui/src/components/Wallet.tsx | 464 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/button.tsx | 64 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/checkbox.tsx | 30 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/confetti.tsx | 146 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/dot-pattern.tsx | 156 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/field.tsx | 246 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/label.tsx | 22 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/magic-card.tsx | 101 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/pagination.tsx | 127 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/separator.tsx | 28 | ||||
| -rw-r--r-- | jb-ui/src/components/ui/skeleton.tsx | 13 |
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 } |
