diff options
Diffstat (limited to 'jb-ui/src/components/Redemption.tsx')
| -rw-r--r-- | jb-ui/src/components/Redemption.tsx | 981 |
1 files changed, 964 insertions, 17 deletions
diff --git a/jb-ui/src/components/Redemption.tsx b/jb-ui/src/components/Redemption.tsx index 1d76f17..2d0380e 100644 --- a/jb-ui/src/components/Redemption.tsx +++ b/jb-ui/src/components/Redemption.tsx @@ -1,42 +1,990 @@ import { clsx } from "clsx"; -import { useState } from "react"; -// import { useSelector } from "react-redux"; -// import { cashoutMethodsSelectors } from "@/models/cashoutMethodsSlice"; +import { + bpid, + formatCentsToUSD, + truncate, + stripPriceFromTitle, +} from "@/lib/utils"; +import moment from "moment"; +import { useAppDispatch, useAppSelector } from "@/hooks"; +import { useState, useEffect } from "react"; +import { useGetMeQuery } from "@/models/authAPI"; +import { + CashoutMethodOut, + CashoutMethodsResponse, + WalletApi, + CashoutRequestResponse, + CashoutRequestSummary, +} from "@/api_fsb"; +import { WalletApi as JBApi, CashoutRequest } from "@/api_jb"; +import { List, IdCard, PackageCheck } from "lucide-react"; +import { + addCashoutMethods, + selectAllCashoutMethods, +} from "@/models/cashoutMethodsSlice"; +import { setCmSelected, setCmShowHistory } from "@/models/appSlice"; +import { + createColumnHelper, + getCoreRowModel, + getPaginationRowModel, + useReactTable, + getFilteredRowModel, + getSortedRowModel, + flexRender, + RowSelectionState, + FilterFn, +} from "@tanstack/react-table"; +import { useSelector } from "react-redux"; +import type { Row } from "@tanstack/react-table"; + +import { useGetUserCashoutsQuery } from "@/models/cashoutRequests"; const RedemptionHeader = () => { - // const cashout_methods = useSelector(cashoutMethodsSelectors.selectAll); + const userCashoutMethods = useSelector(selectAllCashoutMethods) || []; return ( <> <h2 className="text-xs font-mono font-bold p-0 m-0"> - {/* {cashout_methods.length} Redemption Methods */} - 30+ Redemption Methods + {userCashoutMethods.length} + {userCashoutMethods.length != null ? "+" : ""} Redemption Methods </h2> </> ); }; -const RedemptionModalBody = () => { +interface CashoutMethodListItemDescriptionProps { + description?: string; +} + +function CashoutMethodListItemDescription({ + description, +}: CashoutMethodListItemDescriptionProps) { + return ( + <div + className=" + text-[8px] text-left + bg-blue-600 + max-h-24 overflow-y-auto + [&::-webkit-scrollbar]:h-1 + [&::-webkit-scrollbar]:w-1 + [&::-webkit-scrollbar-track]:bg-blue-100 + [&::-webkit-scrollbar-thumb]:bg-blue-300 + " + dangerouslySetInnerHTML={{ __html: description! }} + /> + ); +} + +interface CashoutMethodListItemProps { + row: Row<CashoutMethodOut>; +} + +interface CashoutMethodItemAmountProps { + cm: CashoutMethodOut; +} + +const CashoutMethodItemAmount = ({ cm }: CashoutMethodItemAmountProps) => { + const cardRange = cm.min_value !== cm.max_value; + + if (cardRange) { + return ( + <> + <div className="label text-left">Min Amount:</div> + <div className="value text-right"> + {formatCentsToUSD(cm.min_value_usd || 0)} + </div> + + <div className="label text-left">Max Amount:</div> + <div className="value text-right"> + {formatCentsToUSD(cm.max_value_usd || 0)} + </div> + </> + ); + } else { + return ( + <> + <div className="label text-left">Amount:</div> + <div className="value text-right"> + {formatCentsToUSD(cm.min_value_usd || 0)} + </div> + </> + ); + } +}; + +function CashoutMethodListItem({ row }: CashoutMethodListItemProps) { + const [showDescription, setShowDescription] = useState(false); + + return ( + <div + key={row.original.id} + className={clsx( + "transition-colors my-1", + row.getIsSelected() ? "border-red-500" : "border-blue-50", + )} + > + <div + className="cm-item-body grid grid-cols-2 px-2" + style={{ + gap: 2, + padding: 2, + }} + > + <div> + <h2 + className="cm-item-head text-xs font-semibold rounded flex justify-between + py-1 px-0" + > + {stripPriceFromTitle(row.original.name)} + </h2> + <div + className="grid grid-cols-[auto_1fr] + text-xs font-mono + px-2" + > + <CashoutMethodItemAmount cm={row.original} /> + </div> + + <button + className="cursor-pointer + text-blue-200 text-sm px-3 py-1/2 + + bg-blue-600 + hover:bg-blue-700 hover:text-blue-50 hover:border-transparent + rounded + " + onClick={() => row.toggleSelected()} + > + Redeem + </button> + </div> + + <div + className="group relative inline-flex items-center justify-center + p-3 rounded-sm + bg-gradient-from-center + transition-all duration-300 + " + > + {/* animated glow blob behind the image */} + <div + className=" + absolute inset-0 rounded-2xl + opacity-0 group-hover:opacity-20 + blur-xl transition-opacity duration-300" + /> + + <img + src={row.original.image_url!} + alt={row.original.name} + style={{ height: 60 }} + className="relative z-10 drop-shadow-md + text-center align-middle + group-hover:drop-shadow-lg transition-all duration-300 group-hover:scale-105" + /> + </div> + </div> + + {/* Not every CashoutMethod has a Description */} + {row.original.description && ( + <div + className="cm-item-bottom + m-2 px-2 py-1 + bg-blue-600" + > + <p + className=" + text-[8px] font-semibold + cursor-pointer text-right + hover:underline" + onClick={() => setShowDescription(!showDescription)} + > + Show Description + </p> + + {showDescription && ( + <CashoutMethodListItemDescription + description={row.original.description} + /> + )} + </div> + )} + </div> + ); +} + +interface CashoutAmountInputProps { + reqAmount: number; // cents + setRequestAmount: (cents: number) => void; + maxCents: number; // max withdrawable, in cents +} + +const RedemptionDetailAmountEnter = ({ + reqAmount, + setRequestAmount, + maxCents, +}: CashoutAmountInputProps) => { + // Local text state so users can type freely (e.g. "12.", "0.5") + // without fighting a reformatted value every keystroke. + const [displayValue, setDisplayValue] = useState( + reqAmount ? (reqAmount / 100).toString() : "", + ); + + const overLimit = reqAmount > maxCents; + + const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const raw = e.target.value; + + // Allow only digits and a single decimal point with up to 2 decimals + if (!/^\d*\.?\d{0,2}$/.test(raw)) return; + + setDisplayValue(raw); + + const dollars = parseFloat(raw); + setRequestAmount(isNaN(dollars) ? 0 : Math.round(dollars * 100)); + }; + return ( - <div className="grid grid-cols-[auto_1fr] text-xs"> - <div className="label text-center p-2"> - Paypal, Visa, Giftcards will be available. Finishing processing the - transfer from Amazon. + <div className="px-4 "> + <label + className=" + flex items-center + text-xs text-blue-50 border-b + " + htmlFor="amount-input" + > + Amount:{" "} + </label> + + <div className="flex items-left gap-2 min-w-0"> + <span>$</span> + <input + id="amount-input" + className="flex-1 min-w-0 w-full" + type="text" + inputMode="decimal" + value={displayValue} + onChange={handleChange} + placeholder="0.00" + aria-invalid={overLimit} + /> </div> + + {!overLimit && ( + <div className="text-[8px] text-emerald-50"> + Max: ${(maxCents / 100).toFixed(2)} + </div> + )} + + {overLimit && ( + <div + role="alert" + className=" + text-red-400 text-[8px] + font-semibold + " + > + Amount exceeds your available balance of $ + {(maxCents / 100).toFixed(2)}. + </div> + )} </div> ); }; +const RedemptionDetails = () => { + // res = session.post(JB_BASE_URL + 'wallet/cashout/request/', json={ + // 'amount': 10_00, + // 'cashout_method_id': paypal_cm['id'], + // }) + // res.raise_for_status() + // print(res.json()) + const selected = useAppSelector((state) => state.app.cmSelected); + if (!selected) return null; + const dispatch = useAppDispatch(); + + const [reqAmount, setRequestAmount] = useState(selected.min_value); + const [errorMsg, setErrorMesg] = useState(""); + + const { data: user } = useGetMeQuery(); + if (!user?.product_user_id) return; + + const submitCashOut = () => { + const ccr = { + cashout_method_id: selected.id, + amount: reqAmount, + } as CashoutRequest; + + // const config = new Configuration({ basePath: "http://localhost:5174" }); + new JBApi() + .requestCashoutWalletCashoutRequestPost(ccr) + .then(() => { + // TODO: will `res.data.cashout_method` & `res.data.confirmation_link` + // TODO: always be available in this response? + dispatch(setCmSelected(undefined)); + dispatch(setCmShowHistory(true)); + }) + .catch((err) => { + console.log("ERR", err); + const response = err?.response?.data as + | CashoutRequestResponse + | undefined; + setErrorMesg(response?.msg ?? err.message); + }); + }; + + return ( + <> + <div> + <h2 className="text-center cm-item-head text-sm font-semibold py-1"> + {selected.type === "PAYPAL" + ? `${selected.name} (${selected.ext_id})` + : selected.name} + </h2> + </div> + + <div + className=" + cm-item-body grid grid-cols-2 + border-b border-blue-900 + py-2 + " + > + <RedemptionDetailAmountEnter + reqAmount={reqAmount} + setRequestAmount={setRequestAmount} + maxCents={selected.max_value} + /> + + <div className="flex items-center "> + <button + className=" + flex items-center + px-4 py-1 font-semibold text-white bg-blue-600 + cursor-pointer + rounded-lg shadow-md hover:bg-blue-700" + onClick={submitCashOut} + > + Submit + </button> + </div> + </div> + + {errorMsg && ( + <div + className=" + font-mono text-xs + py-1 bg-orange-700 text-red-200" + > + <h3>{errorMsg}</h3> + </div> + )} + + <div className=""> + <p + className=" + text-[8px] white-blue-200 font-mono + px-6 py-1 + " + > + You will recieve an email with confirmation deatils when requesting + the Cashout Method + </p> + </div> + + <div + className="group relative inline-flex items-center justify-center + p-3 rounded-sm + bg-gradient-from-center + transition-all duration-300 + " + > + {/* animated glow blob behind the image */} + <div + className=" + absolute inset-0 rounded-2xl + opacity-0 group-hover:opacity-20 + blur-xl transition-opacity duration-300" + /> + + <img + src={selected.image_url!} + alt={selected.name} + style={{ height: 120 }} + className="relative z-10 drop-shadow-md + text-center align-middle + group-hover:drop-shadow-lg transition-all duration-300 group-hover:scale-105" + /> + </div> + + <div + className="cm-item-bottom + m-2 px-2 py-1 + bg-blue-600" + > + <CashoutMethodListItemDescription description={selected.description} /> + </div> + </> + ); +}; + +const RedemptionBrowser = () => { + // Don't show the browser if a CashoutMethod is already selected + const selected = useAppSelector((state) => state.app.cmSelected); + if (selected) return; + + const dispatch = useAppDispatch(); + const userCashoutMethods = useSelector(selectAllCashoutMethods); + + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const [globalFilter, setGlobalFilter] = useState("CASH"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: user } = useGetMeQuery(); + + useEffect(() => { + if (!user?.product_user_id) return; + + new WalletApi() + .getCashoutMethodsProductIdCashoutMethodsGet(bpid, user.product_user_id) + .then((res) => { + const response = res.data as CashoutMethodsResponse; + dispatch(addCashoutMethods(response.cashout_methods)); + }); + }, [bpid, user?.product_user_id]); + + const columnHelper = createColumnHelper<CashoutMethodOut>(); + const columns = [ + columnHelper.accessor("name", { + header: () => "Name", + }), + columnHelper.accessor("image_url", { + header: () => "Image", + }), + columnHelper.accessor("description", { + header: () => "Desc", + }), + ]; + + useEffect(() => { + const selectedRows = table + .getSelectedRowModel() + .rows.map((r) => r.original); + const cm = selectedRows[0] as CashoutMethodOut; + dispatch(setCmSelected(cm)); + }, [rowSelection]); + + const categoryFilter: FilterFn<CashoutMethodOut> = ( + row, + _columnId, + filterValue, + ) => row.original.category === filterValue; + + const table = useReactTable({ + data: userCashoutMethods || [], + columns: columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.id, + + enableRowSelection: true, + enableMultiRowSelection: false, + + state: { + pagination, + rowSelection, + globalFilter, + }, + + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + globalFilterFn: categoryFilter, + onGlobalFilterChange: setGlobalFilter, + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( + <div> + <div + className=" + flex justify-center items-center + gap-2 my-2" + > + <div + className={clsx( + "rounded-md", + "bg-blue-600 hover:bg-blue-700", + "py-0.5 px-2.5", + "font-mono text-[8px]", + "text-white cursor-pointer", + globalFilter == "CASH" ? "bg-blue-800" : "bg-blue-600", + )} + onClick={() => setGlobalFilter("CASH")} + > + Cash + </div> + + <div + className=" + rounded-md + bg-blue-700 hover:bg-blue-800 + py-0.5 px-2 + font-mono text-[8px] + text-white cursor-not-allowed" + // onClick={() => setGlobalFilter("GIFT_CARD")} + > + Gift Card + </div> + </div> + + <div + className=" + max-h-96 overflow-y-auto + [&::-webkit-scrollbar]:h-1 + [&::-webkit-scrollbar]:w-1 + [&::-webkit-scrollbar-track]:bg-blue-100 + [&::-webkit-scrollbar-thumb]:bg-blue-300 + " + > + {table.getRowModel().rows.map((row) => ( + <CashoutMethodListItem + key={row.original.id} + row={row} + // setRowSelection={setRowSelection} + /> + ))} + </div> + + <div + className=" + px-2 py-1 + 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> + </div> + ); +}; + +const RedemptionModalSteps = () => { + const dispatch = useAppDispatch(); + const selected = useAppSelector((state) => state.app.cmSelected); + const showHistory = useAppSelector((state) => state.app.cmShowHistory); + + return ( + <div + className=" + flex justify-center items-center + border-b border-blue-700 + " + > + <ol + className=" + flex items-center w-1/2 + my-1 + " + > + <li + className={clsx( + "flex w-full items-center cursor-pointer", + "hover:text-blue-50", + !selected ? "text-white" : "text-blue-200", + )} + onClick={() => { + dispatch(setCmSelected(undefined)); + dispatch(setCmShowHistory(false)); + }} + > + <span + className=" + flex items-center justify-center + w-6 h-6 + " + > + <List /> + </span> + </li> + + <li + className={clsx( + "flex w-full items-center", + selected ? "text-white" : "text-blue-400 cursor-not-allowed", + )} + > + <span + className=" + flex items-center justify-center + w-6 h-6 + " + > + <IdCard /> + </span> + </li> + + <li + className={clsx( + "flex items-center", + showHistory ? "text-white" : "text-blue-400", + )} + onClick={() => { + dispatch(setCmSelected(undefined)); + dispatch(setCmShowHistory(true)); + }} + > + <span + className=" + flex items-center justify-center + w-6 h-6 + ml-8 + hover:text-white cursor-pointer + " + > + <PackageCheck /> + </span> + </li> + </ol> + </div> + ); +}; + +const RedemptionHistory = () => { + const { data: user } = useGetMeQuery(); + if (!user?.product_user_id) return; + + const { data: cashout_requests } = useGetUserCashoutsQuery( + { bpid, productUserId: user.product_user_id }, + { skip: !user.product_user_id }, + ); + + // --- Table work now --- + + // const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); + const [globalFilter, setGlobalFilter] = useState(""); + // const [pagination, setPagination] = useState({ + // pageIndex: 0, + // pageSize: 10, + // }); + + const columnHelper = createColumnHelper<CashoutRequestSummary>(); + const columns = [ + columnHelper.accessor("created", { + header: () => "Created", + cell: (info) => moment(info.getValue()).format("M/D/YY"), + size: 60, + meta: { + align: "left", + }, + }), + columnHelper.accessor("description", { + header: () => "Description", + cell: (info) => truncate(info.getValue(), 12), + size: 100, + meta: { + align: "left", + }, + }), + columnHelper.accessor("amount", { + header: () => "Amount", + cell: (info) => formatCentsToUSD(info.getValue()), + size: 80, + meta: { + align: "center", + }, + }), + columnHelper.accessor("status", { + header: () => "Status", + cell: (props) => { + const desc = props.getValue(); + switch (desc) { + case "PENDING": + return "Pending 🤞"; + + case "APPROVED": + return "Approved 👍"; + + case "REJECTED": + return "Rejected 🚫"; + + case "CANCELLED": + return "Cancelled 🚫"; + + case "FAILED": + return "Failed ❌"; + + case "COMPLETE": + return "Complete ✅"; + + default: + return desc; + } + }, + size: 80, + meta: { + align: "left", + }, + }), + ]; + + let table = useReactTable({ + data: cashout_requests || [], + columns: columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.id, + + // enableRowSelection: true, + // enableMultiRowSelection: false, + state: { + // pagination, + // rowSelection, + globalFilter, + }, + // onPaginationChange: setPagination, + // onRowSelectionChange: setRowSelection, + onGlobalFilterChange: setGlobalFilter, + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + const STATUSES = ["PENDING", "APPROVED", "REJECTED", "COMPLETE"]; + + return ( + <div> + <div + className=" + flex justify-center items-center + gap-2 my-2" + > + {STATUSES.map((status) => { + const isActive = globalFilter === status; + + return ( + <div + key={status} + className={clsx( + "rounded-md", + "py-0.5 px-2.5", + "font-mono text-[8px]", + "text-white cursor-pointer", + isActive ? "bg-blue-800" : "bg-blue-600 hover:bg-blue-700", + )} + onClick={() => setGlobalFilter(status)} + > + {status} + </div> + ); + })} + </div> + + <div className="w-full border-t 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> + </div> + ); +}; + +const RedemptionModalBody = () => { + // Show (1) Selection, (2) Amount Application, (3) Confirmation + const selected = useAppSelector((state) => state.app.cmSelected); + const showHistory = useAppSelector((state) => state.app.cmShowHistory); + + if (showHistory) { + return ( + <> + <RedemptionModalSteps /> + <RedemptionHistory /> + </> + ); + } + + if (selected) { + return ( + <> + <RedemptionModalSteps /> + <RedemptionDetails /> + </> + ); + } else { + return ( + <> + <RedemptionModalSteps /> + <RedemptionBrowser /> + </> + ); + } +}; + const Redemption = () => { - const [showRedemption, setShowRedemption] = useState(false); + const [showRedemption, setShowRedemption] = useState(true); + + const { data: user } = useGetMeQuery(); + if (!user?.product_user_id) return null; return ( <div className={clsx( "fixed rounded bg-blue-500 text-white shadow-lg z-60 \ - top-1 sm:top-6 \ + top-2 \ right-1 sm:right-2", - showRedemption ? "w-[98vw] sm:w-[400px]" : "w-[220px]", + showRedemption ? "w-[98vw] sm:w-[320px]" : "w-[220px]", )} > <div @@ -47,9 +995,8 @@ const Redemption = () => { > <RedemptionHeader /> - <h4 className="text-[10px] text-center italic text-white-300 py-0"> - {/* (Click to {showRedemption ? "hide" : "show"}) */} - Details within 24hr. + <h4 className="text-[10px] text-center italic text-blue-300 py-0"> + (Click to {showRedemption ? "hide" : "show"}) </h4> </div> |
